Feature/rebrand (#3)

This commit is contained in:
Daniel Lautzenheiser
2022-09-30 06:13:47 -06:00
committed by GitHub
parent 213e51c52d
commit 8acda23acc
416 changed files with 2818 additions and 8793 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
# Docs coming soon!
Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages.
That's over 20 Readme's! We haven't created one for this package yet, but we will soon.
In the meantime, you can:
1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation
site](https://www.netlifycms.org) for more info.
2. Reach out to the [community chat](https://netlifycms.org/chat/) if you need help.
3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-core/README.md)!
# Using Core
```tsx
import React from 'react';
import {
AzureBackend,
BitbucketBackend,
BooleanWidget,
CodeWidget,
ColorStringWidget,
DateTimeWidget,
FileWidget,
GitGatewayBackend,
GitHubBackend,
GitLabBackend,
imageEditorComponent,
ImageWidget,
ListWidget,
MapWidget,
MarkdownWidget,
NetlifyCmsCore as CMS,
NumberWidget,
ObjectWidget,
ProxyBackend,
RelationWidget,
SelectWidget,
StringWidget,
TestBackend,
TextWidget,
locales,
Icon,
images
} from 'netlify-cms-core';
// Register all the things
CMS.registerBackend('git-gateway', GitGatewayBackend);
CMS.registerBackend('azure', AzureBackend);
CMS.registerBackend('github', GitHubBackend);
CMS.registerBackend('gitlab', GitLabBackend);
CMS.registerBackend('bitbucket', BitbucketBackend);
CMS.registerBackend('test-repo', TestBackend);
CMS.registerBackend('proxy', ProxyBackend);
CMS.registerWidget([
StringWidget.Widget(),
NumberWidget.Widget(),
TextWidget.Widget(),
ImageWidget.Widget(),
FileWidget.Widget(),
SelectWidget.Widget(),
MarkdownWidget.Widget(),
ListWidget.Widget(),
ObjectWidget.Widget(),
RelationWidget.Widget(),
BooleanWidget.Widget(),
MapWidget.Widget(),
DateTimeWidget.Widget(),
CodeWidget.Widget(),
ColorStringWidget.Widget(),
]);
CMS.registerEditorComponent(imageEditorComponent);
CMS.registerEditorComponent({
id: 'code-block',
label: 'Code Block',
widget: 'code',
type: 'code-block',
});
CMS.registerLocale('en', locales.en);
Object.keys(images).forEach(iconName => {
CMS.registerIcon(iconName, <Icon type={iconName} />);
});
```

View File

@ -1,962 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module 'netlify-cms-core' {
import type { Iterable as ImmutableIterable, List, Map } from 'immutable';
import type { ComponentType, FocusEventHandler, ReactNode } from 'react';
import type { t } from 'react-polyglot';
import type { Pluggable } from 'unified';
export type CmsBackendType =
| 'azure'
| 'git-gateway'
| 'github'
| 'gitlab'
| '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 =
| 'yml'
| 'yaml'
| 'toml'
| 'json'
| 'frontmatter'
| 'yaml-frontmatter'
| 'toml-frontmatter'
| 'json-frontmatter';
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;
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;
}
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;
editor?: {
preview?: boolean;
};
}
export interface ViewFilter {
label: string;
field: string;
pattern: string;
}
export interface ViewGroup {
label: string;
field: string;
pattern?: string;
}
export type SortDirection = 'Ascending' | 'Descending' | 'None';
export interface CmsSortableFieldsDefault {
field: string;
direction?: SortDirection;
}
export interface CmsSortableFields {
default?: CmsSortableFieldsDefault;
fields: string[];
}
export interface CmsCollection {
name: string;
icon?: 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;
};
publish?: boolean;
nested?: {
depth: number;
};
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?: CmsSortableFields;
view_filters?: ViewFilter[];
view_groups?: ViewGroup[];
i18n?: boolean | CmsI18nConfig;
}
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;
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 interface EditorComponentField {
name: string;
label: string;
widget: string;
}
export interface EditorComponentWidgetOptions {
id: string;
label: string;
widget: string;
type: string;
}
export interface EditorComponentManualOptions {
id: string;
label: string;
fields: EditorComponentField[];
pattern: RegExp;
allow_add?: boolean;
fromBlock: (match: RegExpMatchArray) => any;
toBlock: (data: any) => string;
toPreview: (data: any) => string;
}
export type EditorComponentOptions = EditorComponentManualOptions | EditorComponentWidgetOptions;
export interface PreviewStyleOptions {
raw: boolean;
}
export interface PreviewStyle extends PreviewStyleOptions {
value: string;
}
export type CmsBackendClass = Implementation;
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;
setActiveStyle: FocusEventHandler;
setInactiveStyle: FocusEventHandler;
t: t;
}
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>;
validator?: (props: {
field: Map<string, any>;
value: T | undefined | null;
t: t;
}) => boolean | { error: any } | Promise<boolean | { error: any }>;
globalStyles?: any;
}
export interface CmsWidget<T = any> {
control: ComponentType<CmsWidgetControlProps<T>>;
preview?: ComponentType<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 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;
};
}
type GetAssetFunction = (asset: string) => {
url: string;
path: string;
field?: any;
fileObj: File;
};
export type PreviewTemplateComponentProps = {
entry: Map<string, any>;
collection: 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 CMSApi {
getBackend: (name: string) => CmsRegistryBackend | undefined;
getEditorComponents: () => Map<string, ComponentType<any>>;
getRemarkPlugins: () => Array<Pluggable>;
getLocale: (locale: string) => CmsLocalePhrases | undefined;
getMediaLibrary: (name: string) => CmsMediaLibrary | undefined;
resolveWidget: (name: string) => CmsWidget | 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 | CmsWidgetParam[],
control?: ComponentType<CmsWidgetControlProps> | string,
preview?: ComponentType<CmsWidgetPreviewProps>,
) => void;
registerWidgetValueSerializer: (
widgetName: string,
serializer: CmsWidgetValueSerializer,
) => void;
registerIcon: (iconName: string, icon: ReactNode) => void;
getIcon: (iconName: string) => ReactNode;
registerAdditionalLink: (
id: string,
title: string,
data: string | ComponentType,
iconName?: string,
) => void;
getAdditionalLinks: () => { title: string; data: string | ComponentType; iconName?: string }[];
getAdditionalLink: (
id: string,
) => { title: string; data: string | ComponentType; iconName?: string } | undefined;
}
export const CMS: CMSApi;
export default CMS;
// Backends
export type DisplayURLObject = { id: string; path: string };
export type DisplayURL = DisplayURLObject | string;
export type DataFile = {
path: string;
slug: string;
raw: string;
newPath?: string;
};
export type AssetProxy = {
path: string;
fileObj?: File;
toBase64?: () => Promise<string>;
};
export type Entry = {
dataFiles: DataFile[];
assets: AssetProxy[];
};
export type PersistOptions = {
newEntry?: boolean;
commitMessage: string;
collectionName?: string;
useWorkflow?: boolean;
unpublished?: boolean;
status?: string;
};
export type DeleteOptions = {};
export type Credentials = { token: string | {}; refresh_token?: string };
export type User = Credentials & {
backendName?: string;
login?: string;
name: string;
useOpenAuthoring?: boolean;
};
export interface ImplementationEntry {
data: string;
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
}
export type ImplementationFile = {
id?: string | null | undefined;
label?: string;
path: string;
};
export interface ImplementationMediaFile {
name: string;
id: string;
size?: number;
displayURL?: DisplayURL;
path: string;
draft?: boolean;
url?: string;
file?: File;
}
export interface UnpublishedEntryMediaFile {
id: string;
path: string;
}
export interface UnpublishedEntryDiff {
id: string;
path: string;
newFile: boolean;
}
export interface UnpublishedEntry {
pullRequestAuthor?: string;
slug: string;
collection: string;
status: string;
diffs: UnpublishedEntryDiff[];
updatedAt: string;
}
export type CursorStoreObject = {
actions: Set<string>;
data: Map<string, unknown>;
meta: Map<string, unknown>;
};
export type CursorStore = {
get<K extends keyof CursorStoreObject>(
key: K,
defaultValue?: CursorStoreObject[K],
): CursorStoreObject[K];
getIn<V>(path: string[]): V;
set<K extends keyof CursorStoreObject, V extends CursorStoreObject[K]>(
key: K,
value: V,
): CursorStoreObject[K];
setIn(path: string[], value: unknown): CursorStore;
hasIn(path: string[]): boolean;
mergeIn(path: string[], value: unknown): CursorStore;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: (...args: any[]) => CursorStore;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateIn: (...args: any[]) => CursorStore;
};
export type ActionHandler = (action: string) => unknown;
export class Cursor {
static create(...args: {}[]): Cursor;
updateStore(...args: any[]): Cursor;
updateInStore(...args: any[]): Cursor;
hasAction(action: string): boolean;
addAction(action: string): Cursor;
removeAction(action: string): Cursor;
setActions(actions: Iterable<string>): Cursor;
mergeActions(actions: Set<string>): Cursor;
getActionHandlers(handler: ActionHandler): ImmutableIterable<string, unknown>;
setData(data: {}): Cursor;
mergeData(data: {}): Cursor;
wrapData(data: {}): Cursor;
unwrapData(): [Map<string, unknown>, Cursor];
clearData(): Cursor;
setMeta(meta: {}): Cursor;
mergeMeta(meta: {}): Cursor;
}
class Implementation {
authComponent: () => void;
restoreUser: (user: User) => Promise<User>;
authenticate: (credentials: Credentials) => Promise<User>;
logout: () => Promise<void> | void | null;
getToken: () => Promise<string | null>;
getEntry: (path: string) => Promise<ImplementationEntry>;
entriesByFolder: (
folder: string,
extension: string,
depth: number,
) => Promise<ImplementationEntry[]>;
entriesByFiles: (files: ImplementationFile[]) => Promise<ImplementationEntry[]>;
getMediaDisplayURL?: (displayURL: DisplayURL) => Promise<string>;
getMedia: (folder?: string) => Promise<ImplementationMediaFile[]>;
getMediaFile: (path: string) => Promise<ImplementationMediaFile>;
persistEntry: (entry: Entry, opts: PersistOptions) => Promise<void>;
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
deleteFiles: (paths: string[], commitMessage: string) => Promise<void>;
unpublishedEntries: () => Promise<string[]>;
unpublishedEntry: (args: {
id?: string;
collection?: string;
slug?: string;
}) => Promise<UnpublishedEntry>;
unpublishedEntryDataFile: (
collection: string,
slug: string,
path: string,
id: string,
) => Promise<string>;
unpublishedEntryMediaFile: (
collection: string,
slug: string,
path: string,
id: string,
) => Promise<ImplementationMediaFile>;
updateUnpublishedEntryStatus: (
collection: string,
slug: string,
newStatus: string,
) => Promise<void>;
publishUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
deleteUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
getDeployPreview: (
collectionName: string,
slug: string,
) => Promise<{ url: string; status: string } | null>;
allEntriesByFolder?: (
folder: string,
extension: string,
depth: number,
) => Promise<ImplementationEntry[]>;
traverseCursor?: (
cursor: Cursor,
action: string,
) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>;
isGitBackend?: () => boolean;
status: () => Promise<{
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}>;
}
export const AzureBackend: Implementation;
export const BitbucketBackend: Implementation;
export const GitGatewayBackend: Implementation;
export const GitHubBackend: Implementation;
export const GitLabBackend: Implementation;
export const ProxyBackend: Implementation;
export const TestBackend: Implementation;
// Widgets
export const BooleanWidget: {
Widget: () => CmsWidgetParam<boolean>;
};
export const CodeWidget: {
Widget: () => CmsWidgetParam<any>;
};
export const ColorStringWidget: {
Widget: () => CmsWidgetParam<string>;
};
export const DateTimeWidget: {
Widget: () => CmsWidgetParam<Date | string>;
};
export const FileWidget: {
Widget: () => CmsWidgetParam<string | string[] | List<string>>;
};
export const ImageWidget: {
Widget: () => CmsWidgetParam<string | string[] | List<string>>;
};
export const ListWidget: {
Widget: () => CmsWidgetParam<List<any>>;
};
export const MapWidget: {
Widget: () => CmsWidgetParam<any>;
};
export const MarkdownWidget: {
Widget: () => CmsWidgetParam<string>;
};
export const NumberWidget: {
Widget: () => CmsWidgetParam<string | number>;
};
export const ObjectWidget: {
Widget: () => CmsWidgetParam<Map<string, any> | Record<string, any>>;
};
export const RelationWidget: {
Widget: () => CmsWidgetParam<any>;
};
export const SelectWidget: {
Widget: () => CmsWidgetParam<string | string[]>;
};
export const StringWidget: {
Widget: () => CmsWidgetParam<string>;
};
export const TextWidget: {
Widget: () => CmsWidgetParam<string>;
};
export const MediaLibraryCloudinary: {
name: string;
init: ({
options,
handleInsert,
}?: {
options?: Record<string, any> | undefined;
handleInsert: any;
}) => Promise<{
show: ({
config,
allowMultiple,
}?: {
config?: Record<string, any> | undefined;
allowMultiple: boolean;
}) => any;
hide: () => any;
enableStandalone: () => boolean;
}>;
};
export const MediaLibraryUploadcare: {
name: string;
init: ({
options,
handleInsert,
}?: {
options?:
| {
config: Record<string, any>;
settings: Record<string, any>;
}
| undefined;
handleInsert: any;
}) => Promise<{
show: ({
value,
config,
allowMultiple,
imagesOnly,
}?: {
value: any;
config?: Record<string, any> | undefined;
allowMultiple: boolean;
imagesOnly?: boolean | undefined;
}) => any;
enableStandalone: () => boolean;
}>;
};
export const imageEditorComponent: EditorComponentManualOptions;
export const locales: {
en: Record<string, any>;
};
class NetlifyAuthenticator {
constructor(config: Record<string, any>);
refresh: (args: {
provider: string;
refresh_token: string;
}) => Promise<{ token: string; refresh_token: string }>;
}
export { NetlifyAuthenticator };
// Images
export interface IconProps {
type: string;
direction?: 'right' | 'down' | 'left' | 'up';
size?: string;
className?: string;
}
export const Icon: React.ComponentType<IconProps>;
export const images: Record<string, ReactNode>;
}

View File

@ -1,145 +0,0 @@
{
"name": "netlify-cms-core",
"description": "Netlify CMS core application, see netlify-cms package for the main distribution.",
"version": "2.55.62",
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-core",
"bugs": "https://github.com/netlify/netlify-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/netlify-cms-core.js",
"files": [
"src/",
"dist/",
"index.d.ts"
],
"types": "index.d.ts",
"scripts": {
"develop": "webpack serve --hot",
"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": [
"netlify",
"cms",
"core"
],
"license": "MIT",
"dependencies": {
"@emotion/css": "11.10.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@hot-loader/react-dom": "17.0.2",
"@iarna/toml": "2.2.5",
"@mui/icons-material": "5.10.6",
"@mui/material": "5.10.6",
"@reduxjs/toolkit": "1.8.5",
"ajv": "6.12.6",
"ajv-errors": "1.0.1",
"ajv-keywords": "3.5.2",
"apollo-cache-inmemory": "1.6.6",
"apollo-client": "2.6.10",
"apollo-link-context": "1.0.20",
"apollo-link-http": "1.5.17",
"array-move": "4.0.0",
"clean-stack": "4.2.0",
"codemirror": "5.65.9",
"common-tags": "1.8.1",
"copy-text-to-clipboard": "3.0.1",
"create-react-class": "15.7.0",
"deepmerge": "4.2.2",
"diacritics": "1.3.0",
"dompurify": "2.4.0",
"fuzzy": "0.1.3",
"gotrue-js": "0.9.29",
"graphql": "15.8.0",
"graphql-tag": "2.12.6",
"gray-matter": "4.0.3",
"history": "4.10.1",
"immer": "9.0.15",
"immutable": "3.8.2",
"ini": "2.0.0",
"is-hotkey": "0.2.0",
"js-base64": "3.7.2",
"js-sha256": "0.9.0",
"jwt-decode": "3.1.2",
"localforage": "1.10.0",
"lodash": "4.17.21",
"mdast-util-definitions": "1.2.5",
"mdast-util-to-string": "1.1.0",
"minimatch": "3.0.4",
"moment": "2.29.4",
"node-polyglot": "2.4.2",
"ol": "6.15.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-aria-menubutton": "7.0.3",
"react-codemirror2": "7.2.1",
"react-color": "2.19.3",
"react-datetime": "3.1.1",
"react-dnd": "14.0.5",
"react-dnd-html5-backend": "14.1.0",
"react-dom": "17.0.2",
"react-frame-component": "5.2.3",
"react-hot-loader": "4.13.0",
"react-immutable-proptypes": "2.2.0",
"react-is": "18.2.0",
"react-markdown": "6.0.3",
"react-modal": "3.15.1",
"react-polyglot": "0.7.2",
"react-redux": "8.0.4",
"react-router-dom": "5.3.3",
"react-scroll-sync": "0.9.0",
"react-select": "4.3.1",
"react-sortable-hoc": "2.0.0",
"react-split-pane": "0.1.92",
"react-textarea-autosize": "8.3.4",
"react-toggled": "1.2.7",
"react-topbar-progress-indicator": "4.1.1",
"react-transition-group": "4.4.5",
"react-virtualized-auto-sizer": "1.0.7",
"react-waypoint": "10.3.0",
"react-window": "1.8.7",
"rehype-parse": "6.0.2",
"rehype-remark": "8.1.1",
"rehype-stringify": "7.0.0",
"remark-gfm": "3.0.1",
"remark-parse": "6.0.3",
"remark-rehype": "4.0.1",
"remark-stringify": "6.0.4",
"sanitize-filename": "1.6.3",
"semaphore": "1.1.0",
"slate": "0.47.9",
"slate-base64-serializer": "0.2.115",
"slate-plain-serializer": "0.7.13",
"slate-react": "0.22.10",
"slate-soft-break": "0.9.0",
"tomlify-j0.4": "3.0.0",
"unified": "7.1.0",
"unist-builder": "1.0.4",
"unist-util-visit-parents": "2.1.2",
"uploadcare-widget": "3.19.0",
"uploadcare-widget-tab-effects": "1.5.0",
"url": "0.11.0",
"url-join": "4.0.1",
"uuid": "3.4.0",
"validate-color": "2.2.1",
"what-input": "5.2.12",
"what-the-diff": "0.6.0",
"yaml": "1.10.2"
},
"devDependencies": {
"@types/history": "4.7.11",
"@types/react": "17.0.50",
"@types/react-dom": "17.0.17",
"@types/react-router-dom": "5.3.3",
"@types/react-scroll-sync": "0.8.4",
"@types/url-join": "4.0.1",
"commonmark": "0.30.0",
"commonmark-spec": "0.30.0",
"cross-env": "7.0.3",
"react-svg-loader": "3.0.3",
"slate-hyperscript": "0.13.9",
"webpack": "4.46.0",
"webpack-cli": "4.10.0"
}
}

View File

@ -1,126 +0,0 @@
import { currentBackend } from '../backend';
import { addSnackbar } from '../store/slices/snackbars';
import type { Credentials, User } from '../lib/util';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
import type { t } from 'react-polyglot';
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(
addSnackbar({
type: 'warning',
message: {
key: 'ui.toast.onFailToAuth',
message: error.message,
},
}),
);
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());
});
};
}
export type AuthAction = ReturnType<
| typeof authenticating
| typeof authenticate
| typeof authError
| typeof doneAuthenticating
| typeof logout
>;

View File

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

View File

@ -1,531 +0,0 @@
import yaml from 'yaml';
import { fromJS } from 'immutable';
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import { trimStart, trim, isEmpty } from 'lodash';
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 { State } from '../types/redux';
import {
CmsConfig,
CmsField,
CmsFieldBase,
CmsFieldObject,
CmsFieldList,
CmsI18nConfig,
CmsPublishMode,
CmsLocalBackend,
CmsCollection,
} from '../interface';
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.info(`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 };
}
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 = {
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.info(`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.info(`Looking for Netlify 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.info(`Detected Netlify CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
return { proxyUrl, publish_modes, type };
} else {
console.info(`Netlify CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.info(`Netlify 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.info(
`'${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: any) {
dispatch(configFailed(err));
throw err;
}
};
}
export type ConfigAction = ReturnType<
typeof configLoading | typeof configLoaded | typeof configFailed
>;

View File

@ -1,101 +0,0 @@
import { currentBackend } from '../backend';
import { selectDeployPreview } from '../reducers';
import { addSnackbar } from '../store/slices/snackbars';
import type { t } from 'react-polyglot';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
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: any) {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToLoadDeployPreview', details: error.message },
}),
);
dispatch(deployPreviewError(collectionName, slug));
}
};
}
export type DeploysAction = ReturnType<
typeof deployPreviewLoading | typeof deployPreviewLoaded | typeof deployPreviewError
>;

View File

@ -1,536 +0,0 @@
import { List, Map } from 'immutable';
import { get } from 'lodash';
import { currentBackend, slugFromCustomPath } from '../backend';
import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { EDITORIAL_WORKFLOW_ERROR } from '../lib/util';
import {
selectEntry,
selectPublishedSlugs,
selectUnpublishedEntry,
selectUnpublishedSlugs,
} from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { navigateToEntry } from '../routing/history';
import { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import {
createDraftFromEntry,
entryDeleted,
getMediaAssets,
getSerializedEntry,
loadEntries,
loadEntry,
} from './entries';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Status } from '../constants/publishModes';
import type {
Collection,
Collections,
EntryDraft,
EntryMap,
MediaFile,
State,
} from '../types/redux';
import type { EntryValue } from '../valueObjects/Entry';
/*
* 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: any) {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToLoadEntries', details: error },
}),
);
}
}
};
}
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(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToLoadEntries', details: error },
}),
);
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(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.missingRequiredField' },
}),
);
}
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(
addSnackbar({
type: 'success',
message: { key: 'ui.toast.entrySaved' },
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry));
if (entry.get('slug') !== newSlug) {
dispatch(loadUnpublishedEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
} catch (error: any) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToPersist', details: error },
}),
);
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(
addSnackbar({
type: 'success',
message: { key: 'ui.toast.entryUpdated' },
}),
);
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, newStatus));
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToUpdateStatus', details: error },
}),
);
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(
addSnackbar({
type: 'success',
message: { key: 'ui.toast.onDeleteUnpublishedChanges' },
}),
);
dispatch(unpublishedEntryDeleted(collection, slug));
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onDeleteUnpublishedChanges', details: error },
}),
);
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(
addSnackbar({
type: 'success',
message: { key: 'ui.toast.entryPublished' },
}),
);
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(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
}),
);
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(
addSnackbar({
type: 'success',
message: { key: 'ui.toast.entryUnpublished' },
}),
);
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToUnpublishEntry', details: error },
}),
);
dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug')));
});
};
}

File diff suppressed because it is too large Load Diff

View File

@ -1,138 +0,0 @@
import { isAbsolutePath } from '../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: any) {
dispatch(loadAssetFailure(resolvedPath, e));
}
};
}
interface GetAssetArgs {
collection: Collection;
entry: EntryMap;
path: string;
field?: EntryField;
}
const emptyAsset = createAssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
export function boundGetAsset(
dispatch: ThunkDispatch<State, {}, AnyAction>,
collection: Collection,
entry: EntryMap,
) {
function bound(path: string, field: EntryField) {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
}
return bound;
}
export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (!path) return emptyAsset;
const state = getState();
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return emptyAsset;
}
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return asset;
}
if (isAbsolutePath(resolvedPath)) {
// asset path is a public url so we can just use it as is
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
}
}
return asset;
};
}
export type MediasAction = ReturnType<
| typeof addAssets
| typeof addAsset
| typeof removeAsset
| typeof loadAssetRequest
| typeof loadAssetSuccess
| typeof loadAssetFailure
>;

View File

@ -1,590 +0,0 @@
import { Map } from 'immutable';
import { currentBackend } from '../backend';
import confirm from '../components/UI/Confirm';
import { getIntegrationProvider } from '../integrations';
import { sanitizeSlug } from '../lib/urlHelper';
import { basename, getBlobSHA } from '../lib/util';
import { selectIntegration } from '../reducers';
import {
selectEditingDraft,
selectMediaFilePath,
selectMediaFilePublicPath,
} from '../reducers/entries';
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
import { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { addAsset, removeAsset } from './media';
import { waitUntilWithTimeout } from './waitUntil';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { ImplementationMediaFile } from '../lib/util';
import type {
DisplayURLState,
EntryField,
MediaFile,
MediaLibraryInstance,
State,
} from '../types/redux';
import type AssetProxy from '../valueObjects/AssetProxy';
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.info('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 (
!(await confirm({
title: 'mediaLibrary.mediaLibrary.alreadyExistsTitle',
body: {
key: 'mediaLibrary.mediaLibrary.alreadyExistsBody',
options: { filename: existingFile.name },
},
color: 'error',
}))
) {
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(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToPersistMedia',
details: error,
},
}),
);
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: any) {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToDeleteMedia',
details: error.message,
},
}),
);
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: any) {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToDeleteMedia',
details: error.message,
},
}),
);
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: any) {
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;
}
return url;
}
export type MediaLibraryAction = ReturnType<
| typeof createMediaLibrary
| typeof mediaLibraryOpened
| typeof mediaLibraryClosed
| typeof mediaInserted
| typeof removeInsertedMedia
| typeof mediaLoading
| typeof mediaLoaded
| typeof mediaLoadFailed
| typeof mediaPersisting
| typeof mediaPersisted
| typeof mediaPersistFailed
| typeof mediaDeleting
| typeof mediaDeleted
| typeof mediaDeleteFailed
| typeof mediaDisplayURLRequest
| typeof mediaDisplayURLSuccess
| typeof mediaDisplayURLFailure
>;

View File

@ -1,30 +0,0 @@
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { State } from '../types/redux';
export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
export const TOGGLE_SCROLL = 'TOGGLE_SCROLL';
export const SET_SCROLL = 'SET_SCROLL';
export function togglingScroll() {
return {
type: TOGGLE_SCROLL,
} as const;
}
export function loadScroll() {
return {
type: SET_SCROLL,
payload: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
} as const;
}
export function toggleScroll() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, _getState: () => State) => {
return dispatch(togglingScroll());
};
}
export type ScrollAction = ReturnType<typeof togglingScroll | typeof loadScroll>;

View File

@ -1,194 +0,0 @@
import { isEqual } from 'lodash';
import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
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';
/*
* 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) {
return {
type: QUERY_REQUEST,
payload: {
searchTerm,
},
} 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;
}
/*
* 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: any) {
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) => {
dispatch(querying(searchTerm));
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = state.collections.find(
collection => collection.get('name') === collectionName,
);
const queryPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
searchFields.map(f => `data.${f}`),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm, file, limit);
try {
const response: QueryResponse = await queryPromise;
return dispatch(querySuccess(namespace, response.hits));
} catch (error: any) {
return dispatch(queryFailure(error));
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
>;

View File

@ -1,92 +0,0 @@
import { currentBackend } from '../backend';
import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
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 previousBackendDownNotifs = state.snackbar.messages.filter(
n => n.message?.key === backendDownKey,
);
if (status.api.status === false) {
if (previousBackendDownNotifs.length === 0) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onBackendDown', details: status.api.statusPage },
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifs.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifs.forEach(notif => {
dispatch(removeSnackbarById(notif.id));
});
}
const authError = status.auth.status === false;
if (authError) {
const key = 'ui.toast.onLoggedOut';
const existingNotification = state.snackbar.messages.find(n => n.message?.key === key);
if (!existingNotification) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onLoggedOut' },
}),
);
}
}
dispatch(statusSuccess(status));
} catch (error: any) {
dispatch(statusFailure(error));
}
};
}
export type StatusAction = ReturnType<
typeof statusRequest | typeof statusSuccess | typeof statusFailure
>;

View File

@ -1,49 +0,0 @@
import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction';
import type { WaitActionArgs } from '../store/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> {
let waitDone = false;
const waitPromise = new Promise<T>(resolve => {
dispatch(waitUntil(waitActionArgs(resolve)));
});
const timeoutPromise = new Promise<T | null>(resolve => {
setTimeout(() => {
if (waitDone) {
resolve();
} else {
console.warn('Wait Action timed out');
resolve(null);
}
}, timeout);
});
const result = await Promise.race([
waitPromise
.then(result => {
waitDone = true;
return result;
})
.catch(null),
timeoutPromise,
]);
return result;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,794 +0,0 @@
import { Base64 } from 'js-base64';
import { partial, result, trim, trimStart } from 'lodash';
import { dirname, basename } from 'path';
import {
localForage,
APIError,
unsentRequest,
requestWithBackoff,
responseParser,
readFile,
DEFAULT_PR_BODY,
MERGE_COMMIT_MESSAGE,
generateContentKey,
parseContentKey,
labelToStatus,
isCMSLabel,
EditorialWorkflowError,
statusToLabel,
PreviewState,
readFileMetadata,
branchFromContentKey,
} from '../../lib/util';
import type { ApiRequest, AssetProxy, PersistOptions, DataFile } from '../../lib/util';
import type { Map } from 'immutable';
export const API_NAME = 'Azure DevOps';
const API_VERSION = 'api-version';
type AzureUser = {
coreAttributes?: {
Avatar?: { value?: { value?: string } };
DisplayName?: { value?: string };
EmailAddress?: { value?: string };
};
};
type AzureGitItem = {
objectId: string;
gitObjectType: AzureObjectType;
path: string;
};
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull%20requests/get%20pull%20request?view=azure-devops-rest-6.1#gitpullrequest
type AzureWebApiTagDefinition = {
active: boolean;
id: string;
name: string;
url: string;
};
type AzurePullRequest = {
title: string;
artifactId: string;
closedDate: string;
creationDate: string;
isDraft: string;
status: AzurePullRequestStatus;
lastMergeSourceCommit: AzureGitChangeItem;
mergeStatus: AzureAsyncPullRequestStatus;
pullRequestId: number;
labels: AzureWebApiTagDefinition[];
sourceRefName: string;
createdBy?: {
displayName?: string;
uniqueName: string;
};
};
type AzurePullRequestCommit = { commitId: string };
enum AzureCommitStatusState {
ERROR = 'error',
FAILED = 'failed',
NOT_APPLICABLE = 'notApplicable',
NOT_SET = 'notSet',
PENDING = 'pending',
SUCCEEDED = 'succeeded',
}
type AzureCommitStatus = {
context: { genre?: string | null; name: string };
state: AzureCommitStatusState;
targetUrl: string;
};
// This does not match Azure documentation, but it is what comes back from some calls
// PullRequest as an example is documented as returning PullRequest[], but it actually
// returns that inside of this value prop in the json
interface AzureArray<T> {
value: T[];
}
enum AzureCommitChangeType {
ADD = 'add',
DELETE = 'delete',
RENAME = 'rename',
EDIT = 'edit',
}
enum AzureItemContentType {
BASE64 = 'base64encoded',
}
enum AzurePullRequestStatus {
ACTIVE = 'active',
COMPLETED = 'completed',
ABANDONED = 'abandoned',
}
enum AzureAsyncPullRequestStatus {
CONFLICTS = 'conflicts',
FAILURE = 'failure',
QUEUED = 'queued',
REJECTED = 'rejectedByPolicy',
SUCCEEDED = 'succeeded',
}
enum AzureObjectType {
BLOB = 'blob',
TREE = 'tree',
}
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitcommitdiffs
interface AzureGitCommitDiffs {
changes: AzureGitChange[];
}
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitchange
interface AzureGitChange {
changeId: number;
item: AzureGitChangeItem;
changeType: AzureCommitChangeType;
originalPath: string;
url: string;
}
interface AzureGitChangeItem {
objectId: string;
originalObjectId: string;
gitObjectType: string;
commitId: string;
path: string;
isFolder: string;
url: string;
}
type AzureRef = {
name: string;
objectId: string;
};
type AzureCommit = {
author: {
date: string;
email: string;
name: string;
};
};
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getChangeItem(item: AzureCommitItem) {
switch (item.action) {
case AzureCommitChangeType.ADD:
return {
changeType: AzureCommitChangeType.ADD,
item: { path: item.path },
newContent: {
content: item.base64Content,
contentType: AzureItemContentType.BASE64,
},
};
case AzureCommitChangeType.EDIT:
return {
changeType: AzureCommitChangeType.EDIT,
item: { path: item.path },
newContent: {
content: item.base64Content,
contentType: AzureItemContentType.BASE64,
},
};
case AzureCommitChangeType.DELETE:
return {
changeType: AzureCommitChangeType.DELETE,
item: { path: item.path },
};
case AzureCommitChangeType.RENAME:
return {
changeType: AzureCommitChangeType.RENAME,
item: { path: item.path },
sourceServerItem: item.oldPath,
};
default:
return {};
}
}
type AzureCommitItem = {
action: AzureCommitChangeType;
base64Content?: string;
text?: string;
path: string;
oldPath?: string;
};
interface AzureApiConfig {
apiRoot: string;
repo: { org: string; project: string; repoName: string };
branch: string;
squashMerges: boolean;
initialWorkflowStatus: string;
cmsLabelPrefix: string;
apiVersion: string;
}
export default class API {
apiVersion: string;
token: string;
branch: string;
mergeStrategy: string;
endpointUrl: string;
initialWorkflowStatus: string;
cmsLabelPrefix: string;
constructor(config: AzureApiConfig, token: string) {
const { repo } = config;
const apiRoot = trim(config.apiRoot, '/');
this.endpointUrl = `${apiRoot}/${repo.org}/${repo.project}/_apis/git/repositories/${repo.repoName}`;
this.token = token;
this.branch = config.branch;
this.mergeStrategy = config.squashMerges ? 'squash' : 'noFastForward';
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.apiVersion = config.apiVersion;
this.cmsLabelPrefix = config.cmsLabelPrefix;
}
withHeaders = (req: ApiRequest) => {
const withHeaders = unsentRequest.withHeaders(
{
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json; charset=utf-8',
},
req,
);
return withHeaders;
};
withAzureFeatures = (req: Map<string, Map<string, string>>) => {
if (req.hasIn(['params', API_VERSION])) {
return req;
}
const withParams = unsentRequest.withParams(
{
[API_VERSION]: `${this.apiVersion}`,
},
req,
);
return withParams;
};
buildRequest = (req: ApiRequest) => {
const withHeaders = this.withHeaders(req);
const withAzureFeatures = this.withAzureFeatures(withHeaders);
if (withAzureFeatures.has('cache')) {
return withAzureFeatures;
} else {
const withNoCache = unsentRequest.withNoCache(withAzureFeatures);
return withNoCache;
}
};
request = (req: ApiRequest): Promise<Response> => {
try {
return requestWithBackoff(this, req);
} catch (err: any) {
throw new APIError(err.message, null, API_NAME);
}
};
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
responseToText = responseParser({ format: 'text', apiName: API_NAME });
requestJSON = <T>(req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<T>;
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
fromBase64 = (str: string) => Base64.decode(str);
branchToRef = (branch: string): string => `refs/heads/${branch}`;
refToBranch = (ref: string): string => ref.slice('refs/heads/'.length);
user = async () => {
const result = await this.requestJSON<AzureUser>({
url: 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me',
params: { [API_VERSION]: '6.1-preview.2' },
});
const name = result.coreAttributes?.DisplayName?.value;
const email = result.coreAttributes?.EmailAddress?.value;
const url = result.coreAttributes?.Avatar?.value?.value;
const user = {
name: name || email || '',
avatar_url: `data:image/png;base64,${url}`,
email,
};
return user;
};
async readFileMetadata(
path: string,
sha: string | null | undefined,
{ branch = this.branch } = {},
) {
const fetchFileMetadata = async () => {
try {
const { value } = await this.requestJSON<AzureArray<AzureCommit>>({
url: `${this.endpointUrl}/commits/`,
params: {
'searchCriteria.itemPath': path,
'searchCriteria.itemVersion.version': branch,
'searchCriteria.$top': 1,
},
});
const [commit] = value;
return {
author: commit.author.name || commit.author.email,
updatedOn: commit.author.date,
};
} catch (error) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
readFile = (
path: string,
sha?: string | null,
{ parseText = true, branch = this.branch } = {},
) => {
const fetchContent = () => {
return this.request({
url: `${this.endpointUrl}/items/`,
params: { version: branch, path },
cache: 'no-store',
}).then<Blob | string>(parseText ? this.responseToText : this.responseToBlob);
};
return readFile(sha, fetchContent, localForage, parseText);
};
listFiles = async (path: string, recursive: boolean, branch = this.branch) => {
try {
const { value: items } = await this.requestJSON<AzureArray<AzureGitItem>>({
url: `${this.endpointUrl}/items/`,
params: {
version: branch,
scopePath: path,
recursionLevel: recursive ? 'full' : 'oneLevel',
},
});
const files = items
.filter(item => item.gitObjectType === AzureObjectType.BLOB)
.map(file => ({
id: file.objectId,
path: trimStart(file.path, '/'),
name: basename(file.path),
}));
return files;
} catch (err: any) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return [];
} else {
throw err;
}
}
};
async getRef(branch: string = this.branch) {
const { value: refs } = await this.requestJSON<AzureArray<AzureRef>>({
url: `${this.endpointUrl}/refs`,
params: {
$top: '1', // There's only one ref, so keep the payload small
filter: 'heads/' + branch,
},
});
return refs.find(b => b.name == this.branchToRef(branch))!;
}
async deleteRef(ref: AzureRef): Promise<void> {
const deleteBranchPayload = [
{
name: ref.name,
oldObjectId: ref.objectId,
newObjectId: '0000000000000000000000000000000000000000',
},
];
await this.requestJSON({
method: 'POST',
url: `${this.endpointUrl}/refs`,
body: JSON.stringify(deleteBranchPayload),
});
}
async uploadAndCommit(
items: AzureCommitItem[],
comment: string,
branch: string,
newBranch: boolean,
) {
const ref = await this.getRef(newBranch ? this.branch : branch);
const refUpdate = [
{
name: this.branchToRef(branch),
oldObjectId: ref.objectId,
},
];
const changes = items.map(item => getChangeItem(item));
const commits = [{ comment, changes }];
const push = {
refUpdates: refUpdate,
commits,
};
return this.requestJSON({
url: `${this.endpointUrl}/pushes`,
method: 'POST',
body: JSON.stringify(push),
});
}
async retrieveUnpublishedEntryData(contentKey: string) {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const diffs = await this.getDifferences(pullRequest.sourceRefName);
const diffsWithIds = await Promise.all(
diffs.map(async d => {
const path = trimStart(d.item.path, '/');
const newFile = d.changeType === AzureCommitChangeType.ADD;
const id = d.item.objectId;
return { id, path, newFile };
}),
);
const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix));
const labelName = label && label.name ? label.name : this.cmsLabelPrefix;
const status = labelToStatus(labelName, this.cmsLabelPrefix);
// Uses creationDate, as we do not have direct access to the updated date
const updatedAt = pullRequest.closedDate ? pullRequest.closedDate : pullRequest.creationDate;
const pullRequestAuthor =
pullRequest.createdBy?.displayName || pullRequest.createdBy?.uniqueName;
return {
collection,
slug,
status,
diffs: diffsWithIds,
updatedAt,
pullRequestAuthor,
};
}
async getPullRequestStatues(pullRequest: AzurePullRequest) {
const { value: commits } = await this.requestJSON<AzureArray<AzurePullRequestCommit>>({
url: `${this.endpointUrl}/pullrequests/${pullRequest.pullRequestId}/commits`,
params: {
$top: 1,
},
});
const { value: statuses } = await this.requestJSON<AzureArray<AzureCommitStatus>>({
url: `${this.endpointUrl}/commits/${commits[0].commitId}/statuses`,
params: { latestOnly: true },
});
return statuses;
}
async getStatuses(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const statuses = await this.getPullRequestStatues(pullRequest);
return statuses.map(({ context, state, targetUrl }) => ({
context: context.name,
state: state === AzureCommitStatusState.SUCCEEDED ? PreviewState.Success : PreviewState.Other,
target_url: targetUrl,
}));
}
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
const items = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
this.isFileExists(file.path, branch),
]);
const path = file.newPath || file.path;
const oldPath = file.path;
const renameOrEdit =
path !== oldPath ? AzureCommitChangeType.RENAME : AzureCommitChangeType.EDIT;
const action = fileExists ? renameOrEdit : AzureCommitChangeType.ADD;
return {
action,
base64Content,
path,
oldPath,
} as AzureCommitItem;
}),
);
// move children
for (const item of items.filter(i => i.oldPath && i.action === AzureCommitChangeType.RENAME)) {
const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path);
const children = await this.listFiles(sourceDir, true, branch);
children
.filter(file => file.path !== item.oldPath)
.forEach(file => {
items.push({
action: AzureCommitChangeType.RENAME,
path: file.path.replace(sourceDir, destDir),
oldPath: file.path,
});
});
}
return items;
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
} else {
const items = await this.getCommitItems(files, this.branch);
return this.uploadAndCommit(items, options.commitMessage, this.branch, true);
}
}
async deleteFiles(paths: string[], comment: string) {
const ref = await this.getRef(this.branch);
const refUpdate = {
name: ref.name,
oldObjectId: ref.objectId,
};
const changes = paths.map(path =>
getChangeItem({ action: AzureCommitChangeType.DELETE, path }),
);
const commits = [{ comment, changes }];
const push = {
refUpdates: [refUpdate],
commits,
};
return this.requestJSON({
url: `${this.endpointUrl}/pushes`,
method: 'POST',
body: JSON.stringify(push),
});
}
async getPullRequests(sourceBranch?: string) {
const { value: pullRequests } = await this.requestJSON<AzureArray<AzurePullRequest>>({
url: `${this.endpointUrl}/pullrequests`,
params: {
'searchCriteria.status': 'active',
'searchCriteria.targetRefName': this.branchToRef(this.branch),
'searchCriteria.includeLinks': false,
...(sourceBranch ? { 'searchCriteria.sourceRefName': this.branchToRef(sourceBranch) } : {}),
},
});
const filtered = pullRequests.filter(pr => {
return pr.labels.some(label => isCMSLabel(label.name, this.cmsLabelPrefix));
});
return filtered;
}
async listUnpublishedBranches(): Promise<string[]> {
const pullRequests = await this.getPullRequests();
const branches = pullRequests.map(pr => this.refToBranch(pr.sourceRefName));
return branches;
}
async isFileExists(path: string, branch: string) {
try {
await this.requestText({
url: `${this.endpointUrl}/items/`,
params: { version: branch, path },
cache: 'no-store',
});
return true;
} catch (error) {
if (error instanceof APIError && error.status === 404) {
return false;
}
throw error;
}
}
async createPullRequest(branch: string, commitMessage: string, status: string) {
const pr = {
sourceRefName: this.branchToRef(branch),
targetRefName: this.branchToRef(this.branch),
title: commitMessage,
description: DEFAULT_PR_BODY,
labels: [
{
name: statusToLabel(status, this.cmsLabelPrefix),
},
],
};
await this.requestJSON({
method: 'POST',
url: `${this.endpointUrl}/pullrequests`,
params: {
supportsIterations: false,
},
body: JSON.stringify(pr),
});
}
async getBranchPullRequest(branch: string) {
const pullRequests = await this.getPullRequests(branch);
if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return pullRequests[0];
}
async getDifferences(to: string) {
const result = await this.requestJSON<AzureGitCommitDiffs>({
url: `${this.endpointUrl}/diffs/commits`,
params: {
baseVersion: this.branch,
targetVersion: this.refToBranch(to),
},
});
return result.changes.filter(
d =>
d.item.gitObjectType === AzureObjectType.BLOB &&
Object.values(AzureCommitChangeType).includes(d.changeType),
);
}
async editorialWorkflowGit(
files: (DataFile | AssetProxy)[],
slug: string,
options: PersistOptions,
) {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
const items = await this.getCommitItems(files, this.branch);
await this.uploadAndCommit(items, options.commitMessage, branch, true);
await this.createPullRequest(
branch,
options.commitMessage,
options.status || this.initialWorkflowStatus,
);
} else {
const items = await this.getCommitItems(files, branch);
await this.uploadAndCommit(items, options.commitMessage, branch, false);
}
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const nonCmsLabels = pullRequest.labels
.filter(label => !isCMSLabel(label.name, this.cmsLabelPrefix))
.map(label => label.name);
const labels = [...nonCmsLabels, statusToLabel(newStatus, this.cmsLabelPrefix)];
await this.updatePullRequestLabels(pullRequest, labels);
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.abandonPullRequest(pullRequest);
}
async publishUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.completePullRequest(pullRequest);
}
async updatePullRequestLabels(pullRequest: AzurePullRequest, labels: string[]) {
const cmsLabels = pullRequest.labels.filter(l => isCMSLabel(l.name, this.cmsLabelPrefix));
await Promise.all(
cmsLabels.map(l => {
return this.requestText({
method: 'DELETE',
url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(
pullRequest.pullRequestId,
)}/labels/${encodeURIComponent(l.id)}`,
});
}),
);
await Promise.all(
labels.map(l => {
return this.requestText({
method: 'POST',
url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(
pullRequest.pullRequestId,
)}/labels`,
body: JSON.stringify({ name: l }),
});
}),
);
}
async completePullRequest(pullRequest: AzurePullRequest) {
const pullRequestCompletion = {
status: AzurePullRequestStatus.COMPLETED,
lastMergeSourceCommit: pullRequest.lastMergeSourceCommit,
completionOptions: {
deleteSourceBranch: true,
mergeCommitMessage: MERGE_COMMIT_MESSAGE,
mergeStrategy: this.mergeStrategy,
},
};
let response = await this.requestJSON<AzurePullRequest>({
method: 'PATCH',
url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(pullRequest.pullRequestId)}`,
body: JSON.stringify(pullRequestCompletion),
});
// We need to wait for Azure to complete the pull request to actually complete
// Sometimes this is instant, but frequently it is 1-3 seconds
const DELAY_MILLISECONDS = 500;
const MAX_ATTEMPTS = 10;
let attempt = 1;
while (response.mergeStatus === AzureAsyncPullRequestStatus.QUEUED && attempt <= MAX_ATTEMPTS) {
await delay(DELAY_MILLISECONDS);
response = await this.requestJSON({
url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(pullRequest.pullRequestId)}`,
});
attempt = attempt + 1;
}
}
async abandonPullRequest(pullRequest: AzurePullRequest) {
const pullRequestAbandon = {
status: AzurePullRequestStatus.ABANDONED,
};
await this.requestJSON({
method: 'PATCH',
url: `${this.endpointUrl}/pullrequests/${encodeURIComponent(pullRequest.pullRequestId)}`,
body: JSON.stringify(pullRequestAbandon),
});
await this.deleteRef({
name: pullRequest.sourceRefName,
objectId: pullRequest.lastMergeSourceCommit.commitId,
});
}
}

View File

@ -1,84 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { AuthenticationPage, Icon } from '../../ui';
import { ImplicitAuthenticator } from '../../lib/auth';
import alert from '../../components/UI/Alert';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
export default class AzureAuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
base_url: PropTypes.string,
siteId: PropTypes.string,
authEndpoint: PropTypes.string,
config: PropTypes.object.isRequired,
clearHash: PropTypes.func,
t: PropTypes.func.isRequired,
};
state = {};
componentDidMount() {
this.auth = new ImplicitAuthenticator({
base_url: `https://login.microsoftonline.com/${this.props.config.backend.tenant_id}`,
auth_endpoint: 'oauth2/authorize',
app_id: this.props.config.backend.app_id,
clearHash: this.props.clearHash,
});
// Complete implicit authentication if we were redirected back to from the provider.
this.auth.completeAuth((err, data) => {
if (err) {
alert({
title: 'auth.errors.authTitle',
body: { key: 'auth.errors.authBody', options: { details: err } },
});
return;
}
this.props.onLogin(data);
});
}
handleLogin = e => {
e.preventDefault();
this.auth.authenticate(
{
scope: 'vso.code_full,user.read',
resource: '499b84ac-1321-427f-aa17-267ca6975798',
prompt: 'select_account',
},
(err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
},
);
};
render() {
const { inProgress, config, t } = this.props;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress}
loginErrorMessage={this.state.loginError}
logoUrl={config.logo_url}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="azure" />
{inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')}
</React.Fragment>
)}
t={t}
/>
);
}
}

View File

@ -1,383 +0,0 @@
import { trimStart, trim } from 'lodash';
import semaphore from 'semaphore';
import {
basename,
getMediaDisplayURL,
generateContentKey,
getMediaAsBlob,
getPreviewStatus,
asyncLock,
runWithLock,
unpublishedEntries,
entriesByFiles,
filterByExtension,
branchFromContentKey,
entriesByFolder,
contentKeyFromBranch,
getBlobSHA,
} from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import type { Semaphore } from 'semaphore';
import type {
Credentials,
Implementation,
ImplementationFile,
ImplementationMediaFile,
DisplayURL,
Entry,
AssetProxy,
PersistOptions,
Config,
AsyncLock,
User,
UnpublishedEntryMediaFile,
} from '../../lib/util';
const MAX_CONCURRENT_DOWNLOADS = 10;
function parseAzureRepo(config: Config) {
const { repo } = config.backend;
if (typeof repo !== 'string') {
throw new Error('The Azure backend needs a "repo" in the backend configuration.');
}
const parts = repo.split('/');
if (parts.length !== 3) {
throw new Error('The Azure backend must be in a the format of {org}/{project}/{repo}');
}
const [org, project, repoName] = parts;
return {
org,
project,
repoName,
};
}
export default class Azure implements Implementation {
lock: AsyncLock;
api?: API;
options: {
initialWorkflowStatus: string;
};
repo: {
org: string;
project: string;
repoName: string;
};
branch: string;
apiRoot: string;
apiVersion: string;
token: string | null;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string;
previewContext: string;
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
initialWorkflowStatus: '',
...options,
};
this.repo = parseAzureRepo(config);
this.branch = config.backend.branch || 'master';
this.apiRoot = config.backend.api_root || 'https://dev.azure.com';
this.apiVersion = config.backend.api_version || '6.1-preview';
this.token = '';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = trim(config.media_folder, '/');
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api!.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Azure user', e);
return false;
})) || false;
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.api = new API(
{
apiRoot: this.apiRoot,
apiVersion: this.apiVersion,
repo: this.repo,
branch: this.branch,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
},
this.token,
);
const user = await this.api.user();
return { token: state.token as string, ...user };
}
/**
* Log the user out by forgetting their access token.
* TODO: *Actual* logout by redirecting to:
* https://login.microsoftonline.com/{tenantId}/oauth2/logout?client_id={clientId}&post_logout_redirect_uri={baseUrl}
*/
logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
async entriesByFolder(folder: string, extension: string, depth: number) {
const listFiles = async () => {
const files = await this.api!.listFiles(folder, depth > 1);
const filtered = files.filter(file => filterByExtension({ path: file.path }, extension));
return filtered.map(file => ({
id: file.id,
path: file.path,
}));
};
const entries = await entriesByFolder(
listFiles,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return entries;
}
entriesByFiles(files: ImplementationFile[]) {
return entriesByFiles(
files,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
}
async getEntry(path: string) {
const data = (await this.api!.readFile(path)) as string;
return {
file: { path },
data,
};
}
async getMedia() {
const files = await this.api!.listFiles(this.mediaFolder, false);
const mediaFiles = await Promise.all(
files.map(async ({ id, path, name }) => {
const blobUrl = await this.getMediaDisplayURL({ id, path });
return { id, name, displayURL: blobUrl, path };
}),
);
return mediaFiles;
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = new File([blob], name);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: Entry, options: PersistOptions): Promise<void> {
const mediaFiles: AssetProxy[] = entry.assets;
await this.api!.persistFiles(entry.dataFiles, mediaFiles, options);
}
async persistMedia(
mediaFile: AssetProxy,
options: PersistOptions,
): Promise<ImplementationMediaFile> {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const { path } = mediaFile;
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(path, '/'),
name: fileObj!.name,
size: fileObj!.size,
file: fileObj,
url,
id: id as string,
};
}
async deleteFiles(paths: string[], commitMessage: string) {
await this.api!.deleteFiles(paths, commitMessage);
}
async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const blob = await getMediaAsBlob(file.path, null, readFile);
const name = basename(file.path);
const fileObj = new File([blob], name);
return {
id: file.path,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name,
size: fileObj.size,
file: fileObj,
};
}
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) {
const mediaFiles = await Promise.all(files.map(file => this.loadMediaFile(branch, file)));
return mediaFiles;
}
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const contentKey = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(contentKey);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View File

@ -1,10 +0,0 @@
import AzureBackend from './implementation';
import API from './API';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendAzure = {
AzureBackend,
API,
AuthenticationPage,
};
export { AzureBackend, API, AuthenticationPage };

View File

@ -1,803 +0,0 @@
import { flow, get } from 'lodash';
import { dirname } from 'path';
import { oneLine } from 'common-tags';
import { parse } from 'what-the-diff';
import {
localForage,
unsentRequest,
responseParser,
then,
basename,
Cursor,
APIError,
readFile,
CMS_BRANCH_PREFIX,
generateContentKey,
labelToStatus,
isCMSLabel,
EditorialWorkflowError,
statusToLabel,
DEFAULT_PR_BODY,
MERGE_COMMIT_MESSAGE,
PreviewState,
parseContentKey,
branchFromContentKey,
requestWithBackoff,
readFileMetadata,
throwOnConflictingBranches,
} from '../../lib/util';
import type {
ApiRequest,
AssetProxy,
PersistOptions,
FetchError,
DataFile,
} from '../../lib/util';
interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
requestFunction?: (req: ApiRequest) => Promise<Response>;
hasWriteAccess?: () => Promise<boolean>;
squashMerges: boolean;
initialWorkflowStatus: string;
cmsLabelPrefix: string;
}
interface CommitAuthor {
name: string;
email: string;
}
enum BitBucketPullRequestState {
MERGED = 'MERGED',
SUPERSEDED = 'SUPERSEDED',
OPEN = 'OPEN',
DECLINED = 'DECLINED',
}
type BitBucketPullRequest = {
description: string;
id: number;
title: string;
state: BitBucketPullRequestState;
updated_on: string;
summary: {
raw: string;
};
source: {
commit: {
hash: string;
};
branch: {
name: string;
};
};
destination: {
commit: {
hash: string;
};
branch: {
name: string;
};
};
author: BitBucketUser;
};
type BitBucketPullRequests = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullRequest[];
};
type BitBucketPullComment = {
content: {
raw: string;
};
};
type BitBucketPullComments = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullComment[];
};
enum BitBucketPullRequestStatusState {
Successful = 'SUCCESSFUL',
Failed = 'FAILED',
InProgress = 'INPROGRESS',
Stopped = 'STOPPED',
}
type BitBucketPullRequestStatus = {
uuid: string;
name: string;
key: string;
refname: string;
url: string;
description: string;
state: BitBucketPullRequestStatusState;
};
type BitBucketPullRequestStatues = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullRequestStatus[];
};
type DeleteEntry = {
path: string;
delete: true;
};
type BitBucketFile = {
id: string;
type: string;
path: string;
commit?: { hash: string };
};
type BitBucketSrcResult = {
size: number;
page: number;
pagelen: number;
next: string;
previous: string;
values: BitBucketFile[];
};
type BitBucketUser = {
username: string;
display_name: string;
nickname: string;
links: {
avatar: {
href: string;
};
};
};
type BitBucketBranch = {
name: string;
target: { hash: string };
};
type BitBucketCommit = {
hash: string;
author: {
raw: string;
user: {
display_name: string;
nickname: string;
};
};
date: string;
};
export const API_NAME = 'Bitbucket';
const APPLICATION_JSON = 'application/json; charset=utf-8';
function replace404WithEmptyResponse(err: FetchError) {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return { size: 0, values: [] as BitBucketFile[] } as BitBucketSrcResult;
} else {
return Promise.reject(err);
}
}
export default class API {
apiRoot: string;
branch: string;
repo: string;
requestFunction: (req: ApiRequest) => Promise<Response>;
repoURL: string;
commitAuthor?: CommitAuthor;
mergeStrategy: string;
initialWorkflowStatus: string;
cmsLabelPrefix: string;
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
this.branch = config.branch || 'master';
this.repo = config.repo || '';
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
// Allow overriding this.hasWriteAccess
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit';
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
}
buildRequest = (req: ApiRequest) => {
const withRoot = unsentRequest.withRoot(this.apiRoot)(req);
if (withRoot.has('cache')) {
return withRoot;
} else {
const withNoCache = unsentRequest.withNoCache(withRoot);
return withNoCache;
}
};
request = (req: ApiRequest): Promise<Response> => {
try {
return requestWithBackoff(this, req);
} catch (err: any) {
throw new APIError(err.message, null, API_NAME);
}
};
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
responseToText = responseParser({ format: 'text', apiName: API_NAME });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
user = () => this.requestJSON('/user') as Promise<BitBucketUser>;
hasWriteAccess = async () => {
const response = await this.request(this.repoURL);
if (response.status === 404) {
throw Error('Repo not found');
}
return response.ok;
};
getBranch = async (branchName: string) => {
const branch: BitBucketBranch = await this.requestJSON(
`${this.repoURL}/refs/branches/${branchName}`,
);
return branch;
};
branchCommitSha = async (branch: string) => {
const {
target: { hash: branchSha },
}: BitBucketBranch = await this.getBranch(branch);
return branchSha;
};
defaultBranchCommitSha = () => {
return this.branchCommitSha(this.branch);
};
isFile = ({ type }: BitBucketFile) => type === 'commit_file';
getFileId = (commitHash: string, path: string) => {
return `${commitHash}/${path}`;
};
processFile = (file: BitBucketFile) => ({
id: file.id,
type: file.type,
path: file.path,
name: basename(file.path),
// BitBucket does not return file SHAs, but it does give us the
// commit SHA. Since the commit SHA will change if any files do,
// we can construct an ID using the commit SHA and the file path
// that will help with caching (though not as well as a normal
// SHA, since it will change even if the individual file itself
// doesn't.)
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
});
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile);
readFile = async (
path: string,
sha?: string | null,
{ parseText = true, branch = this.branch, head = '' } = {},
): Promise<string | Blob> => {
const fetchContent = async () => {
const node = head ? head : await this.branchCommitSha(branch);
const content = await this.request({
url: `${this.repoURL}/src/${node}/${path}`,
cache: 'no-store',
}).then<string | Blob>(parseText ? this.responseToText : this.responseToBlob);
return content;
};
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
};
async readFileMetadata(path: string, sha: string | null | undefined) {
const fetchFileMetadata = async () => {
try {
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
url: `${this.repoURL}/commits`,
params: { path, include: this.branch },
});
const commit = values[0];
return {
author: commit.author.user
? commit.author.user.display_name || commit.author.user.nickname
: commit.author.raw,
updatedOn: commit.date,
};
} catch (e) {
return { author: '', updatedOn: '' };
}
};
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
return fileMetadata;
}
async isShaExistsInBranch(branch: string, sha: string) {
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
url: `${this.repoURL}/commits`,
params: { include: branch, pagelen: 100 },
}).catch(e => {
console.info(`Failed getting commits for branch '${branch}'`, e);
return [];
});
return values.some(v => v.hash === sha);
}
getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => {
const {
size: count,
page,
pagelen: pageSize,
next,
previous: prev,
values: entries,
} = jsonResponse;
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
return {
entries,
cursor: Cursor.create({
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
meta: { page, count, pageSize, pageCount },
data: { links: { next, prev } },
}),
};
};
listFiles = async (path: string, depth = 1, pagelen: number, branch: string) => {
const node = await this.branchCommitSha(branch);
const result: BitBucketSrcResult = await this.requestJSON({
url: `${this.repoURL}/src/${node}/${path}`,
params: {
max_depth: depth,
pagelen,
},
}).catch(replace404WithEmptyResponse);
const { entries, cursor } = this.getEntriesAndCursor(result);
return { entries: this.processFiles(entries), cursor: cursor as Cursor };
};
traverseCursor = async (
cursor: Cursor,
action: string,
): Promise<{
cursor: Cursor;
entries: { path: string; name: string; type: string; id: string }[];
}> =>
flow([
this.requestJSON,
then(this.getEntriesAndCursor),
then<
{ cursor: Cursor; entries: BitBucketFile[] },
{ cursor: Cursor; entries: BitBucketFile[] }
>(({ cursor: newCursor, entries }) => ({
cursor: newCursor,
entries: this.processFiles(entries),
})),
])(cursor.data!.getIn(['links', action]));
listAllFiles = async (path: string, depth: number, branch: string) => {
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
path,
depth,
100,
branch,
);
const entries = [...initialEntries];
let currentCursor = initialCursor;
while (currentCursor && currentCursor.actions!.has('next')) {
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
currentCursor,
'next',
);
entries.push(...newEntries);
currentCursor = newCursor;
}
return this.processFiles(entries);
};
async uploadFiles(
files: { path: string; newPath?: string; delete?: boolean }[],
{
commitMessage,
branch,
parentSha,
}: { commitMessage: string; branch: string; parentSha?: string },
) {
const formData = new FormData();
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
files.forEach(file => {
if (file.delete) {
// delete the file
formData.append('files', file.path);
} else if (file.newPath) {
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
toMove.push({ from: file.path, to: file.newPath, contentBlob });
} else {
// add/modify the file
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
// Third param is filename header, in case path is `message`, `branch`, etc.
formData.append(file.path, contentBlob, basename(file.path));
}
});
for (const { from, to, contentBlob } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const filesBranch = parentSha ? this.branch : branch;
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
for (const file of files) {
// to move a file in Bitbucket we need to delete the old path
// and upload the file content to the new path
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
// reports these files as deleted+added instead of renamed
// delete current path
formData.append('files', file.path);
// create in new path
const content =
file.path === from
? contentBlob
: await this.readFile(file.path, null, {
branch: filesBranch,
parseText: false,
});
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
}
}
if (commitMessage) {
formData.append('message', commitMessage);
}
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
formData.append('author', `${name} <${email}>`);
}
formData.append('branch', branch);
if (parentSha) {
formData.append('parents', parentSha);
}
try {
await this.requestText({
url: `${this.repoURL}/src`,
method: 'POST',
body: formData,
});
} catch (error: any) {
const message = error.message || '';
// very descriptive message from Bitbucket
if (parentSha && message.includes('Something went wrong')) {
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
}
throw error;
}
return files;
}
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
} else {
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
}
}
async addPullRequestComment(pullRequest: BitBucketPullRequest, comment: string) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/comments`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
content: {
raw: comment,
},
}),
});
}
async getPullRequestLabel(id: number) {
const comments: BitBucketPullComments = await this.requestJSON({
url: `${this.repoURL}/pullrequests/${id}/comments`,
params: {
pagelen: 100,
},
});
return comments.values.map(c => c.content.raw)[comments.values.length - 1];
}
async createPullRequest(branch: string, commitMessage: string, status: string) {
const pullRequest: BitBucketPullRequest = await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
title: commitMessage,
source: {
branch: {
name: branch,
},
},
destination: {
branch: {
name: this.branch,
},
},
description: DEFAULT_PR_BODY,
close_source_branch: true,
}),
});
// use comments for status labels
await this.addPullRequestComment(pullRequest, statusToLabel(status, this.cmsLabelPrefix));
}
async getDifferences(source: string, destination: string = this.branch) {
if (source === destination) {
return [];
}
const rawDiff = await this.requestText({
url: `${this.repoURL}/diff/${source}..${destination}`,
params: {
binary: false,
},
});
const diffs = parse(rawDiff).map(d => {
const oldPath = d.oldPath?.replace(/b\//, '') || '';
const newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string);
return {
oldPath,
newPath,
status: d.status,
newFile: d.status === 'added',
path,
binary: d.binary || /.svg$/.test(path),
};
});
return diffs;
}
async editorialWorkflowGit(
files: (DataFile | AssetProxy)[],
slug: string,
options: PersistOptions,
) {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
const defaultBranchSha = await this.branchCommitSha(this.branch);
await this.uploadFiles(files, {
commitMessage: options.commitMessage,
branch,
parentSha: defaultBranchSha,
});
await this.createPullRequest(
branch,
options.commitMessage,
options.status || this.initialWorkflowStatus,
);
} else {
// mark files for deletion
const diffs = await this.getDifferences(branch);
const toDelete: DeleteEntry[] = [];
for (const diff of diffs.filter(d => d.binary && d.status !== 'deleted')) {
if (!files.some(file => file.path === diff.path)) {
toDelete.push({ path: diff.path, delete: true });
}
}
await this.uploadFiles([...files, ...toDelete], {
commitMessage: options.commitMessage,
branch,
});
}
}
deleteFiles = (paths: string[], message: string) => {
const body = new FormData();
paths.forEach(path => {
body.append('files', path);
});
body.append('branch', this.branch);
if (message) {
body.append('message', message);
}
if (this.commitAuthor) {
const { name, email } = this.commitAuthor;
body.append('author', `${name} <${email}>`);
}
return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])(
`${this.repoURL}/src`,
);
};
async getPullRequests(sourceBranch?: string) {
const sourceQuery = sourceBranch
? `source.branch.name = "${sourceBranch}"`
: `source.branch.name ~ "${CMS_BRANCH_PREFIX}/"`;
const pullRequests: BitBucketPullRequests = await this.requestJSON({
url: `${this.repoURL}/pullrequests`,
params: {
pagelen: 50,
q: oneLine`
source.repository.full_name = "${this.repo}"
AND state = "${BitBucketPullRequestState.OPEN}"
AND destination.branch.name = "${this.branch}"
AND comment_count > 0
AND ${sourceQuery}
`,
},
});
const labels = await Promise.all(
pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)),
);
return pullRequests.values.filter((_, index) => isCMSLabel(labels[index], this.cmsLabelPrefix));
}
async getBranchPullRequest(branch: string) {
const pullRequests = await this.getPullRequests(branch);
if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return pullRequests[0];
}
async listUnpublishedBranches() {
console.info(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
const pullRequests = await this.getPullRequests();
const branches = pullRequests.map(mr => mr.source.branch.name);
return branches;
}
async retrieveUnpublishedEntryData(contentKey: string) {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const diffs = await this.getDifferences(branch);
const label = await this.getPullRequestLabel(pullRequest.id);
const status = labelToStatus(label, this.cmsLabelPrefix);
const updatedAt = pullRequest.updated_on;
const pullRequestAuthor = pullRequest.author.display_name;
return {
collection,
slug,
status,
// TODO: get real id
diffs: diffs
.filter(d => d.status !== 'deleted')
.map(d => ({ path: d.path, newFile: d.newFile, id: '' })),
updatedAt,
pullRequestAuthor,
};
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.addPullRequestComment(pullRequest, statusToLabel(newStatus, this.cmsLabelPrefix));
}
async mergePullRequest(pullRequest: BitBucketPullRequest) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/merge`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
message: MERGE_COMMIT_MESSAGE,
close_source_branch: true,
merge_strategy: this.mergeStrategy,
}),
});
}
async publishUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.mergePullRequest(pullRequest);
}
async declinePullRequest(pullRequest: BitBucketPullRequest) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/decline`,
});
}
async deleteBranch(branch: string) {
await this.request({
method: 'DELETE',
url: `${this.repoURL}/refs/branches/${branch}`,
});
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.declinePullRequest(pullRequest);
await this.deleteBranch(branch);
}
async getPullRequestStatuses(pullRequest: BitBucketPullRequest) {
const statuses: BitBucketPullRequestStatues = await this.requestJSON({
url: `${this.repoURL}/pullrequests/${pullRequest.id}/statuses`,
params: {
pagelen: 100,
},
});
return statuses.values;
}
async getStatuses(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const statuses = await this.getPullRequestStatuses(pullRequest);
return statuses.map(({ key, state, url }) => ({
context: key,
state:
state === BitBucketPullRequestStatusState.Successful
? PreviewState.Success
: PreviewState.Other,
target_url: url,
}));
}
async getUnpublishedEntrySha(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
return pullRequest.destination.commit.hash;
}
}

View File

@ -1,95 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { AuthenticationPage, Icon } from '../../ui';
import { NetlifyAuthenticator, ImplicitAuthenticator } from '../../lib/auth';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
export default class BitbucketAuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
base_url: PropTypes.string,
siteId: PropTypes.string,
authEndpoint: PropTypes.string,
config: PropTypes.object.isRequired,
clearHash: PropTypes.func,
t: PropTypes.func.isRequired,
};
state = {};
componentDidMount() {
const { auth_type: authType = '' } = this.props.config.backend;
if (authType === 'implicit') {
const {
base_url = 'https://bitbucket.org',
auth_endpoint = 'site/oauth2/authorize',
app_id = '',
} = this.props.config.backend;
this.auth = new ImplicitAuthenticator({
base_url,
auth_endpoint,
app_id,
clearHash: this.props.clearHash,
});
// Complete implicit authentication if we were redirected back to from the provider.
this.auth.completeAuth((err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
this.authSettings = { scope: 'repository:write' };
} else {
this.auth = new NetlifyAuthenticator({
base_url: this.props.base_url,
site_id:
document.location.host.split(':')[0] === 'localhost'
? 'cms.netlify.com'
: this.props.siteId,
auth_endpoint: this.props.authEndpoint,
});
this.authSettings = { provider: 'bitbucket', scope: 'repo' };
}
}
handleLogin = e => {
e.preventDefault();
this.auth.authenticate(this.authSettings, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};
render() {
const { inProgress, config, t } = this.props;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress}
loginErrorMessage={this.state.loginError}
logoUrl={config.logo_url}
siteUrl={config.site_url}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="bitbucket" />
{inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
</React.Fragment>
)}
t={t}
/>
);
}
}

View File

@ -1,103 +0,0 @@
import minimatch from 'minimatch';
import { unsentRequest } from '../../lib/util';
import type { ApiRequest, PointerFile } from '../../lib/util';
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
interface LfsBatchAction {
href: string;
header?: { [key: string]: string };
expires_in?: number;
expires_at?: string;
}
interface LfsBatchObject {
oid: string;
size: number;
}
interface LfsBatchObjectUpload extends LfsBatchObject {
actions?: {
upload: LfsBatchAction;
verify?: LfsBatchAction;
};
}
interface LfsBatchObjectError extends LfsBatchObject {
error: {
code: number;
message: string;
};
}
interface LfsBatchUploadResponse {
transfer?: string;
objects: (LfsBatchObjectUpload | LfsBatchObjectError)[];
}
export class GitLfsClient {
private static defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
constructor(
public enabled: boolean,
public rootURL: string,
public patterns: string[],
private makeAuthorizedRequest: MakeAuthorizedRequest,
) {}
matchPath(path: string) {
return this.patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
}
async uploadResource(pointer: PointerFile, resource: Blob): Promise<string> {
const requests = await this.getResourceUploadRequests([pointer]);
for (const request of requests) {
await this.doUpload(request.actions!.upload, resource);
if (request.actions!.verify) {
await this.doVerify(request.actions!.verify, request);
}
}
return pointer.sha;
}
private async doUpload(upload: LfsBatchAction, resource: Blob) {
await unsentRequest.fetchWithTimeout(decodeURI(upload.href), {
method: 'PUT',
body: resource,
headers: upload.header,
});
}
private async doVerify(verify: LfsBatchAction, object: LfsBatchObject) {
this.makeAuthorizedRequest({
url: decodeURI(verify.href),
method: 'POST',
headers: { ...GitLfsClient.defaultContentHeaders, ...verify.header },
body: JSON.stringify({ oid: object.oid, size: object.size }),
});
}
private async getResourceUploadRequests(objects: PointerFile[]): Promise<LfsBatchObjectUpload[]> {
const response = await this.makeAuthorizedRequest({
url: `${this.rootURL}/objects/batch`,
method: 'POST',
headers: GitLfsClient.defaultContentHeaders,
body: JSON.stringify({
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
}),
});
return ((await response.json()) as LfsBatchUploadResponse).objects.filter(object => {
if ('error' in object) {
console.error(object.error);
return false;
}
return object.actions;
});
}
}

View File

@ -1,630 +0,0 @@
import semaphore from 'semaphore';
import { trimStart } from 'lodash';
import { stripIndent } from 'common-tags';
import {
CURSOR_COMPATIBILITY_SYMBOL,
filterByExtension,
unsentRequest,
basename,
getBlobSHA,
entriesByFolder,
entriesByFiles,
getMediaDisplayURL,
getMediaAsBlob,
unpublishedEntries,
runWithLock,
asyncLock,
getPreviewStatus,
getLargeMediaPatternsFromGitAttributesFile,
getPointerFileForMediaFileObj,
getLargeMediaFilteredMediaFiles,
blobToFileObj,
contentKeyFromBranch,
generateContentKey,
localForage,
allEntriesByFolder,
AccessTokenError,
branchFromContentKey,
} from '../../lib/util';
import { NetlifyAuthenticator } from '../../lib/auth';
import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import { GitLfsClient } from './git-lfs-client';
import type {
Entry,
ApiRequest,
Cursor,
AssetProxy,
PersistOptions,
DisplayURL,
Implementation,
User,
Credentials,
Config,
ImplementationFile,
AsyncLock,
FetchError,
} from '../../lib/util';
import type { Semaphore } from 'semaphore';
const MAX_CONCURRENT_DOWNLOADS = 10;
const STATUS_PAGE = 'https://bitbucket.status.atlassian.com';
const BITBUCKET_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const BITBUCKET_OPERATIONAL_UNITS = ['API', 'Authentication and user management', 'Git LFS'];
type BitbucketStatusComponent = {
id: string;
name: string;
status: string;
};
// Implementation wrapper class
export default class BitbucketBackend implements Implementation {
lock: AsyncLock;
api: API | null;
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
options: {
proxied: boolean;
API: API | null;
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
initialWorkflowStatus: string;
};
repo: string;
branch: string;
apiRoot: string;
baseUrl: string;
siteId: string;
token: string | null;
mediaFolder: string;
refreshToken?: string;
refreshedTokenPromise?: Promise<string>;
authenticator?: NetlifyAuthenticator;
_mediaDisplayURLSem?: Semaphore;
squashMerges: boolean;
cmsLabelPrefix: string;
previewContext: string;
largeMediaURL: string;
_largeMediaClientPromise?: Promise<GitLfsClient>;
authType: string;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
updateUserCredentials: async () => null,
initialWorkflowStatus: '',
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.updateUserCredentials = this.options.updateUserCredentials;
this.repo = config.backend.repo || '';
this.branch = config.backend.branch || 'master';
this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0';
this.baseUrl = config.base_url || '';
this.siteId = config.site_id || '';
this.largeMediaURL =
config.backend.large_media_url || `https://bitbucket.org/${config.backend.repo}/info/lfs`;
this.token = '';
this.mediaFolder = config.media_folder;
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
this.authType = config.backend.auth_type || '';
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(BITBUCKET_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: BitbucketStatusComponent) =>
BITBUCKET_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every(
(statusComponent: BitbucketStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting BitBucket status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting Bitbucket user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
authComponent() {
return AuthenticationPage;
}
setUser(user: { token: string }) {
this.token = user.token;
this.api = new API({
requestFunction: this.apiRequestFunction,
branch: this.branch,
repo: this.repo,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
}
requestFunction = async (req: ApiRequest) => {
const token = await this.getToken();
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
return unsentRequest.performRequest(authorizedRequest);
};
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.refreshToken = state.refresh_token;
this.api = new API({
requestFunction: this.apiRequestFunction,
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const isCollab = await this.api.hasWriteAccess().catch(error => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a Bitbucket account with access.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your BitBucket user account does not have access to this repo.');
}
const user = await this.api.user();
// Authorized user
return {
...user,
name: user.display_name,
login: user.username,
token: state.token,
avatar_url: user.links.avatar.href,
refresh_token: state.refresh_token,
};
}
getRefreshedAccessToken() {
if (this.authType === 'implicit') {
throw new AccessTokenError(`Can't refresh access token when using implicit auth`);
}
if (this.refreshedTokenPromise) {
return this.refreshedTokenPromise;
}
// instantiating a new Authenticator on each refresh isn't ideal,
if (!this.authenticator) {
const cfg = {
base_url: this.baseUrl,
site_id: this.siteId,
};
this.authenticator = new NetlifyAuthenticator(cfg);
}
this.refreshedTokenPromise = this.authenticator!.refresh({
provider: 'bitbucket',
refresh_token: this.refreshToken as string,
}).then(({ token, refresh_token }) => {
this.token = token;
this.refreshToken = refresh_token;
this.refreshedTokenPromise = undefined;
this.updateUserCredentials({ token, refresh_token });
return token;
});
return this.refreshedTokenPromise;
}
logout() {
this.token = null;
return;
}
getToken() {
if (this.refreshedTokenPromise) {
return this.refreshedTokenPromise;
}
return Promise.resolve(this.token);
}
apiRequestFunction = async (req: ApiRequest) => {
const token = (
this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token
) as string;
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
const response: Response = await unsentRequest.performRequest(authorizedRequest);
if (response.status === 401) {
const json = await response.json().catch(() => null);
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
const newToken = await this.getRefreshedAccessToken();
const reqWithNewToken = unsentRequest.withHeaders(
{
Authorization: `Bearer ${newToken}`,
},
req,
) as ApiRequest;
return unsentRequest.performRequest(reqWithNewToken);
}
}
return response;
};
async entriesByFolder(folder: string, extension: string, depth: number) {
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, depth, 20, this.branch).then(({ entries, cursor: c }) => {
cursor = c.mergeMeta({ extension });
return entries.filter(e => filterByExtension(e, extension));
});
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth, this.branch);
const filtered = files.filter(file => filterByExtension(file, extension));
return filtered;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const files = await allEntriesByFolder({
listAllFiles: () => this.listAllFiles(folder, extension, depth),
readFile,
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
apiName: API_NAME,
branch: this.branch,
localForage,
folder,
extension,
depth,
getDefaultBranch: () => Promise.resolve({ name: this.branch, sha: head }),
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
getDifferences: (source, destination) => this.api!.getDifferences(source, destination),
getFileId: path => Promise.resolve(this.api!.getFileId(head, path)),
filterFile: file => filterByExtension(file, extension),
});
return files;
}
async entriesByFiles(files: ImplementationFile[]) {
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
}
getEntry(path: string) {
return this.api!.readFile(path).then(data => ({
file: { path, id: null },
data: data as string,
}));
}
getMedia(mediaFolder = this.mediaFolder) {
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
);
}
getLargeMediaClient() {
if (!this._largeMediaClientPromise) {
this._largeMediaClientPromise = (async (): Promise<GitLfsClient> => {
const patterns = await this.api!.readFile('.gitattributes')
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.catch((err: FetchError) => {
if (err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
} else {
console.error(err);
}
return [];
});
return new GitLfsClient(
!!(this.largeMediaURL && patterns.length > 0),
this.largeMediaURL,
patterns,
this.requestFunction,
);
})();
}
return this._largeMediaClientPromise;
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(fileObj);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: Entry, options: PersistOptions) {
const client = await this.getLargeMediaClient();
// persistEntry is a transactional operation
return runWithLock(
this.lock,
async () =>
this.api!.persistFiles(
entry.dataFiles,
client.enabled
? await getLargeMediaFilteredMediaFiles(client, entry.assets)
: entry.assets,
options,
),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const { fileObj, path } = mediaFile;
const displayURL = URL.createObjectURL(fileObj as Blob);
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
if (!client.enabled || !client.matchPath(fixedPath)) {
return this._persistMedia(mediaFile, options);
}
const persistMediaArgument = await getPointerFileForMediaFileObj(client, fileObj as File, path);
return {
...(await this._persistMedia(persistMediaArgument, options)),
displayURL,
};
}
async _persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(mediaFile.path, '/k'),
name: fileObj!.name,
size: fileObj!.size,
id,
file: fileObj,
url,
};
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
traverseCursor(cursor: Cursor, action: string) {
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
const extension = cursor.meta?.get('extension');
if (extension) {
entries = entries.filter(e => filterByExtension(e, extension));
newCursor = newCursor.mergeMeta({ extension });
}
const head = await this.api!.defaultBranchCommitSha();
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { head }) as Promise<string>;
};
const entriesWithData = await entriesByFiles(
entries,
readFile,
this.api!.readFileMetadata.bind(this.api)!,
API_NAME,
);
return {
entries: entriesWithData,
cursor: newCursor,
};
});
}
async loadMediaFile(path: string, id: string, { branch }: { branch: string }) {
const readFile = async (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => {
const content = await this.api!.readFile(path, id, { branch, parseText });
return content;
};
const blob = await getMediaAsBlob(path, id, readFile);
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
return {
id: path,
displayURL: URL.createObjectURL(fileObj),
path,
name,
size: fileObj.size,
file: fileObj,
};
}
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(path, id, { branch });
return mediaFile;
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
async deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
async publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View File

@ -1,10 +0,0 @@
import BitbucketBackend from './implementation';
import API from './API';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendBitbucket = {
BitbucketBackend,
API,
AuthenticationPage,
};
export { BitbucketBackend, API, AuthenticationPage };

View File

@ -1,5 +0,0 @@
declare module 'semaphore' {
export type Semaphore = { take: (f: Function) => void; leave: () => void };
const semaphore: (count: number) => Semaphore;
export default semaphore;
}

View File

@ -1,5 +0,0 @@
declare module 'what-the-diff' {
export const parse: (
rawDiff: string,
) => { oldPath?: string; newPath?: string; binary: boolean; status: string }[];
}

View File

@ -1,230 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from '@emotion/styled';
import { partial } from 'lodash';
import {
AuthenticationPage,
buttons,
shadows,
colors,
colorsRaw,
lengths,
zIndex,
} from '../../ui';
const LoginButton = styled.button`
${buttons.button};
${shadows.dropDeep};
${buttons.default};
${buttons.gray};
padding: 0 30px;
display: block;
margin-top: 20px;
margin-left: auto;
`;
const AuthForm = styled.form`
width: 350px;
margin-top: -80px;
`;
const AuthInput = styled.input`
background-color: ${colorsRaw.white};
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px;
margin-bottom: 15px;
margin-top: 6px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
border: 1px solid ${colorsRaw.gray};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colors.active};
border: 1px solid transparent;
}
`;
const ErrorMessage = styled.p`
color: ${colors.errorText};
`;
let component = null;
if (window.netlifyIdentity) {
window.netlifyIdentity.on('login', user => {
component && component.handleIdentityLogin(user);
});
window.netlifyIdentity.on('logout', () => {
component && component.handleIdentityLogout();
});
window.netlifyIdentity.on('error', err => {
component && component.handleIdentityError(err);
});
}
export default class GitGatewayAuthenticationPage extends React.Component {
static authClient;
constructor(props) {
super(props);
component = this;
}
componentDidMount() {
if (!this.loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
this.props.onLogin(window.netlifyIdentity.currentUser());
window.netlifyIdentity.close();
}
}
componentWillUnmount() {
component = null;
}
handleIdentityLogin = user => {
this.props.onLogin(user);
window.netlifyIdentity.close();
};
handleIdentityLogout = () => {
window.netlifyIdentity.open();
};
handleIdentityError = err => {
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
window.netlifyIdentity.close();
this.setState({
errors: { identity: this.props.t('auth.errors.identitySettings') },
});
}
};
handleIdentity = () => {
const user = window.netlifyIdentity.currentUser();
if (user) {
this.props.onLogin(user);
} else {
window.netlifyIdentity.open();
}
};
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool.isRequired,
error: PropTypes.node,
config: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
state = { email: '', password: '', errors: {} };
handleChange = (name, e) => {
this.setState({ ...this.state, [name]: e.target.value });
};
handleLogin = async e => {
e.preventDefault();
const { email, password } = this.state;
const { t } = this.props;
const errors = {};
if (!email) {
errors.email = t('auth.errors.email');
}
if (!password) {
errors.password = t('auth.errors.password');
}
if (Object.keys(errors).length > 0) {
this.setState({ errors });
return;
}
try {
const client = await GitGatewayAuthenticationPage.authClient();
const user = await client.login(this.state.email, this.state.password, true);
this.props.onLogin(user);
} catch (error) {
this.setState({
errors: { server: error.description || error.msg || error },
loggingIn: false,
});
}
};
render() {
const { errors } = this.state;
const { error, inProgress, config, t } = this.props;
if (window.netlifyIdentity) {
if (errors.identity) {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={this.handleIdentity}
renderPageContent={() => (
<a
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
target="_blank"
rel="noopener noreferrer"
>
{errors.identity}
</a>
)}
t={t}
/>
);
} else {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={this.handleIdentity}
renderButtonContent={() => t('auth.loginWithNetlifyIdentity')}
t={t}
/>
);
}
}
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
renderPageContent={() => (
<AuthForm onSubmit={this.handleLogin}>
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
<ErrorMessage>{errors.email || null}</ErrorMessage>
<AuthInput
type="text"
name="email"
placeholder="Email"
value={this.state.email}
onChange={partial(this.handleChange, 'email')}
/>
<ErrorMessage>{errors.password || null}</ErrorMessage>
<AuthInput
type="password"
name="password"
placeholder="Password"
value={this.state.password}
onChange={partial(this.handleChange, 'password')}
/>
<LoginButton disabled={inProgress}>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</LoginButton>
</AuthForm>
)}
t={t}
/>
);
}
}

View File

@ -1,130 +0,0 @@
import { APIError } from '../../lib/util';
import { API as GithubAPI } from '../github';
import type { Config as GitHubConfig, Diff } from '../github/API';
import type { FetchError } from '../../lib/util';
import type { Octokit } from '@octokit/rest';
type Config = GitHubConfig & {
apiRoot: string;
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
};
export default class API extends GithubAPI {
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
constructor(config: Config) {
super(config);
this.apiRoot = config.apiRoot;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.isLargeMedia = config.isLargeMedia;
this.repoURL = '';
this.originRepoURL = '';
}
hasWriteAccess() {
return this.getDefaultBranch()
.then(() => true)
.catch((error: FetchError) => {
if (error.status === 401) {
if (error.message === 'Bad credentials') {
throw new APIError(
'Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.',
error.status,
'Git Gateway',
);
} else {
return false;
}
} else if (
error.status === 404 &&
(error.message === undefined || error.message === 'Unable to locate site configuration')
) {
throw new APIError(
`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`,
error.status,
'Git Gateway',
);
} else {
console.error('Problem fetching repo data from Git Gateway');
throw error;
}
});
}
requestHeaders(headers = {}) {
return this.tokenPromise().then(jwtToken => {
const baseHeader = {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json; charset=utf-8',
...headers,
};
return baseHeader;
});
}
handleRequestError(error: FetchError & { msg: string }, responseStatus: number) {
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
}
user() {
return Promise.resolve({ login: '', ...this.commitAuthor });
}
async getHeadReference(head: string) {
if (!this.repoOwner) {
// get the repo owner from the branch url
// this is required for returning the full head reference, e.g. owner:head
// when filtering pull requests based on the head
const branch = await this.getDefaultBranch();
const self = branch._links.self;
const regex = new RegExp('https?://.+?/repos/(.+?)/');
const owner = self.match(regex);
this.repoOwner = owner ? owner[1] : '';
}
return super.getHeadReference(head);
}
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
const commitParams: {
message: string;
tree: string;
parents: string[];
author?: { name: string; date: string };
} = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request('/git/commits', {
method: 'POST',
body: JSON.stringify(commitParams),
});
}
nextUrlProcessor() {
return (url: string) => url.replace(/^(?:[a-z]+:\/\/.+?\/.+?\/.+?\/)/, `${this.apiRoot}/`);
}
async diffFromFile(file: Octokit.ReposCompareCommitsResponseFilesItem): Promise<Diff> {
const diff = await super.diffFromFile(file);
return {
...diff,
binary: diff.binary || (await this.isLargeMedia(file.filename)),
};
}
}

View File

@ -1,30 +0,0 @@
import { unsentRequest } from '../../lib/util';
import { API as GitlabAPI } from '../gitlab';
import type { Config as GitLabConfig, CommitAuthor } from '../gitlab/API';
import type { ApiRequest } from '../../lib/util';
type Config = GitLabConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
export default class API extends GitlabAPI {
tokenPromise: () => Promise<string>;
constructor(config: Config) {
super(config);
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = '';
}
withAuthorizationHeaders = async (req: ApiRequest) => {
const token = await this.tokenPromise();
return unsentRequest.withHeaders(
{
Authorization: `Bearer ${token}`,
},
req,
);
};
hasWriteAccess = () => Promise.resolve(true);
}

View File

@ -1,627 +0,0 @@
import GoTrue from 'gotrue-js';
import jwtDecode from 'jwt-decode';
import { get, pick, intersection } from 'lodash';
import ini from 'ini';
import {
APIError,
unsentRequest,
basename,
entriesByFiles,
parsePointerFile,
getLargeMediaPatternsFromGitAttributesFile,
getPointerFileForMediaFileObj,
getLargeMediaFilteredMediaFiles,
AccessTokenError,
PreviewState,
} from '../../lib/util';
import { GitHubBackend } from '../github';
import { GitLabBackend } from '../gitlab';
import { BitbucketBackend, API as BitBucketAPI } from '../bitbucket';
import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import AuthenticationPage from './AuthenticationPage';
import { getClient } from './netlify-lfs-client';
import type { Client } from './netlify-lfs-client';
import type {
ApiRequest,
AssetProxy,
PersistOptions,
Entry,
Cursor,
Implementation,
DisplayURL,
User,
Credentials,
Config,
ImplementationFile,
DisplayURLObject,
} from '../../lib/util';
const STATUS_PAGE = 'https://www.netlifystatus.com';
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GIT_GATEWAY_OPERATIONAL_UNITS = ['Git Gateway'];
type GitGatewayStatus = {
id: string;
name: string;
status: string;
};
type NetlifyIdentity = {
logout: () => void;
currentUser: () => User;
on: (event: string, args: unknown) => void;
init: () => void;
store: { user: unknown; modal: { page: string }; saving: boolean };
};
type AuthClient = {
logout: () => void;
currentUser: () => unknown;
login?(email: string, password: string, remember?: boolean): Promise<unknown>;
clearStore: () => void;
};
declare global {
interface Window {
netlifyIdentity?: NetlifyIdentity;
}
}
const localHosts: Record<string, boolean> = {
localhost: true,
'127.0.0.1': true,
'0.0.0.0': true,
};
const defaults = {
identity: '/.netlify/identity',
gateway: '/.netlify/git',
largeMedia: '/.netlify/large-media',
};
function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
if (
localHosts[document.location.host.split(':').shift() as string] &&
netlifySiteURL &&
endpoint.match(/^\/\.netlify\//)
) {
const parts = [];
if (netlifySiteURL) {
parts.push(netlifySiteURL);
if (!netlifySiteURL.match(/\/$/)) {
parts.push('/');
}
}
parts.push(endpoint.replace(/^\//, ''));
return parts.join('');
}
return endpoint;
}
// wait for identity widget to initialize
// force init on timeout
let initPromise = Promise.resolve() as Promise<unknown>;
if (window.netlifyIdentity) {
let initialized = false;
initPromise = Promise.race([
new Promise<void>(resolve => {
window.netlifyIdentity?.on('init', () => {
initialized = true;
resolve();
});
}),
new Promise(resolve => setTimeout(resolve, 2500)).then(() => {
if (!initialized) {
console.info('Manually initializing identity widget');
window.netlifyIdentity?.init();
}
}),
]);
}
interface NetlifyUser extends Credentials {
jwt: () => Promise<string>;
email: string;
user_metadata: { full_name: string; avatar_url: string };
}
async function apiGet(path: string) {
const apiRoot = 'https://api.netlify.com/api/v1/sites';
const response = await fetch(`${apiRoot}/${path}`).then(res => res.json());
return response;
}
export default class GitGateway implements Implementation {
config: Config;
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string;
transformImages: boolean;
gatewayUrl: string;
netlifyLargeMediaURL: string;
backendType: string | null;
apiUrl: string;
authClient?: AuthClient;
backend: GitHubBackend | GitLabBackend | BitbucketBackend | null;
acceptRoles?: string[];
tokenPromise?: () => Promise<string>;
_largeMediaClientPromise?: Promise<Client>;
options: {
proxied: boolean;
API: GitHubAPI | GitLabAPI | BitBucketAPI | null;
initialWorkflowStatus: string;
};
constructor(config: Config, options = {}) {
this.options = {
proxied: true,
API: null,
initialWorkflowStatus: '',
...options,
};
this.config = config;
this.branch = config.backend.branch?.trim() || 'master';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend;
this.transformImages = transformImages;
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
this.apiUrl = getEndpoint(config.backend.identity_url || defaults.identity, netlifySiteURL);
this.gatewayUrl = getEndpoint(config.backend.gateway_url || defaults.gateway, netlifySiteURL);
this.netlifyLargeMediaURL = getEndpoint(
config.backend.large_media_url || defaults.largeMedia,
netlifySiteURL,
);
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
if (backendTypeMatches) {
this.backendType = backendTypeMatches[1];
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
} else {
this.backendType = null;
}
this.backend = null;
AuthenticationPage.authClient = () => this.getAuthClient();
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: GitGatewayStatus) =>
GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
})
.catch(e => {
console.warn('Failed getting Git Gateway status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.tokenPromise?.()
.then(token => !!token)
.catch(e => {
console.warn('Failed getting Identity token', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
async getAuthClient() {
if (this.authClient) {
return this.authClient;
}
await initPromise;
if (window.netlifyIdentity) {
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
clearStore: () => {
const store = window.netlifyIdentity?.store;
if (store) {
store.user = null;
store.modal.page = 'login';
store.saving = false;
}
},
};
} else {
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
this.authClient = {
logout: () => {
const user = goTrue.currentUser();
if (user) {
return user.logout();
}
},
currentUser: () => goTrue.currentUser(),
login: goTrue.login.bind(goTrue),
clearStore: () => undefined,
};
}
return this.authClient;
}
requestFunction = (req: ApiRequest) =>
this.tokenPromise!()
.then(
token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req) as ApiRequest,
)
.then(unsentRequest.performRequest);
authenticate(credentials: Credentials) {
const user = credentials as NetlifyUser;
this.tokenPromise = async () => {
try {
const func = user.jwt.bind(user);
const token = await func();
return token;
} catch (error: any) {
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
}
};
return this.tokenPromise!().then(async token => {
if (!this.backendType) {
const {
github_enabled: githubEnabled,
gitlab_enabled: gitlabEnabled,
bitbucket_enabled: bitbucketEnabled,
roles,
} = await unsentRequest
.fetchWithTimeout(`${this.gatewayUrl}/settings`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(async res => {
const contentType = res.headers.get('Content-Type') || '';
if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
throw new APIError(
`Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
res.status,
'Git Gateway',
);
}
const body = await res.json();
if (!res.ok) {
throw new APIError(
`Git Gateway Error: ${body.message ? body.message : body}`,
res.status,
'Git Gateway',
);
}
return body;
});
this.acceptRoles = roles;
if (githubEnabled) {
this.backendType = 'github';
} else if (gitlabEnabled) {
this.backendType = 'gitlab';
} else if (bitbucketEnabled) {
this.backendType = 'bitbucket';
}
}
if (this.acceptRoles && this.acceptRoles.length > 0) {
const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
const validRole = intersection(userRoles, this.acceptRoles).length > 0;
if (!validRole) {
throw new Error("You don't have sufficient permissions to access Netlify CMS");
}
}
const userData = {
name: user.user_metadata.full_name || user.email.split('@').shift()!,
email: user.email,
avatar_url: user.user_metadata.avatar_url,
metadata: user.user_metadata,
};
const apiConfig = {
apiRoot: `${this.gatewayUrl}/${this.backendType}`,
branch: this.branch,
tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']),
isLargeMedia: (filename: string) => this.isLargeMediaFile(filename),
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
};
if (this.backendType === 'github') {
this.api = new GitHubAPI(apiConfig);
this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'gitlab') {
this.api = new GitLabAPI(apiConfig);
this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'bitbucket') {
this.api = new BitBucketAPI({
...apiConfig,
requestFunction: this.requestFunction,
hasWriteAccess: async () => true,
});
this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
}
if (!(await this.api!.hasWriteAccess())) {
throw new Error("You don't have sufficient permissions to access Netlify CMS");
}
return { name: userData.name, login: userData.email } as User;
});
}
async restoreUser() {
const client = await this.getAuthClient();
const user = client.currentUser();
if (!user) return Promise.reject();
return this.authenticate(user as Credentials);
}
authComponent() {
return AuthenticationPage;
}
async logout() {
const client = await this.getAuthClient();
try {
client.logout();
} catch (e) {
// due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails
// TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged
client.clearStore();
}
}
getToken() {
return this.tokenPromise!();
}
async entriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.entriesByFolder(folder, extension, depth);
}
allEntriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.allEntriesByFolder(folder, extension, depth);
}
entriesByFiles(files: ImplementationFile[]) {
return this.backend!.entriesByFiles(files);
}
getEntry(path: string) {
return this.backend!.getEntry(path);
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
return this.backend!.unpublishedEntryDataFile(collection, slug, path, id);
}
async isLargeMediaFile(path: string) {
const client = await this.getLargeMediaClient();
return client.enabled && client.matchPath(path);
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const branch = this.backend!.getBranch(collection, slug);
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id }, branch);
const name = basename(path);
return {
id,
name,
path,
url,
displayURL: url,
file: new File([blob], name),
size: blob.size,
};
} else {
return this.backend!.unpublishedEntryMediaFile(collection, slug, path, id);
}
}
getMedia(mediaFolder = this.mediaFolder) {
return this.backend!.getMedia(mediaFolder);
}
// this method memoizes this._getLargeMediaClient so that there can
// only be one client at a time
getLargeMediaClient() {
if (this._largeMediaClientPromise) {
return this._largeMediaClientPromise;
}
this._largeMediaClientPromise = this._getLargeMediaClient();
return this._largeMediaClientPromise;
}
_getLargeMediaClient() {
const netlifyLargeMediaEnabledPromise = this.api!.readFile('.lfsconfig')
.then(config => ini.decode<{ lfs: { url: string } }>(config as string))
.then(({ lfs: { url } }) => new URL(url))
.then(lfsURL => ({
enabled: lfsURL.hostname.endsWith('netlify.com') || lfsURL.hostname.endsWith('netlify.app'),
}))
.catch((err: Error) => ({ enabled: false, err }));
const lfsPatternsPromise = this.api!.readFile('.gitattributes')
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.then((patterns: string[]) => ({ err: null, patterns }))
.catch((err: Error) => {
if (err.message.includes('404')) {
console.info('This 404 was expected and handled appropriately.');
return { err: null, patterns: [] as string[] };
} else {
return { err, patterns: [] as string[] };
}
});
return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
const enabled = maybeEnabled && !patternsErr;
// We expect LFS patterns to exist when the .lfsconfig states
// that we're using Netlify Large Media
if (maybeEnabled && patternsErr) {
console.error(patternsErr);
}
return getClient({
enabled,
rootURL: this.netlifyLargeMediaURL,
makeAuthorizedRequest: this.requestFunction,
patterns,
transformImages: this.transformImages ? { nf_resize: 'fit', w: 560, h: 320 } : false,
});
},
);
}
async getLargeMediaDisplayURL(
{ path, id }: { path: string; id: string | null },
branch = this.branch,
) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const items = await entriesByFiles(
[{ path, id }],
readFile,
this.api!.readFileMetadata.bind(this.api),
'Git-Gateway',
);
const entry = items[0];
const pointerFile = parsePointerFile(entry.data);
if (!pointerFile.sha) {
console.warn(`Failed parsing pointer file ${path}`);
return { url: path, blob: new Blob() };
}
const client = await this.getLargeMediaClient();
const { url, blob } = await client.getDownloadURL(pointerFile);
return { url, blob };
}
async getMediaDisplayURL(displayURL: DisplayURL) {
const { path, id } = displayURL as DisplayURLObject;
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url } = await this.getLargeMediaDisplayURL({ path, id });
return url;
}
if (typeof displayURL === 'string') {
return displayURL;
}
const url = await this.backend!.getMediaDisplayURL(displayURL);
return url;
}
async getMediaFile(path: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id: null });
const name = basename(path);
return {
id: url,
name,
path,
url,
displayURL: url,
file: new File([blob], name),
size: blob.size,
};
}
return this.backend!.getMediaFile(path);
}
async persistEntry(entry: Entry, options: PersistOptions) {
const client = await this.getLargeMediaClient();
if (client.enabled) {
const assets = await getLargeMediaFilteredMediaFiles(client, entry.assets);
return this.backend!.persistEntry({ ...entry, assets }, options);
} else {
return this.backend!.persistEntry(entry, options);
}
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const { fileObj, path } = mediaFile;
const displayURL = URL.createObjectURL(fileObj as Blob);
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
const isLargeMedia = await this.isLargeMediaFile(fixedPath);
if (isLargeMedia) {
const persistMediaArgument = await getPointerFileForMediaFileObj(
client,
fileObj as File,
path,
);
return {
...(await this.backend!.persistMedia(persistMediaArgument, options)),
displayURL,
};
}
return await this.backend!.persistMedia(mediaFile, options);
}
deleteFiles(paths: string[], commitMessage: string) {
return this.backend!.deleteFiles(paths, commitMessage);
}
async getDeployPreview(collection: string, slug: string) {
let preview = await this.backend!.getDeployPreview(collection, slug);
if (!preview) {
try {
// if the commit doesn't have a status, try to use Netlify API directly
// this is useful when builds are queue up in Netlify and don't have a commit status yet
// and only works with public logs at the moment
// TODO: get Netlify API Token and use it to access private logs
const siteId = new URL(localStorage.getItem('netlifySiteURL') || '').hostname;
const site = await apiGet(siteId);
const deploys: { state: string; commit_ref: string; deploy_url: string }[] = await apiGet(
`${site.id}/deploys?per_page=100`,
);
if (deploys.length > 0) {
const ref = await this.api!.getUnpublishedEntrySha(collection, slug);
const deploy = deploys.find(d => d.commit_ref === ref);
if (deploy) {
preview = {
status: deploy.state === 'ready' ? PreviewState.Success : PreviewState.Other,
url: deploy.deploy_url,
};
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
}
return preview;
}
unpublishedEntries() {
return this.backend!.unpublishedEntries();
}
unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) {
return this.backend!.unpublishedEntry({ id, collection, slug });
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.backend!.deleteUnpublishedEntry(collection, slug);
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.backend!.publishUnpublishedEntry(collection, slug);
}
traverseCursor(cursor: Cursor, action: string) {
return this.backend!.traverseCursor!(cursor, action);
}
}

View File

@ -1,8 +0,0 @@
import GitGatewayBackend from './implementation';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendGitGateway = {
GitGatewayBackend,
AuthenticationPage,
};
export { GitGatewayBackend, AuthenticationPage };

View File

@ -1,181 +0,0 @@
import { flow, fromPairs, map } from 'lodash/fp';
import { isPlainObject, isEmpty } from 'lodash';
import minimatch from 'minimatch';
import { unsentRequest } from '../../lib/util';
import type { ApiRequest, PointerFile } from '../../lib/util';
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
type ImageTransformations = { nf_resize: string; w: number; h: number };
type ClientConfig = {
rootURL: string;
makeAuthorizedRequest: MakeAuthorizedRequest;
patterns: string[];
enabled: boolean;
transformImages: ImageTransformations | boolean;
};
export function matchPath({ patterns }: ClientConfig, path: string) {
return patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
}
//
// API interactions
const defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
async function resourceExists(
{ rootURL, makeAuthorizedRequest }: ClientConfig,
{ sha, size }: PointerFile,
) {
const response = await makeAuthorizedRequest({
url: `${rootURL}/verify`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify({ oid: sha, size }),
});
if (response.ok) {
return true;
}
if (response.status === 404) {
return false;
}
// TODO: what kind of error to throw here? APIError doesn't seem
// to fit
}
function getTransofrmationsParams(t: boolean | ImageTransformations) {
if (isPlainObject(t) && !isEmpty(t)) {
const { nf_resize: resize, w, h } = t as ImageTransformations;
return `?nf_resize=${resize}&w=${w}&h=${h}`;
}
return '';
}
async function getDownloadURL(
{ rootURL, transformImages: t, makeAuthorizedRequest }: ClientConfig,
{ sha }: PointerFile,
) {
try {
const transformation = getTransofrmationsParams(t);
const transformedPromise = makeAuthorizedRequest(`${rootURL}/origin/${sha}${transformation}`);
const [transformed, original] = await Promise.all([
transformedPromise,
// if transformation is defined, we need to load the original so we have the correct meta data
transformation ? makeAuthorizedRequest(`${rootURL}/origin/${sha}`) : transformedPromise,
]);
if (!transformed.ok) {
const error = await transformed.json();
throw new Error(
`Failed getting large media for sha '${sha}': '${error.code} - ${error.msg}'`,
);
}
const transformedBlob = await transformed.blob();
const url = URL.createObjectURL(transformedBlob);
return { url, blob: transformation ? await original.blob() : transformedBlob };
} catch (error) {
console.error(error);
return { url: '', blob: new Blob() };
}
}
function uploadOperation(objects: PointerFile[]) {
return {
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
};
}
async function getResourceUploadURLs(
{
rootURL,
makeAuthorizedRequest,
}: { rootURL: string; makeAuthorizedRequest: MakeAuthorizedRequest },
pointerFiles: PointerFile[],
) {
const response = await makeAuthorizedRequest({
url: `${rootURL}/objects/batch`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify(uploadOperation(pointerFiles)),
});
const { objects } = await response.json();
const uploadUrls = objects.map(
(object: { error?: { message: string }; actions: { upload: { href: string } } }) => {
if (object.error) {
throw new Error(object.error.message);
}
return object.actions.upload.href;
},
);
return uploadUrls;
}
function uploadBlob(uploadURL: string, blob: Blob) {
return unsentRequest.fetchWithTimeout(uploadURL, {
method: 'PUT',
body: blob,
});
}
async function uploadResource(
clientConfig: ClientConfig,
{ sha, size }: PointerFile,
resource: Blob,
) {
const existingFile = await resourceExists(clientConfig, { sha, size });
if (existingFile) {
return sha;
}
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
await uploadBlob(uploadURL, resource);
return sha;
}
//
// Create Large Media client
function configureFn(config: ClientConfig, fn: Function) {
return (...args: unknown[]) => fn(config, ...args);
}
const clientFns: Record<string, Function> = {
resourceExists,
getResourceUploadURLs,
getDownloadURL,
uploadResource,
matchPath,
};
export type Client = {
resourceExists: (pointer: PointerFile) => Promise<boolean | undefined>;
getResourceUploadURLs: (objects: PointerFile[]) => Promise<string>;
getDownloadURL: (pointer: PointerFile) => Promise<{ url: string; blob: Blob }>;
uploadResource: (pointer: PointerFile, blob: Blob) => Promise<string>;
matchPath: (path: string) => boolean;
patterns: string[];
enabled: boolean;
};
export function getClient(clientConfig: ClientConfig) {
return flow([
Object.keys,
map((key: string) => [key, configureFn(clientConfig, clientFns[key])]),
fromPairs,
configuredFns => ({
...configuredFns,
patterns: clientConfig.patterns,
enabled: clientConfig.enabled,
}),
])(clientFns);
}

View File

@ -1,4 +0,0 @@
declare module 'ini' {
const ini: { decode: <T>(ini: string) => T };
export default ini;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,152 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { AuthenticationPage, Icon } from '../../ui';
import { NetlifyAuthenticator } from '../../lib/auth';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const ForkApprovalContainer = styled.div`
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
flex-grow: 0.2;
`;
const ForkButtonsContainer = styled.div`
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
align-items: center;
`;
export default class GitHubAuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
base_url: PropTypes.string,
siteId: PropTypes.string,
authEndpoint: PropTypes.string,
config: PropTypes.object.isRequired,
clearHash: PropTypes.func,
t: PropTypes.func.isRequired,
};
state = {};
getPermissionToFork = () => {
return new Promise((resolve, reject) => {
this.setState({
requestingFork: true,
approveFork: () => {
this.setState({ requestingFork: false });
resolve();
},
refuseFork: () => {
this.setState({ requestingFork: false });
reject();
},
});
});
};
loginWithOpenAuthoring(data) {
const { backend } = this.props;
this.setState({ findingFork: true });
return backend
.authenticateWithFork({ userData: data, getPermissionToFork: this.getPermissionToFork })
.catch(err => {
this.setState({ findingFork: false });
console.error(err);
throw err;
});
}
handleLogin = e => {
e.preventDefault();
const cfg = {
base_url: this.props.base_url,
site_id:
document.location.host.split(':')[0] === 'localhost'
? 'cms.netlify.com'
: this.props.siteId,
auth_endpoint: this.props.authEndpoint,
};
const auth = new NetlifyAuthenticator(cfg);
const { open_authoring: openAuthoring = false, auth_scope: authScope = '' } =
this.props.config.backend;
const scope = authScope || (openAuthoring ? 'public_repo' : 'repo');
auth.authenticate({ provider: 'github', scope }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
if (openAuthoring) {
return this.loginWithOpenAuthoring(data).then(() => this.props.onLogin(data));
}
this.props.onLogin(data);
});
};
renderLoginButton = () => {
const { inProgress, t } = this.props;
return inProgress || this.state.findingFork ? (
t('auth.loggingIn')
) : (
<React.Fragment>
<LoginButtonIcon type="github" />
{t('auth.loginWithGitHub')}
</React.Fragment>
);
};
getAuthenticationPageRenderArgs() {
const { requestingFork } = this.state;
if (requestingFork) {
const { approveFork, refuseFork } = this.state;
return {
renderPageContent: ({ LoginButton, TextButton, showAbortButton }) => (
<ForkApprovalContainer>
<p>
Open Authoring is enabled: we need to use a fork on your github account. (If a fork
already exists, we&#39;ll use that.)
</p>
<ForkButtonsContainer>
<LoginButton onClick={approveFork}>Fork the repo</LoginButton>
{showAbortButton && (
<TextButton onClick={refuseFork}>Don&#39;t fork the repo</TextButton>
)}
</ForkButtonsContainer>
</ForkApprovalContainer>
),
};
}
return {
renderButtonContent: this.renderLoginButton,
};
}
render() {
const { inProgress, config, t } = this.props;
const { loginError, requestingFork, findingFork } = this.state;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress || findingFork || requestingFork}
loginErrorMessage={loginError}
logoUrl={config.logo_url}
siteUrl={config.site_url}
{...this.getAuthenticationPageRenderArgs()}
t={t}
/>
);
}
}

View File

@ -1,709 +0,0 @@
import { ApolloClient } from 'apollo-client';
import {
InMemoryCache,
defaultDataIdFromObject,
IntrospectionFragmentMatcher,
} from 'apollo-cache-inmemory';
import { createHttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import { trim, trimStart } from 'lodash';
import {
APIError,
readFile,
localForage,
DEFAULT_PR_BODY,
branchFromContentKey,
CMS_BRANCH_PREFIX,
throwOnConflictingBranches,
} from '../../lib/util';
import introspectionQueryResultData from './fragmentTypes';
import API, { API_NAME, PullRequestState, MOCK_PULL_REQUEST } from './API';
import * as queries from './queries';
import * as mutations from './mutations';
import type { Config, BlobArgs } from './API';
import type { NormalizedCacheObject } from 'apollo-cache-inmemory';
import type { QueryOptions, MutationOptions, OperationVariables } from 'apollo-client';
import type { GraphQLError } from 'graphql';
import type { Octokit } from '@octokit/rest';
const NO_CACHE = 'no-cache';
const CACHE_FIRST = 'cache-first';
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
interface TreeEntry {
object?: {
entries: TreeEntry[];
};
type: 'blob' | 'tree';
name: string;
sha: string;
blob?: {
size: number;
};
}
interface TreeFile {
path: string;
id: string;
size: number;
type: string;
name: string;
}
type GraphQLPullRequest = {
id: string;
baseRefName: string;
baseRefOid: string;
body: string;
headRefName: string;
headRefOid: string;
number: number;
state: string;
title: string;
mergedAt: string | null;
updatedAt: string | null;
labels: { nodes: { name: string }[] };
repository: {
id: string;
isFork: boolean;
};
user: GraphQLPullsListResponseItemUser;
};
type GraphQLPullsListResponseItemUser = {
avatar_url: string;
login: string;
url: string;
name: string;
};
function transformPullRequest(pr: GraphQLPullRequest) {
return {
...pr,
labels: pr.labels.nodes,
head: { ref: pr.headRefName, sha: pr.headRefOid, repo: { fork: pr.repository.isFork } },
base: { ref: pr.baseRefName, sha: pr.baseRefOid },
};
}
type Error = GraphQLError & { type: string };
export default class GraphQLAPI extends API {
client: ApolloClient<NormalizedCacheObject>;
constructor(config: Config) {
super(config);
this.client = this.getApolloClient();
}
getApolloClient() {
const authLink = setContext((_, { headers }) => {
return {
headers: {
'Content-Type': 'application/json; charset=utf-8',
...headers,
authorization: this.token ? `token ${this.token}` : '',
},
};
});
const httpLink = createHttpLink({ uri: `${this.apiRoot}/graphql` });
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({ fragmentMatcher }),
defaultOptions: {
watchQuery: {
fetchPolicy: NO_CACHE,
errorPolicy: 'ignore',
},
query: {
fetchPolicy: NO_CACHE,
errorPolicy: 'all',
},
},
});
}
reset() {
return this.client.resetStore();
}
async getRepository(owner: string, name: string) {
const { data } = await this.query({
query: queries.repository,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // repository id doesn't change
});
return data.repository;
}
query(options: QueryOptions<OperationVariables>) {
return this.client.query(options).catch(error => {
throw new APIError(error.message, 500, 'GitHub');
});
}
async mutate(options: MutationOptions<OperationVariables>) {
try {
const result = await this.client.mutate(options);
return result;
} catch (error: any) {
const errors = error.graphQLErrors;
if (Array.isArray(errors) && errors.some(e => e.message === 'Ref cannot be created.')) {
const refName = options?.variables?.createRefInput?.name || '';
const branchName = trimStart(refName, 'refs/heads/');
if (branchName) {
await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME);
}
} else if (
Array.isArray(errors) &&
errors.some(e =>
new RegExp(
`A ref named "refs/heads/${CMS_BRANCH_PREFIX}/.+?" already exists in the repository.`,
).test(e.message),
)
) {
const refName = options?.variables?.createRefInput?.name || '';
const sha = options?.variables?.createRefInput?.oid || '';
const branchName = trimStart(refName, 'refs/heads/');
if (branchName && branchName.startsWith(`${CMS_BRANCH_PREFIX}/`) && sha) {
try {
// this can happen if the branch wasn't deleted when the PR was merged
// we backup the existing branch just in case an re-run the mutation
await this.backupBranch(branchName);
await this.deleteBranch(branchName);
const result = await this.client.mutate(options);
return result;
} catch (e) {
console.error(e);
}
}
}
throw new APIError(error.message, 500, 'GitHub');
}
}
async hasWriteAccess() {
const { repoOwner: owner, repoName: name } = this;
try {
const { data } = await this.query({
query: queries.repoPermission,
variables: { owner, name },
fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often
});
// https://developer.github.com/v4/enum/repositorypermission/
const { viewerPermission } = data.repository;
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission);
} catch (error) {
console.error('Problem fetching repo data from GitHub');
throw error;
}
}
async user() {
const { data } = await this.query({
query: queries.user,
fetchPolicy: CACHE_FIRST, // we can assume user details don't change often
});
return data.viewer;
}
async retrieveBlobObject(owner: string, name: string, expression: string, options = {}) {
const { data } = await this.query({
query: queries.blob,
variables: { owner, name, expression },
...options,
});
// https://developer.github.com/v4/object/blob/
if (data.repository.object) {
const { is_binary: isBinary, text } = data.repository.object;
return { isNull: false, isBinary, text };
} else {
return { isNull: true };
}
}
getOwnerAndNameFromRepoUrl(repoURL: string) {
let { repoOwner: owner, repoName: name } = this;
if (repoURL === this.originRepoURL) {
({ originRepoOwner: owner, originRepoName: name } = this);
}
return { owner, name };
}
async readFile(
path: string,
sha?: string | null,
{
branch = this.branch,
repoURL = this.repoURL,
parseText = true,
}: {
branch?: string;
repoURL?: string;
parseText?: boolean;
} = {},
) {
if (!sha) {
sha = await this.getFileSha(path, { repoURL, branch });
}
const fetchContent = () => this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
const content = await readFile(sha, fetchContent, localForage, parseText);
return content;
}
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
if (!parseText) {
return super.fetchBlobContent({ sha, repoURL, parseText });
}
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { isNull, isBinary, text } = await this.retrieveBlobObject(
owner,
name,
sha,
{ fetchPolicy: CACHE_FIRST }, // blob sha is derived from file content
);
if (isNull) {
throw new APIError('Not Found', 404, 'GitHub');
} else if (!isBinary) {
return text;
} else {
return super.fetchBlobContent({ sha, repoURL, parseText });
}
}
async getPullRequestAuthor(pullRequest: Octokit.PullsListResponseItem) {
const user = pullRequest.user as unknown as GraphQLPullsListResponseItemUser;
return user?.name || user?.login;
}
async getPullRequests(
head: string | undefined,
state: PullRequestState,
predicate: (pr: Octokit.PullsListResponseItem) => boolean,
) {
const { originRepoOwner: owner, originRepoName: name } = this;
let states;
if (state === PullRequestState.Open) {
states = ['OPEN'];
} else if (state === PullRequestState.Closed) {
states = ['CLOSED', 'MERGED'];
} else {
states = ['OPEN', 'CLOSED', 'MERGED'];
}
const { data } = await this.query({
query: queries.pullRequests,
variables: {
owner,
name,
...(head ? { head } : {}),
states,
},
});
const {
pullRequests,
}: {
pullRequests: {
nodes: GraphQLPullRequest[];
};
} = data.repository;
const mapped = pullRequests.nodes.map(transformPullRequest);
return (mapped as unknown as Octokit.PullsListResponseItem[]).filter(
pr => pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr),
);
}
async getOpenAuthoringBranches() {
const { repoOwner: owner, repoName: name } = this;
const { data } = await this.query({
query: queries.openAuthoringBranches,
variables: {
owner,
name,
refPrefix: `refs/heads/cms/${this.repo}/`,
},
});
return data.repository.refs.nodes.map(({ name, prefix }: { name: string; prefix: string }) => ({
ref: `${prefix}${name}`,
}));
}
async getStatuses(collectionName: string, slug: string) {
const contentKey = this.generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const sha = pullRequest.head.sha;
const { originRepoOwner: owner, originRepoName: name } = this;
const { data } = await this.query({ query: queries.statues, variables: { owner, name, sha } });
if (data.repository.object) {
const { status } = data.repository.object;
const { contexts } = status || { contexts: [] };
return contexts;
} else {
return [];
}
}
getAllFiles(entries: TreeEntry[], path: string) {
const allFiles: TreeFile[] = entries.reduce((acc, item) => {
if (item.type === 'tree') {
const entries = item.object?.entries || [];
return [...acc, ...this.getAllFiles(entries, `${path}/${item.name}`)];
} else if (item.type === 'blob') {
return [
...acc,
{
name: item.name,
type: item.type,
id: item.sha,
path: `${path}/${item.name}`,
size: item.blob ? item.blob.size : 0,
},
];
}
return acc;
}, [] as TreeFile[]);
return allFiles;
}
async listFiles(path: string, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const folder = trim(path, '/');
const { data } = await this.query({
query: queries.files(depth),
variables: { owner, name, expression: `${branch}:${folder}` },
});
if (data.repository.object) {
const allFiles = this.getAllFiles(data.repository.object.entries, folder);
return allFiles;
} else {
return [];
}
}
getBranchQualifiedName(branch: string) {
return `refs/heads/${branch}`;
}
getBranchQuery(branch: string, owner: string, name: string) {
return {
query: queries.branch,
variables: {
owner,
name,
qualifiedName: this.getBranchQualifiedName(branch),
},
};
}
async getDefaultBranch() {
const { data } = await this.query({
...this.getBranchQuery(this.branch, this.originRepoOwner, this.originRepoName),
});
return data.repository.branch;
}
async getBranch(branch: string) {
const { data } = await this.query({
...this.getBranchQuery(branch, this.repoOwner, this.repoName),
fetchPolicy: CACHE_FIRST,
});
if (!data.repository.branch) {
throw new APIError('Branch not found', 404, API_NAME);
}
return data.repository.branch;
}
async patchRef(type: string, name: string, sha: string, opts: { force?: boolean } = {}) {
if (type !== 'heads') {
return super.patchRef(type, name, sha, opts);
}
const force = opts.force || false;
const branch = await this.getBranch(name);
const { data } = await this.mutate({
mutation: mutations.updateBranch,
variables: {
input: { oid: sha, refId: branch.id, force },
},
});
return data!.updateRef.branch;
}
async deleteBranch(branchName: string) {
const branch = await this.getBranch(branchName);
const { data } = await this.mutate({
mutation: mutations.deleteBranch,
variables: {
deleteRefInput: { refId: branch.id },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: (store: any) => store.data.delete(defaultDataIdFromObject(branch)),
});
return data!.deleteRef;
}
getPullRequestQuery(number: number) {
const { originRepoOwner: owner, originRepoName: name } = this;
return {
query: queries.pullRequest,
variables: { owner, name, number },
};
}
async getPullRequest(number: number) {
const { data } = await this.query({
...this.getPullRequestQuery(number),
fetchPolicy: CACHE_FIRST,
});
// https://developer.github.com/v4/enum/pullrequeststate/
// GraphQL state: [CLOSED, MERGED, OPEN]
// REST API state: [closed, open]
const state =
data.repository.pullRequest.state === 'OPEN'
? PullRequestState.Open
: PullRequestState.Closed;
return {
...data.repository.pullRequest,
state,
};
}
getPullRequestAndBranchQuery(branch: string, number: number) {
const { repoOwner: owner, repoName: name } = this;
const { originRepoOwner, originRepoName } = this;
return {
query: queries.pullRequestAndBranch,
variables: {
owner,
name,
originRepoOwner,
originRepoName,
number,
qualifiedName: this.getBranchQualifiedName(branch),
},
};
}
async getPullRequestAndBranch(branch: string, number: number) {
const { data } = await this.query({
...this.getPullRequestAndBranchQuery(branch, number),
fetchPolicy: CACHE_FIRST,
});
const { repository, origin } = data;
return { branch: repository.branch, pullRequest: origin.pullRequest };
}
async openPR(number: number) {
const pullRequest = await this.getPullRequest(number);
const { data } = await this.mutate({
mutation: mutations.reopenPullRequest,
variables: {
reopenPullRequestInput: { pullRequestId: pullRequest.id },
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult!.reopenPullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
return data!.reopenPullRequest;
}
async closePR(number: number) {
const pullRequest = await this.getPullRequest(number);
const { data } = await this.mutate({
mutation: mutations.closePullRequest,
variables: {
closePullRequestInput: { pullRequestId: pullRequest.id },
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult!.closePullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
return data!.closePullRequest;
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
try {
const contentKey = this.generateContentKey(collectionName, slug);
const branchName = branchFromContentKey(contentKey);
const pr = await this.getBranchPullRequest(branchName);
if (pr.number !== MOCK_PULL_REQUEST) {
const { branch, pullRequest } = await this.getPullRequestAndBranch(branchName, pr.number);
const { data } = await this.mutate({
mutation: mutations.closePullRequestAndDeleteBranch,
variables: {
deleteRefInput: { refId: branch.id },
closePullRequestInput: { pullRequestId: pullRequest.id },
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: (store: any) => {
store.data.delete(defaultDataIdFromObject(branch));
store.data.delete(defaultDataIdFromObject(pullRequest));
},
});
return data!.closePullRequest;
} else {
return await this.deleteBranch(branchName);
}
} catch (e: any) {
const { graphQLErrors } = e;
if (graphQLErrors && graphQLErrors.length > 0) {
const branchNotFound = graphQLErrors.some((e: Error) => e.type === 'NOT_FOUND');
if (branchNotFound) {
return;
}
}
throw e;
}
}
async createPR(title: string, head: string) {
const [repository, headReference] = await Promise.all([
this.getRepository(this.originRepoOwner, this.originRepoName),
this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head,
]);
const { data } = await this.mutate({
mutation: mutations.createPullRequest,
variables: {
createPullRequestInput: {
baseRefName: this.branch,
body: DEFAULT_PR_BODY,
title,
headRefName: headReference,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { pullRequest } = mutationResult!.createPullRequest;
const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } };
store.writeQuery({
...this.getPullRequestQuery(pullRequest.number),
data: pullRequestData,
});
},
});
const { pullRequest } = data!.createPullRequest;
return { ...pullRequest, head: { sha: pullRequest.headRefOid } };
}
async createBranch(branchName: string, sha: string) {
const owner = this.repoOwner;
const name = this.repoName;
const repository = await this.getRepository(owner, name);
const { data } = await this.mutate({
mutation: mutations.createBranch,
variables: {
createRefInput: {
name: this.getBranchQualifiedName(branchName),
oid: sha,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { branch } = mutationResult!.createRef;
const branchData = { repository: { ...branch.repository, branch } };
store.writeQuery({
...this.getBranchQuery(branchName, owner, name),
data: branchData,
});
},
});
const { branch } = data!.createRef;
return { ...branch, ref: `${branch.prefix}${branch.name}` };
}
async createBranchAndPullRequest(branchName: string, sha: string, title: string) {
const owner = this.originRepoOwner;
const name = this.originRepoName;
const repository = await this.getRepository(owner, name);
const { data } = await this.mutate({
mutation: mutations.createBranchAndPullRequest,
variables: {
createRefInput: {
name: this.getBranchQualifiedName(branchName),
oid: sha,
repositoryId: repository.id,
},
createPullRequestInput: {
baseRefName: this.branch,
body: DEFAULT_PR_BODY,
title,
headRefName: branchName,
repositoryId: repository.id,
},
},
update: (store, { data: mutationResult }) => {
const { branch } = mutationResult!.createRef;
const { pullRequest } = mutationResult!.createPullRequest;
const branchData = { repository: { ...branch.repository, branch } };
const pullRequestData = {
repository: { ...pullRequest.repository, branch },
origin: { ...pullRequest.repository, pullRequest },
};
store.writeQuery({
...this.getBranchQuery(branchName, owner, name),
data: branchData,
});
store.writeQuery({
...this.getPullRequestAndBranchQuery(branchName, pullRequest.number),
data: pullRequestData,
});
},
});
const { pullRequest } = data!.createPullRequest;
return transformPullRequest(pullRequest) as unknown as Octokit.PullsCreateResponse;
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { data } = await this.query({
query: queries.fileSha,
variables: { owner, name, expression: `${branch}:${path}` },
});
if (data.repository.file) {
return data.repository.file.sha;
}
throw new APIError('Not Found', 404, API_NAME);
}
}

View File

@ -1,572 +0,0 @@
export default {
__schema: {
types: [
{
kind: 'INTERFACE',
name: 'Node',
possibleTypes: [
{ name: 'AddedToProjectEvent' },
{ name: 'App' },
{ name: 'AssignedEvent' },
{ name: 'BaseRefChangedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'Blob' },
{ name: 'Bot' },
{ name: 'BranchProtectionRule' },
{ name: 'ClosedEvent' },
{ name: 'CodeOfConduct' },
{ name: 'CommentDeletedEvent' },
{ name: 'Commit' },
{ name: 'CommitComment' },
{ name: 'CommitCommentThread' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'CrossReferencedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'DeployKey' },
{ name: 'DeployedEvent' },
{ name: 'Deployment' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'DeploymentStatus' },
{ name: 'ExternalIdentity' },
{ name: 'Gist' },
{ name: 'GistComment' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'Label' },
{ name: 'LabeledEvent' },
{ name: 'Language' },
{ name: 'License' },
{ name: 'LockedEvent' },
{ name: 'Mannequin' },
{ name: 'MarketplaceCategory' },
{ name: 'MarketplaceListing' },
{ name: 'MentionedEvent' },
{ name: 'MergedEvent' },
{ name: 'Milestone' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'Organization' },
{ name: 'OrganizationIdentityProvider' },
{ name: 'OrganizationInvitation' },
{ name: 'PinnedEvent' },
{ name: 'Project' },
{ name: 'ProjectCard' },
{ name: 'ProjectColumn' },
{ name: 'PublicKey' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommit' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
{ name: 'PullRequestReviewThread' },
{ name: 'PushAllowance' },
{ name: 'Reaction' },
{ name: 'ReadyForReviewEvent' },
{ name: 'Ref' },
{ name: 'ReferencedEvent' },
{ name: 'RegistryPackage' },
{ name: 'RegistryPackageDependency' },
{ name: 'RegistryPackageFile' },
{ name: 'RegistryPackageTag' },
{ name: 'RegistryPackageVersion' },
{ name: 'Release' },
{ name: 'ReleaseAsset' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'Repository' },
{ name: 'RepositoryInvitation' },
{ name: 'RepositoryTopic' },
{ name: 'ReviewDismissalAllowance' },
{ name: 'ReviewDismissedEvent' },
{ name: 'ReviewRequest' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'SavedReply' },
{ name: 'SecurityAdvisory' },
{ name: 'SponsorsListing' },
{ name: 'Sponsorship' },
{ name: 'Status' },
{ name: 'StatusContext' },
{ name: 'SubscribedEvent' },
{ name: 'Tag' },
{ name: 'Team' },
{ name: 'Topic' },
{ name: 'TransferredEvent' },
{ name: 'Tree' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'User' },
{ name: 'UserBlockedEvent' },
{ name: 'UserContentEdit' },
{ name: 'UserStatus' },
],
},
{
kind: 'INTERFACE',
name: 'UniformResourceLocatable',
possibleTypes: [
{ name: 'Bot' },
{ name: 'ClosedEvent' },
{ name: 'Commit' },
{ name: 'CrossReferencedEvent' },
{ name: 'Gist' },
{ name: 'Issue' },
{ name: 'Mannequin' },
{ name: 'MergedEvent' },
{ name: 'Milestone' },
{ name: 'Organization' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommit' },
{ name: 'ReadyForReviewEvent' },
{ name: 'Release' },
{ name: 'Repository' },
{ name: 'RepositoryTopic' },
{ name: 'ReviewDismissedEvent' },
{ name: 'User' },
],
},
{
kind: 'INTERFACE',
name: 'Actor',
possibleTypes: [
{ name: 'Bot' },
{ name: 'Mannequin' },
{ name: 'Organization' },
{ name: 'User' },
],
},
{
kind: 'INTERFACE',
name: 'RegistryPackageOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'ProjectOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'Closable',
possibleTypes: [
{ name: 'Issue' },
{ name: 'Milestone' },
{ name: 'Project' },
{ name: 'PullRequest' },
],
},
{
kind: 'INTERFACE',
name: 'Updatable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'Project' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'UNION',
name: 'ProjectCardItem',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Assignable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Comment',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'UpdatableComment',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Labelable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'Lockable',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'INTERFACE',
name: 'RegistryPackageSearch',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'RepositoryOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'INTERFACE',
name: 'MemberStatusable',
possibleTypes: [{ name: 'Organization' }, { name: 'Team' }],
},
{
kind: 'INTERFACE',
name: 'ProfileOwner',
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
},
{
kind: 'UNION',
name: 'PinnableItem',
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }],
},
{
kind: 'INTERFACE',
name: 'Starrable',
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }, { name: 'Topic' }],
},
{ kind: 'INTERFACE', name: 'RepositoryInfo', possibleTypes: [{ name: 'Repository' }] },
{
kind: 'INTERFACE',
name: 'GitObject',
possibleTypes: [{ name: 'Blob' }, { name: 'Commit' }, { name: 'Tag' }, { name: 'Tree' }],
},
{
kind: 'INTERFACE',
name: 'RepositoryNode',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'CommitCommentThread' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Subscribable',
possibleTypes: [
{ name: 'Commit' },
{ name: 'Issue' },
{ name: 'PullRequest' },
{ name: 'Repository' },
{ name: 'Team' },
],
},
{
kind: 'INTERFACE',
name: 'Deletable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'GistComment' },
{ name: 'IssueComment' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'Reactable',
possibleTypes: [
{ name: 'CommitComment' },
{ name: 'Issue' },
{ name: 'IssueComment' },
{ name: 'PullRequest' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewComment' },
],
},
{
kind: 'INTERFACE',
name: 'GitSignature',
possibleTypes: [
{ name: 'GpgSignature' },
{ name: 'SmimeSignature' },
{ name: 'UnknownSignature' },
],
},
{
kind: 'UNION',
name: 'RequestedReviewer',
possibleTypes: [{ name: 'User' }, { name: 'Team' }, { name: 'Mannequin' }],
},
{
kind: 'UNION',
name: 'PullRequestTimelineItem',
possibleTypes: [
{ name: 'Commit' },
{ name: 'CommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewThread' },
{ name: 'PullRequestReviewComment' },
{ name: 'IssueComment' },
{ name: 'ClosedEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'MergedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'CrossReferencedEvent' },
{ name: 'AssignedEvent' },
{ name: 'UnassignedEvent' },
{ name: 'LabeledEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'MilestonedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'LockedEvent' },
{ name: 'UnlockedEvent' },
{ name: 'DeployedEvent' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReviewDismissedEvent' },
{ name: 'UserBlockedEvent' },
],
},
{
kind: 'UNION',
name: 'Closer',
possibleTypes: [{ name: 'Commit' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'ReferencedSubject',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'Assignee',
possibleTypes: [
{ name: 'Bot' },
{ name: 'Mannequin' },
{ name: 'Organization' },
{ name: 'User' },
],
},
{
kind: 'UNION',
name: 'MilestoneItem',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'RenamedTitleSubject',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'PullRequestTimelineItems',
possibleTypes: [
{ name: 'PullRequestCommit' },
{ name: 'PullRequestCommitCommentThread' },
{ name: 'PullRequestReview' },
{ name: 'PullRequestReviewThread' },
{ name: 'PullRequestRevisionMarker' },
{ name: 'BaseRefChangedEvent' },
{ name: 'BaseRefForcePushedEvent' },
{ name: 'DeployedEvent' },
{ name: 'DeploymentEnvironmentChangedEvent' },
{ name: 'HeadRefDeletedEvent' },
{ name: 'HeadRefForcePushedEvent' },
{ name: 'HeadRefRestoredEvent' },
{ name: 'MergedEvent' },
{ name: 'ReviewDismissedEvent' },
{ name: 'ReviewRequestedEvent' },
{ name: 'ReviewRequestRemovedEvent' },
{ name: 'ReadyForReviewEvent' },
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'AddedToProjectEvent' },
{ name: 'AssignedEvent' },
{ name: 'ClosedEvent' },
{ name: 'CommentDeletedEvent' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'LabeledEvent' },
{ name: 'LockedEvent' },
{ name: 'MentionedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'PinnedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'TransferredEvent' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
],
},
{
kind: 'UNION',
name: 'IssueOrPullRequest',
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
},
{
kind: 'UNION',
name: 'IssueTimelineItem',
possibleTypes: [
{ name: 'Commit' },
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'ClosedEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'UnsubscribedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'AssignedEvent' },
{ name: 'UnassignedEvent' },
{ name: 'LabeledEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'LockedEvent' },
{ name: 'UnlockedEvent' },
{ name: 'TransferredEvent' },
],
},
{
kind: 'UNION',
name: 'IssueTimelineItems',
possibleTypes: [
{ name: 'IssueComment' },
{ name: 'CrossReferencedEvent' },
{ name: 'AddedToProjectEvent' },
{ name: 'AssignedEvent' },
{ name: 'ClosedEvent' },
{ name: 'CommentDeletedEvent' },
{ name: 'ConvertedNoteToIssueEvent' },
{ name: 'DemilestonedEvent' },
{ name: 'LabeledEvent' },
{ name: 'LockedEvent' },
{ name: 'MentionedEvent' },
{ name: 'MilestonedEvent' },
{ name: 'MovedColumnsInProjectEvent' },
{ name: 'PinnedEvent' },
{ name: 'ReferencedEvent' },
{ name: 'RemovedFromProjectEvent' },
{ name: 'RenamedTitleEvent' },
{ name: 'ReopenedEvent' },
{ name: 'SubscribedEvent' },
{ name: 'TransferredEvent' },
{ name: 'UnassignedEvent' },
{ name: 'UnlabeledEvent' },
{ name: 'UnlockedEvent' },
{ name: 'UserBlockedEvent' },
{ name: 'UnpinnedEvent' },
{ name: 'UnsubscribedEvent' },
],
},
{
kind: 'UNION',
name: 'ReviewDismissalAllowanceActor',
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
},
{
kind: 'UNION',
name: 'PushAllowanceActor',
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
},
{
kind: 'UNION',
name: 'PermissionGranter',
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'Team' }],
},
{ kind: 'INTERFACE', name: 'Sponsorable', possibleTypes: [{ name: 'User' }] },
{
kind: 'INTERFACE',
name: 'Contribution',
possibleTypes: [
{ name: 'CreatedCommitContribution' },
{ name: 'CreatedIssueContribution' },
{ name: 'CreatedPullRequestContribution' },
{ name: 'CreatedPullRequestReviewContribution' },
{ name: 'CreatedRepositoryContribution' },
{ name: 'JoinedGitHubContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'CreatedRepositoryOrRestrictedContribution',
possibleTypes: [
{ name: 'CreatedRepositoryContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'CreatedIssueOrRestrictedContribution',
possibleTypes: [{ name: 'CreatedIssueContribution' }, { name: 'RestrictedContribution' }],
},
{
kind: 'UNION',
name: 'CreatedPullRequestOrRestrictedContribution',
possibleTypes: [
{ name: 'CreatedPullRequestContribution' },
{ name: 'RestrictedContribution' },
],
},
{
kind: 'UNION',
name: 'SearchResultItem',
possibleTypes: [
{ name: 'Issue' },
{ name: 'PullRequest' },
{ name: 'Repository' },
{ name: 'User' },
{ name: 'Organization' },
{ name: 'MarketplaceListing' },
{ name: 'App' },
],
},
{
kind: 'UNION',
name: 'CollectionItemContent',
possibleTypes: [{ name: 'Repository' }, { name: 'Organization' }, { name: 'User' }],
},
],
},
};

View File

@ -1,92 +0,0 @@
import { gql } from 'graphql-tag';
export const repository = gql`
fragment RepositoryParts on Repository {
id
isFork
}
`;
export const blobWithText = gql`
fragment BlobWithTextParts on Blob {
id
text
is_binary: isBinary
}
`;
export const object = gql`
fragment ObjectParts on GitObject {
id
sha: oid
}
`;
export const branch = gql`
fragment BranchParts on Ref {
commit: target {
...ObjectParts
}
id
name
prefix
repository {
...RepositoryParts
}
}
${object}
${repository}
`;
export const pullRequest = gql`
fragment PullRequestParts on PullRequest {
id
baseRefName
baseRefOid
body
headRefName
headRefOid
number
state
title
merged_at: mergedAt
updated_at: updatedAt
user: author {
login
... on User {
name
}
}
repository {
...RepositoryParts
}
labels(last: 100) {
nodes {
name
}
}
}
${repository}
`;
export const treeEntry = gql`
fragment TreeEntryParts on TreeEntry {
path: name
sha: oid
type
mode
}
`;
export const fileEntry = gql`
fragment FileEntryParts on TreeEntry {
name
sha: oid
type
blob: object {
... on Blob {
size: byteSize
}
}
}
`;

View File

@ -1,673 +0,0 @@
import * as React from 'react';
import semaphore from 'semaphore';
import trimStart from 'lodash/trimStart';
import { stripIndent } from 'common-tags';
import {
CURSOR_COMPATIBILITY_SYMBOL,
Cursor,
asyncLock,
basename,
getBlobSHA,
entriesByFolder,
entriesByFiles,
unpublishedEntries,
getMediaDisplayURL,
getMediaAsBlob,
filterByExtension,
getPreviewStatus,
runWithLock,
blobToFileObj,
contentKeyFromBranch,
unsentRequest,
branchFromContentKey,
} from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import GraphQLAPI from './GraphQLAPI';
import type { Octokit } from '@octokit/rest';
import type {
AsyncLock,
Implementation,
AssetProxy,
PersistOptions,
DisplayURL,
User,
Credentials,
Config,
ImplementationFile,
UnpublishedEntryMediaFile,
Entry,
} from '../../lib/util';
import type { Semaphore } from 'semaphore';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
const MAX_CONCURRENT_DOWNLOADS = 10;
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
const { fetchWithTimeout: fetch } = unsentRequest;
const STATUS_PAGE = 'https://www.githubstatus.com';
const GITHUB_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GITHUB_OPERATIONAL_UNITS = ['API Requests', 'Issues, Pull Requests, Projects'];
type GitHubStatusComponent = {
id: string;
name: string;
status: string;
};
export default class GitHub implements Implementation {
lock: AsyncLock;
api: API | null;
options: {
proxied: boolean;
API: API | null;
useWorkflow?: boolean;
initialWorkflowStatus: string;
};
originRepo: string;
repo?: string;
openAuthoringEnabled: boolean;
useOpenAuthoring?: boolean;
alwaysForkEnabled: boolean;
branch: string;
apiRoot: string;
mediaFolder: string;
previewContext: string;
token: string | null;
squashMerges: boolean;
cmsLabelPrefix: string;
useGraphql: boolean;
_currentUserPromise?: Promise<GitHubUser>;
_userIsOriginMaintainerPromises?: {
[key: string]: Promise<boolean>;
};
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
initialWorkflowStatus: '',
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.openAuthoringEnabled = config.backend.open_authoring || false;
if (this.openAuthoringEnabled) {
if (!this.options.useWorkflow) {
throw new Error(
'backend.open_authoring is true but publish_mode is not set to editorial_workflow.',
);
}
this.originRepo = config.backend.repo || '';
} else {
this.repo = this.originRepo = config.backend.repo || '';
}
this.alwaysForkEnabled = config.backend.always_fork || false;
this.branch = config.backend.branch?.trim() || 'master';
this.apiRoot = config.backend.api_root || 'https://api.github.com';
this.token = '';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.useGraphql = config.backend.use_graphql || false;
this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GITHUB_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: GitHubStatusComponent) =>
GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every(
(statusComponent: GitHubStatusComponent) => statusComponent.status === 'operational',
);
})
.catch(e => {
console.warn('Failed getting GitHub status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.api
?.getUser()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitHub user', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
authComponent() {
const wrappedAuthenticationPage = (props: Record<string, unknown>) => (
<AuthenticationPage {...props} backend={this} />
);
wrappedAuthenticationPage.displayName = 'AuthenticationPage';
return wrappedAuthenticationPage;
}
restoreUser(user: User) {
return this.openAuthoringEnabled
? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() =>
this.authenticate(user),
)
: this.authenticate(user);
}
async pollUntilForkExists({ repo, token }: { repo: string; token: string }) {
const pollDelay = 250; // milliseconds
let repoExists = false;
while (!repoExists) {
repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, {
headers: { Authorization: `token ${token}` },
})
.then(() => true)
.catch(err => {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return false;
} else {
return Promise.reject(err);
}
});
// wait between polls
if (!repoExists) {
await new Promise(resolve => setTimeout(resolve, pollDelay));
}
}
return Promise.resolve();
}
async currentUser({ token }: { token: string }) {
if (!this._currentUserPromise) {
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
}
return this._currentUserPromise;
}
async userIsOriginMaintainer({
username: usernameArg,
token,
}: {
username?: string;
token: string;
}) {
const username = usernameArg || (await this.currentUser({ token })).login;
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
if (!this._userIsOriginMaintainerPromises[username]) {
this._userIsOriginMaintainerPromises[username] = fetch(
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
{
headers: {
Authorization: `token ${token}`,
},
},
)
.then(res => res.json())
.then(({ permission }) => permission === 'admin' || permission === 'write');
}
return this._userIsOriginMaintainerPromises[username];
}
async forkExists({ token }: { token: string }) {
try {
const currentUser = await this.currentUser({ token });
const repoName = this.originRepo.split('/')[1];
const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, {
method: 'GET',
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
// https://developer.github.com/v3/repos/#get
// The parent and source objects are present when the repository is a fork.
// parent is the repository this repository was forked from, source is the ultimate source for the network.
const forkExists =
repo.fork === true &&
repo.parent &&
repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase();
return forkExists;
} catch {
return false;
}
}
async authenticateWithFork({
userData,
getPermissionToFork,
}: {
userData: User;
getPermissionToFork: () => Promise<boolean> | boolean;
}) {
if (!this.openAuthoringEnabled) {
throw new Error('Cannot authenticate with fork; Open Authoring is turned off.');
}
const token = userData.token as string;
// Origin maintainers should be able to use the CMS normally. If alwaysFork
// is enabled we always fork (and avoid the origin maintainer check)
if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({ token }))) {
this.repo = this.originRepo;
this.useOpenAuthoring = false;
return Promise.resolve();
}
if (!(await this.forkExists({ token }))) {
await getPermissionToFork();
}
const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, {
method: 'POST',
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
this.useOpenAuthoring = true;
this.repo = fork.full_name;
return this.pollUntilForkExists({ repo: fork.full_name, token });
}
async authenticate(state: Credentials) {
this.token = state.token as string;
const apiCtor = this.useGraphql ? GraphQLAPI : API;
this.api = new apiCtor({
token: this.token,
branch: this.branch,
repo: this.repo,
originRepo: this.originRepo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
useOpenAuthoring: this.useOpenAuthoring,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const user = await this.api!.user();
const isCollab = await this.api!.hasWriteAccess().catch(error => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a GitHub account with access.
If your repo is under an organization, ensure the organization has granted access to Netlify
CMS.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your GitHub user account does not have access to this repo.');
}
// Authorized user
return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
}
logout() {
this.token = null;
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
return this.api.reset();
}
}
getToken() {
return Promise.resolve(this.token);
}
getCursorAndFiles = (files: ApiFile[], page: number) => {
const pageSize = 20;
const count = files.length;
const pageCount = Math.ceil(files.length / pageSize);
const actions = [] as string[];
if (page > 1) {
actions.push('prev');
actions.push('first');
}
if (page < pageCount) {
actions.push('next');
actions.push('last');
}
const cursor = Cursor.create({
actions,
meta: { page, count, pageSize, pageCount },
data: { files },
});
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
return { cursor, files: pageFiles };
};
async entriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => {
const filtered = files.filter(file => filterByExtension(file, extension));
const result = this.getCursorAndFiles(filtered, 1);
cursor = result.cursor;
return result.files;
});
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.api!.originRepoURL;
const listFiles = () =>
this.api!.listFiles(folder, {
repoURL,
depth,
}).then(files => files.filter(file => filterByExtension(file, extension)));
const readFile = (path: string, id: string | null | undefined) => {
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
};
const files = await entriesByFolder(
listFiles,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return files;
}
entriesByFiles(files: ImplementationFile[]) {
const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL;
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
}
// Fetches a single entry.
getEntry(path: string) {
const repoURL = this.api!.originRepoURL;
return this.api!.readFile(path, null, { repoURL })
.then(data => ({
file: { path, id: null },
data: data as string,
}))
.catch(() => ({ file: { path, id: null }, data: '' }));
}
getMedia(mediaFolder = this.mediaFolder) {
return this.api!.listFiles(mediaFolder).then(files =>
files.map(({ id, name, size, path }) => {
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
}),
);
}
async getMediaFile(path: string) {
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const name = basename(path);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
persistEntry(entry: Entry, options: PersistOptions) {
// persistEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
try {
await this.api!.persistFiles([], [mediaFile], options);
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
const displayURL = URL.createObjectURL(fileObj as Blob);
return {
id: sha,
name: fileObj!.name,
size: fileObj!.size,
displayURL,
path: trimStart(path, '/'),
};
} catch (error) {
console.error(error);
throw error;
}
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
async traverseCursor(cursor: Cursor, action: string) {
const meta = cursor.meta!;
const files = cursor.data!.get('files')!.toJS() as ApiFile[];
let result: { cursor: Cursor; files: ApiFile[] };
switch (action) {
case 'first': {
result = this.getCursorAndFiles(files, 1);
break;
}
case 'last': {
result = this.getCursorAndFiles(files, meta.get('pageCount'));
break;
}
case 'next': {
result = this.getCursorAndFiles(files, meta.get('page') + 1);
break;
}
case 'prev': {
result = this.getCursorAndFiles(files, meta.get('page') - 1);
break;
}
default: {
result = this.getCursorAndFiles(files, 1);
break;
}
}
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
() => '',
) as Promise<string>;
const entries = await entriesByFiles(
result.files,
readFile,
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
return {
entries,
cursor: result.cursor,
};
}
async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const blob = await getMediaAsBlob(file.path, file.id, readFile);
const name = basename(file.path);
const fileObj = blobToFileObj(name, blob);
return {
id: file.id,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name,
size: fileObj.size,
file: fileObj,
};
}
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = this.api!.generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = this.api!.generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
}

View File

@ -1,10 +0,0 @@
import GitHubBackend from './implementation';
import API from './API';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendGithub = {
GitHubBackend,
API,
AuthenticationPage,
};
export { GitHubBackend, API, AuthenticationPage };

View File

@ -1,110 +0,0 @@
import { gql } from 'graphql-tag';
import * as fragments from './fragments';
// updateRef only works for branches at the moment
export const updateBranch = gql`
mutation updateRef($input: UpdateRefInput!) {
updateRef(input: $input) {
branch: ref {
...BranchParts
}
}
}
${fragments.branch}
`;
// deleteRef only works for branches at the moment
const deleteRefMutationPart = `
deleteRef(input: $deleteRefInput) {
clientMutationId
}
`;
export const deleteBranch = gql`
mutation deleteRef($deleteRefInput: DeleteRefInput!) {
${deleteRefMutationPart}
}
`;
const closePullRequestMutationPart = `
closePullRequest(input: $closePullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
`;
export const closePullRequest = gql`
mutation closePullRequestAndDeleteBranch($closePullRequestInput: ClosePullRequestInput!) {
${closePullRequestMutationPart}
}
${fragments.pullRequest}
`;
export const closePullRequestAndDeleteBranch = gql`
mutation closePullRequestAndDeleteBranch(
$closePullRequestInput: ClosePullRequestInput!
$deleteRefInput: DeleteRefInput!
) {
${closePullRequestMutationPart}
${deleteRefMutationPart}
}
${fragments.pullRequest}
`;
const createPullRequestMutationPart = `
createPullRequest(input: $createPullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
`;
export const createPullRequest = gql`
mutation createPullRequest($createPullRequestInput: CreatePullRequestInput!) {
${createPullRequestMutationPart}
}
${fragments.pullRequest}
`;
export const createBranch = gql`
mutation createBranch($createRefInput: CreateRefInput!) {
createRef(input: $createRefInput) {
branch: ref {
...BranchParts
}
}
}
${fragments.branch}
`;
// createRef only works for branches at the moment
export const createBranchAndPullRequest = gql`
mutation createBranchAndPullRequest(
$createRefInput: CreateRefInput!
$createPullRequestInput: CreatePullRequestInput!
) {
createRef(input: $createRefInput) {
branch: ref {
...BranchParts
}
}
${createPullRequestMutationPart}
}
${fragments.branch}
${fragments.pullRequest}
`;
export const reopenPullRequest = gql`
mutation reopenPullRequest($reopenPullRequestInput: ReopenPullRequestInput!) {
reopenPullRequest(input: $reopenPullRequestInput) {
clientMutationId
pullRequest {
...PullRequestParts
}
}
}
${fragments.pullRequest}
`;

View File

@ -1,213 +0,0 @@
import { gql } from 'graphql-tag';
import { oneLine } from 'common-tags';
import * as fragments from './fragments';
export const repoPermission = gql`
query repoPermission($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
viewerPermission
}
}
${fragments.repository}
`;
export const user = gql`
query {
viewer {
id
avatar_url: avatarUrl
name
login
}
}
`;
export const blob = gql`
query blob($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
... on Blob {
...BlobWithTextParts
}
}
}
}
${fragments.repository}
${fragments.blobWithText}
`;
export const statues = gql`
query statues($owner: String!, $name: String!, $sha: GitObjectID!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(oid: $sha) {
...ObjectParts
... on Commit {
status {
id
contexts {
id
context
state
target_url: targetUrl
}
}
}
}
}
}
${fragments.repository}
${fragments.object}
`;
function buildFilesQuery(depth = 1) {
const PLACE_HOLDER = 'PLACE_HOLDER';
let query = oneLine`
...ObjectParts
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
`;
for (let i = 0; i < depth - 1; i++) {
query = query.replace(
PLACE_HOLDER,
oneLine`
object {
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
}
`,
);
}
query = query.replace(PLACE_HOLDER, '');
return query;
}
export function files(depth: number) {
return gql`
query files($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
${buildFilesQuery(depth)}
}
}
}
${fragments.repository}
${fragments.object}
${fragments.fileEntry}
`;
}
const branchQueryPart = `
branch: ref(qualifiedName: $qualifiedName) {
...BranchParts
}
`;
export const branch = gql`
query branch($owner: String!, $name: String!, $qualifiedName: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
${branchQueryPart}
}
}
${fragments.repository}
${fragments.branch}
`;
export const openAuthoringBranches = gql`
query openAuthoringBranches($owner: String!, $name: String!, $refPrefix: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
refs(refPrefix: $refPrefix, last: 100) {
nodes {
...BranchParts
}
}
}
}
${fragments.repository}
${fragments.branch}
`;
export const repository = gql`
query repository($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
}
}
${fragments.repository}
`;
const pullRequestQueryPart = `
pullRequest(number: $number) {
...PullRequestParts
}
`;
export const pullRequest = gql`
query pullRequest($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
id
${pullRequestQueryPart}
}
}
${fragments.pullRequest}
`;
export const pullRequests = gql`
query pullRequests($owner: String!, $name: String!, $head: String, $states: [PullRequestState!]) {
repository(owner: $owner, name: $name) {
id
pullRequests(last: 100, headRefName: $head, states: $states) {
nodes {
...PullRequestParts
}
}
}
}
${fragments.pullRequest}
`;
export const pullRequestAndBranch = gql`
query pullRequestAndBranch($owner: String!, $name: String!, $originRepoOwner: String!, $originRepoName: String!, $qualifiedName: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
${branchQueryPart}
}
origin: repository(owner: $originRepoOwner, name: $originRepoName) {
...RepositoryParts
${pullRequestQueryPart}
}
}
${fragments.repository}
${fragments.branch}
${fragments.pullRequest}
`;
export const fileSha = gql`
query fileSha($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
file: object(expression: $expression) {
...ObjectParts
}
}
}
${fragments.repository}
${fragments.object}
`;

View File

@ -1,48 +0,0 @@
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com';
const API_TOKEN = process.env.GITHUB_API_TOKEN;
if (!API_TOKEN) {
throw new Error('Missing environment variable GITHUB_API_TOKEN');
}
fetch(`${API_HOST}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `bearer ${API_TOKEN}` },
body: JSON.stringify({
variables: {},
query: `
{
__schema {
types {
kind
name
possibleTypes {
name
}
}
}
}
`,
}),
})
.then(result => result.json())
.then(result => {
// here we're filtering out any type information unrelated to unions or interfaces
const filteredData = result.data.__schema.types.filter(type => type.possibleTypes !== null);
result.data.__schema.types = filteredData;
fs.writeFile(
path.join(__dirname, '..', 'src', 'fragmentTypes.js'),
`module.exports = ${JSON.stringify(result.data)}`,
err => {
if (err) {
console.error('Error writing fragmentTypes file', err);
} else {
console.info('Fragment types successfully extracted!');
}
},
);
});

View File

@ -1,5 +0,0 @@
declare module 'semaphore' {
export type Semaphore = { take: (f: Function) => void; leave: () => void };
const semaphore: (count: number) => Semaphore;
export default semaphore;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { AuthenticationPage, Icon } from '../../ui';
import {
NetlifyAuthenticator,
ImplicitAuthenticator,
PkceAuthenticator,
} from '../../lib/auth';
const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const clientSideAuthenticators = {
pkce: ({ base_url, auth_endpoint, app_id, auth_token_endpoint }) =>
new PkceAuthenticator({ base_url, auth_endpoint, app_id, auth_token_endpoint }),
implicit: ({ base_url, auth_endpoint, app_id, clearHash }) =>
new ImplicitAuthenticator({ base_url, auth_endpoint, app_id, clearHash }),
};
export default class GitLabAuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
base_url: PropTypes.string,
siteId: PropTypes.string,
authEndpoint: PropTypes.string,
config: PropTypes.object.isRequired,
clearHash: PropTypes.func,
t: PropTypes.func.isRequired,
};
state = {};
componentDidMount() {
const {
auth_type: authType = '',
base_url = 'https://gitlab.com',
auth_endpoint = 'oauth/authorize',
app_id = '',
} = this.props.config.backend;
if (clientSideAuthenticators[authType]) {
this.auth = clientSideAuthenticators[authType]({
base_url,
auth_endpoint,
app_id,
auth_token_endpoint: 'oauth/token',
clearHash: this.props.clearHash,
});
// Complete implicit authentication if we were redirected back to from the provider.
this.auth.completeAuth((err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
} else {
this.auth = new NetlifyAuthenticator({
base_url: this.props.base_url,
site_id:
document.location.host.split(':')[0] === 'localhost'
? 'cms.netlify.com'
: this.props.siteId,
auth_endpoint: this.props.authEndpoint,
});
}
}
handleLogin = e => {
e.preventDefault();
this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};
render() {
const { inProgress, config, t } = this.props;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress}
loginErrorMessage={this.state.loginError}
logoUrl={config.logo_url}
siteUrl={config.site_url}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="gitlab" />{' '}
{inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
</React.Fragment>
)}
t={t}
/>
);
}
}

View File

@ -1,456 +0,0 @@
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { trim } from 'lodash';
import { stripIndent } from 'common-tags';
import {
CURSOR_COMPATIBILITY_SYMBOL,
basename,
entriesByFolder,
entriesByFiles,
getMediaDisplayURL,
getMediaAsBlob,
unpublishedEntries,
getPreviewStatus,
asyncLock,
runWithLock,
getBlobSHA,
blobToFileObj,
contentKeyFromBranch,
generateContentKey,
localForage,
allEntriesByFolder,
filterByExtension,
branchFromContentKey,
} from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import type {
Entry,
AssetProxy,
PersistOptions,
Cursor,
Implementation,
DisplayURL,
User,
Credentials,
Config,
ImplementationFile,
UnpublishedEntryMediaFile,
AsyncLock,
} from '../../lib/util';
import type { Semaphore } from 'semaphore';
const MAX_CONCURRENT_DOWNLOADS = 10;
export default class GitLab implements Implementation {
lock: AsyncLock;
api: API | null;
options: {
proxied: boolean;
API: API | null;
initialWorkflowStatus: string;
};
repo: string;
branch: string;
apiRoot: string;
token: string | null;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string;
previewContext: string;
useGraphQL: boolean;
graphQLAPIRoot: string;
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
this.options = {
proxied: false,
API: null,
initialWorkflowStatus: '',
...options,
};
if (
!this.options.proxied &&
(config.backend.repo === null || config.backend.repo === undefined)
) {
throw new Error('The GitLab backend needs a "repo" in the backend configuration.');
}
this.api = this.options.API || null;
this.repo = config.backend.repo || '';
this.branch = config.backend.branch || 'master';
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
this.token = '';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || '';
this.useGraphQL = config.backend.use_graphql || false;
this.graphQLAPIRoot = config.backend.graphql_api_root || 'https://gitlab.com/api/graphql';
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api
?.user()
.then(user => !!user)
.catch(e => {
console.warn('Failed getting GitLab user', e);
return false;
})) || false;
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user: User) {
return this.authenticate(user);
}
async authenticate(state: Credentials) {
this.token = state.token as string;
this.api = new API({
token: this.token,
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
useGraphQL: this.useGraphQL,
graphQLAPIRoot: this.graphQLAPIRoot,
});
const user = await this.api.user();
const isCollab = await this.api.hasWriteAccess().catch((error: Error) => {
error.message = stripIndent`
Repo "${this.repo}" not found.
Please ensure the repo information is spelled correctly.
If the repo is private, make sure you're logged into a GitLab account with access.
`;
throw error;
});
// Unauthorized user
if (!isCollab) {
throw new Error('Your GitLab user account does not have access to this repo.');
}
// Authorized user
return { ...user, login: user.username, token: state.token as string };
}
async logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
filterFile(
folder: string,
file: { path: string; name: string },
extension: string,
depth: number,
) {
// gitlab paths include the root folder
const fileFolder = trim(file.path.split(folder)[1] || '/', '/');
return filterByExtension(file, extension) && fileFolder.split('/').length <= depth;
}
async entriesByFolder(folder: string, extension: string, depth: number) {
let cursor: Cursor;
const listFiles = () =>
this.api!.listFiles(folder, depth > 1).then(({ files, cursor: c }) => {
cursor = c.mergeMeta({ folder, extension, depth });
return files.filter(file => this.filterFile(folder, file, extension, depth));
});
const files = await entriesByFolder(
listFiles,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return files;
}
async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth > 1);
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
return filtered;
}
async allEntriesByFolder(folder: string, extension: string, depth: number) {
const files = await allEntriesByFolder({
listAllFiles: () => this.listAllFiles(folder, extension, depth),
readFile: this.api!.readFile.bind(this.api!),
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
apiName: API_NAME,
branch: this.branch,
localForage,
folder,
extension,
depth,
getDefaultBranch: () =>
this.api!.getDefaultBranch().then(b => ({ name: b.name, sha: b.commit.id })),
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
getDifferences: (to, from) => this.api!.getDifferences(to, from),
getFileId: path => this.api!.getFileId(path, this.branch),
filterFile: file => this.filterFile(folder, file, extension, depth),
customFetch: this.useGraphQL ? files => this.api!.readFilesGraphQL(files) : undefined,
});
return files;
}
entriesByFiles(files: ImplementationFile[]) {
return entriesByFiles(
files,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api),
API_NAME,
);
}
// Fetches a single entry.
getEntry(path: string) {
return this.api!.readFile(path).then(data => ({
file: { path, id: null },
data: data as string,
}));
}
getMedia(mediaFolder = this.mediaFolder) {
return this.api!.listAllFiles(mediaFolder).then(files =>
files.map(({ id, name, path }) => {
return { id, name, path, displayURL: { id, name, path } };
}),
);
}
getMediaDisplayURL(displayURL: DisplayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
return getMediaDisplayURL(
displayURL,
this.api!.readFile.bind(this.api!),
this._mediaDisplayURLSem,
);
}
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
return {
id,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
async persistEntry(entry: Entry, options: PersistOptions) {
// persistEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
'Failed to acquire persist entry lock',
);
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const fileObj = mediaFile.fileObj as File;
const [id] = await Promise.all([
getBlobSHA(fileObj),
this.api!.persistFiles([], [mediaFile], options),
]);
const { path } = mediaFile;
const url = URL.createObjectURL(fileObj);
return {
displayURL: url,
path: trimStart(path, '/'),
name: fileObj!.name,
size: fileObj!.size,
file: fileObj,
url,
id,
};
}
deleteFiles(paths: string[], commitMessage: string) {
return this.api!.deleteFiles(paths, commitMessage);
}
traverseCursor(cursor: Cursor, action: string) {
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
const [folder, depth, extension] = [
cursor.meta?.get('folder') as string,
cursor.meta?.get('depth') as number,
cursor.meta?.get('extension') as string,
];
if (folder && depth && extension) {
entries = entries.filter(f => this.filterFile(folder, f, extension, depth));
newCursor = newCursor.mergeMeta({ folder, extension, depth });
}
const entriesWithData = await entriesByFiles(
entries,
this.api!.readFile.bind(this.api!),
this.api!.readFileMetadata.bind(this.api)!,
API_NAME,
);
return {
entries: entriesWithData,
cursor: newCursor,
};
});
}
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
return getMediaAsBlob(file.path, null, readFile).then(blob => {
const name = basename(file.path);
const fileObj = blobToFileObj(name, blob);
return {
id: file.path,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name,
size: fileObj.size,
file: fileObj,
};
});
}
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) {
const mediaFiles = await Promise.all(files.map(file => this.loadMediaFile(branch, file)));
return mediaFiles;
}
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
async deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
async publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View File

@ -1,10 +0,0 @@
import GitLabBackend from './implementation';
import API from './API';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendGitlab = {
GitLabBackend,
API,
AuthenticationPage,
};
export { GitLabBackend, API, AuthenticationPage };

View File

@ -1,73 +0,0 @@
import { gql } from 'graphql-tag';
import { oneLine } from 'common-tags';
export const files = gql`
query files($repo: ID!, $branch: String!, $path: String!, $recursive: Boolean!, $cursor: String) {
project(fullPath: $repo) {
repository {
tree(ref: $branch, path: $path, recursive: $recursive) {
blobs(after: $cursor) {
nodes {
type
id: sha
path
name
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}
}
`;
export const blobs = gql`
query blobs($repo: ID!, $branch: String!, $paths: [String!]!) {
project(fullPath: $repo) {
repository {
blobs(ref: $branch, paths: $paths) {
nodes {
id
data: rawBlob
}
}
}
}
}
`;
export function lastCommits(paths: string[]) {
const tree = paths
.map(
(path, index) => oneLine`
tree${index}: tree(ref: $branch, path: "${path}") {
lastCommit {
authorName
authoredDate
author {
id
username
name
publicEmail
}
}
}
`,
)
.join('\n');
const query = gql`
query lastCommits($repo: ID!, $branch: String!) {
project(fullPath: $repo) {
repository {
${tree}
}
}
}
`;
return query;
}

View File

@ -1,17 +0,0 @@
import { AzureBackend } from './azure';
import { BitbucketBackend } from './bitbucket';
import { GitGatewayBackend } from './git-gateway';
import { GitHubBackend } from './github';
import { GitLabBackend } from './gitlab';
import { ProxyBackend } from './proxy';
import { TestBackend } from './test';
export {
AzureBackend,
BitbucketBackend,
GitGatewayBackend,
GitHubBackend,
GitLabBackend,
ProxyBackend,
TestBackend,
};

View File

@ -1,63 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, buttons, shadows, GoBackButton } from '../../ui';
const StyledAuthenticationPage = styled.section`
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
margin-top: -300px;
`;
const LoginButton = styled.button`
${buttons.button};
${shadows.dropDeep};
${buttons.default};
${buttons.gray};
padding: 0 30px;
margin-top: -40px;
display: flex;
align-items: center;
position: relative;
${Icon} {
margin-right: 18px;
}
`;
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
config: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
handleLogin = e => {
e.preventDefault();
this.props.onLogin(this.state);
};
render() {
const { config, inProgress, t } = this.props;
return (
<StyledAuthenticationPage>
<PageLogoIcon size="300px" type="netlify-cms" />
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</LoginButton>
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
</StyledAuthenticationPage>
);
}
}

View File

@ -1,262 +0,0 @@
import {
EditorialWorkflowError,
APIError,
unsentRequest,
blobToFileObj,
} from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import type {
Entry,
AssetProxy,
PersistOptions,
User,
Config,
Implementation,
ImplementationFile,
UnpublishedEntry,
} from '../../lib/util';
async function serializeAsset(assetProxy: AssetProxy) {
const base64content = await assetProxy.toBase64!();
return { path: assetProxy.path, content: base64content, encoding: 'base64' };
}
type MediaFile = {
id: string;
content: string;
encoding: string;
name: string;
path: string;
};
function deserializeMediaFile({ id, content, encoding, path, name }: MediaFile) {
let byteArray = new Uint8Array(0);
if (encoding !== 'base64') {
console.error(`Unsupported encoding '${encoding}' for file '${path}'`);
} else {
const decodedContent = atob(content);
byteArray = new Uint8Array(decodedContent.length);
for (let i = 0; i < decodedContent.length; i++) {
byteArray[i] = decodedContent.charCodeAt(i);
}
}
const blob = new Blob([byteArray]);
const file = blobToFileObj(name, blob);
const url = URL.createObjectURL(file);
return { id, name, path, file, size: file.size, url, displayURL: url };
}
export default class ProxyBackend implements Implementation {
proxyUrl: string;
mediaFolder: string;
options: { initialWorkflowStatus?: string };
branch: string;
cmsLabelPrefix?: string;
constructor(config: Config, options = {}) {
if (!config.backend.proxy_url) {
throw new Error('The Proxy backend needs a "proxy_url" in the backend configuration.');
}
this.branch = config.backend.branch || 'master';
this.proxyUrl = config.backend.proxy_url;
this.mediaFolder = config.media_folder;
this.options = options;
this.cmsLabelPrefix = config.backend.cms_label_prefix;
}
isGitBackend() {
return false;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
authComponent() {
return AuthenticationPage;
}
restoreUser() {
return this.authenticate();
}
authenticate() {
return Promise.resolve() as unknown as Promise<User>;
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
async request(payload: { action: string; params: Record<string, unknown> }) {
const response = await unsentRequest.fetchWithTimeout(this.proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ branch: this.branch, ...payload }),
});
const json = await response.json();
if (response.ok) {
return json;
} else {
throw new APIError(json.error, response.status, 'Proxy');
}
}
entriesByFolder(folder: string, extension: string, depth: number) {
return this.request({
action: 'entriesByFolder',
params: { branch: this.branch, folder, extension, depth },
});
}
entriesByFiles(files: ImplementationFile[]) {
return this.request({
action: 'entriesByFiles',
params: { branch: this.branch, files },
});
}
getEntry(path: string) {
return this.request({
action: 'getEntry',
params: { branch: this.branch, path },
});
}
unpublishedEntries() {
return this.request({
action: 'unpublishedEntries',
params: { branch: this.branch },
});
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
try {
const entry: UnpublishedEntry = await this.request({
action: 'unpublishedEntry',
params: { branch: this.branch, id, collection, slug, cmsLabelPrefix: this.cmsLabelPrefix },
});
return entry;
} catch (e: any) {
if (e.status === 404) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
throw e;
}
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const { data } = await this.request({
action: 'unpublishedEntryDataFile',
params: { branch: this.branch, collection, slug, path, id },
});
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const file = await this.request({
action: 'unpublishedEntryMediaFile',
params: { branch: this.branch, collection, slug, path, id },
});
return deserializeMediaFile(file);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.request({
action: 'deleteUnpublishedEntry',
params: { branch: this.branch, collection, slug },
});
}
async persistEntry(entry: Entry, options: PersistOptions) {
const assets = await Promise.all(entry.assets.map(serializeAsset));
return this.request({
action: 'persistEntry',
params: {
branch: this.branch,
dataFiles: entry.dataFiles,
assets,
options: { ...options, status: options.status || this.options.initialWorkflowStatus },
cmsLabelPrefix: this.cmsLabelPrefix,
},
});
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.request({
action: 'updateUnpublishedEntryStatus',
params: {
branch: this.branch,
collection,
slug,
newStatus,
cmsLabelPrefix: this.cmsLabelPrefix,
},
});
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.request({
action: 'publishUnpublishedEntry',
params: { branch: this.branch, collection, slug },
});
}
async getMedia(mediaFolder = this.mediaFolder) {
const files: MediaFile[] = await this.request({
action: 'getMedia',
params: { branch: this.branch, mediaFolder },
});
return files.map(deserializeMediaFile);
}
async getMediaFile(path: string) {
const file = await this.request({
action: 'getMediaFile',
params: { branch: this.branch, path },
});
return deserializeMediaFile(file);
}
async persistMedia(assetProxy: AssetProxy, options: PersistOptions) {
const asset = await serializeAsset(assetProxy);
const file: MediaFile = await this.request({
action: 'persistMedia',
params: { branch: this.branch, asset, options: { commitMessage: options.commitMessage } },
});
return deserializeMediaFile(file);
}
deleteFiles(paths: string[], commitMessage: string) {
return this.request({
action: 'deleteFiles',
params: { branch: this.branch, paths, options: { commitMessage } },
});
}
getDeployPreview(collection: string, slug: string) {
return this.request({
action: 'getDeployPreview',
params: { branch: this.branch, collection, slug },
});
}
}

View File

@ -1,8 +0,0 @@
import ProxyBackend from './implementation';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendProxy = {
ProxyBackend,
AuthenticationPage,
};
export { ProxyBackend, AuthenticationPage };

View File

@ -1,73 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, buttons, shadows, GoBackButton } from '../../ui';
const StyledAuthenticationPage = styled.section`
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100vh;
`;
const PageLogoIcon = styled(Icon)`
color: #c4c6d2;
margin-top: -300px;
`;
const LoginButton = styled.button`
${buttons.button};
${shadows.dropDeep};
${buttons.default};
${buttons.gray};
padding: 0 30px;
margin-top: -40px;
display: flex;
align-items: center;
position: relative;
${Icon} {
margin-right: 18px;
}
`;
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
config: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
componentDidMount() {
/**
* Allow login screen to be skipped for demo purposes.
*/
const skipLogin = this.props.config.backend.login === false;
if (skipLogin) {
this.props.onLogin(this.state);
}
}
handleLogin = e => {
e.preventDefault();
this.props.onLogin(this.state);
};
render() {
const { config, inProgress, t } = this.props;
return (
<StyledAuthenticationPage>
<PageLogoIcon size="300px" type="netlify-cms" />
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</LoginButton>
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
</StyledAuthenticationPage>
);
}
}

View File

@ -1,430 +0,0 @@
import { attempt, isError, take, unset, isEmpty } from 'lodash';
import uuid from 'uuid/v4';
import { extname, dirname } from 'path';
import {
EditorialWorkflowError,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
basename,
} from '../../lib/util';
import AuthenticationPage from './AuthenticationPage';
import type {
Implementation,
Entry,
ImplementationEntry,
AssetProxy,
PersistOptions,
User,
Config,
ImplementationFile,
DataFile,
} from '../../lib/util';
type RepoFile = { path: string; content: string | AssetProxy };
type RepoTree = { [key: string]: RepoFile | RepoTree };
type Diff = {
id: string;
originalPath?: string;
path: string;
newFile: boolean;
status: string;
content: string | AssetProxy;
};
type UnpublishedRepoEntry = {
slug: string;
collection: string;
status: string;
diffs: Diff[];
updatedAt: string;
};
declare global {
interface Window {
repoFiles: RepoTree;
repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry };
}
}
window.repoFiles = window.repoFiles || {};
window.repoFilesUnpublished = window.repoFilesUnpublished || [];
function getFile(path: string, tree: RepoTree) {
const segments = path.split('/');
let obj: RepoTree = tree;
while (obj && segments.length) {
obj = obj[segments.shift() as string] as RepoTree;
}
return (obj as unknown as RepoFile) || {};
}
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
const segments = path.split('/');
let obj = tree;
while (segments.length > 1) {
const segment = segments.shift() as string;
obj[segment] = obj[segment] || {};
obj = obj[segment] as RepoTree;
}
(obj[segments.shift() as string] as RepoFile) = { content, path };
}
function deleteFile(path: string, tree: RepoTree) {
unset(tree, path.split('/'));
}
const pageSize = 10;
function getCursor(
folder: string,
extension: string,
entries: ImplementationEntry[],
index: number,
depth: number,
) {
const count = entries.length;
const pageCount = Math.floor(count / pageSize);
return Cursor.create({
actions: [
...(index < pageCount ? ['next', 'last'] : []),
...(index > 0 ? ['prev', 'first'] : []),
],
meta: { index, count, pageSize, pageCount },
data: { folder, extension, index, pageCount, depth },
});
}
export function getFolderFiles(
tree: RepoTree,
folder: string,
extension: string,
depth: number,
files = [] as RepoFile[],
path = folder,
) {
if (depth <= 0) {
return files;
}
Object.keys(tree[folder] || {}).forEach(key => {
if (extname(key)) {
const file = (tree[folder] as RepoTree)[key] as RepoFile;
if (!extension || key.endsWith(`.${extension}`)) {
files.unshift({ content: file.content, path: `${path}/${key}` });
}
} else {
const subTree = tree[folder] as RepoTree;
return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`);
}
});
return files;
}
export default class TestBackend implements Implementation {
mediaFolder: string;
options: { initialWorkflowStatus?: string };
constructor(config: Config, options = {}) {
this.options = options;
this.mediaFolder = config.media_folder;
}
isGitBackend() {
return false;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
authComponent() {
return AuthenticationPage;
}
restoreUser() {
return this.authenticate();
}
authenticate() {
return Promise.resolve() as unknown as Promise<User>;
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
traverseCursor(cursor: Cursor, action: string) {
const { folder, extension, index, pageCount, depth } = cursor.data!.toObject() as {
folder: string;
extension: string;
index: number;
pageCount: number;
depth: number;
};
const newIndex = (() => {
if (action === 'next') {
return (index as number) + 1;
}
if (action === 'prev') {
return (index as number) - 1;
}
if (action === 'first') {
return 0;
}
if (action === 'last') {
return pageCount;
}
return 0;
})();
// TODO: stop assuming cursors are for collections
const allFiles = getFolderFiles(window.repoFiles, folder, extension, depth);
const allEntries = allFiles.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
const newCursor = getCursor(folder, extension, allEntries, newIndex, depth);
return Promise.resolve({ entries, cursor: newCursor });
}
entriesByFolder(folder: string, extension: string, depth: number) {
const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
const entries = files.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const cursor = getCursor(folder, extension, entries, 0, depth);
const ret = take(entries, pageSize);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return Promise.resolve(ret);
}
entriesByFiles(files: ImplementationFile[]) {
return Promise.all(
files.map(file => ({
file,
data: getFile(file.path, window.repoFiles).content as string,
})),
);
}
getEntry(path: string) {
return Promise.resolve({
file: { path, id: null },
data: getFile(path, window.repoFiles).content as string,
});
}
unpublishedEntries() {
return Promise.resolve(Object.keys(window.repoFilesUnpublished));
}
unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) {
if (id) {
const parts = id.split('/');
collection = parts[0];
slug = parts[1];
}
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
if (!entry) {
return Promise.reject(
new EditorialWorkflowError('content is not under editorial workflow', true),
);
}
return Promise.resolve(entry);
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return file?.content as string;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return this.normalizeAsset(file?.content as AssetProxy);
}
deleteUnpublishedEntry(collection: string, slug: string) {
delete window.repoFilesUnpublished[`${collection}/${slug}`];
return Promise.resolve();
}
async addOrUpdateUnpublishedEntry(
key: string,
dataFiles: DataFile[],
assetProxies: AssetProxy[],
slug: string,
collection: string,
status: string,
) {
const diffs: Diff[] = [];
dataFiles.forEach(dataFile => {
const { path, newPath, raw } = dataFile;
const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path);
const originalPath = currentDataFile ? currentDataFile.originalPath : path;
diffs.push({
originalPath,
id: newPath || path,
path: newPath || path,
newFile: isEmpty(getFile(originalPath as string, window.repoFiles)),
status: 'added',
content: raw,
});
});
assetProxies.forEach(a => {
const asset = this.normalizeAsset(a);
diffs.push({
id: asset.id,
path: asset.path,
newFile: true,
status: 'added',
content: asset,
});
});
window.repoFilesUnpublished[key] = {
slug,
collection,
status,
diffs,
updatedAt: new Date().toISOString(),
};
}
async persistEntry(entry: Entry, options: PersistOptions) {
if (options.useWorkflow) {
const slug = entry.dataFiles[0].slug;
const key = `${options.collectionName}/${slug}`;
const currentEntry = window.repoFilesUnpublished[key];
const status =
currentEntry?.status || options.status || (this.options.initialWorkflowStatus as string);
this.addOrUpdateUnpublishedEntry(
key,
entry.dataFiles,
entry.assets,
slug,
options.collectionName as string,
status,
);
return Promise.resolve();
}
entry.dataFiles.forEach(dataFile => {
const { path, raw } = dataFile;
writeFile(path, raw, window.repoFiles);
});
entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles);
});
return Promise.resolve();
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
window.repoFilesUnpublished[`${collection}/${slug}`].status = newStatus;
return Promise.resolve();
}
publishUnpublishedEntry(collection: string, slug: string) {
const key = `${collection}/${slug}`;
const unpubEntry = window.repoFilesUnpublished[key];
delete window.repoFilesUnpublished[key];
const tree = window.repoFiles;
unpubEntry.diffs.forEach(d => {
if (d.originalPath && !d.newFile) {
const originalPath = d.originalPath;
const sourceDir = dirname(originalPath);
const destDir = dirname(d.path);
const toMove = getFolderFiles(tree, originalPath.split('/')[0], '', 100).filter(f =>
f.path.startsWith(sourceDir),
);
toMove.forEach(f => {
deleteFile(f.path, tree);
writeFile(f.path.replace(sourceDir, destDir), f.content, tree);
});
}
writeFile(d.path, d.content, tree);
});
return Promise.resolve();
}
getMedia(mediaFolder = this.mediaFolder) {
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
f.path.startsWith(mediaFolder),
);
const assets = files.map(f => this.normalizeAsset(f.content as AssetProxy));
return Promise.resolve(assets);
}
async getMediaFile(path: string) {
const asset = getFile(path, window.repoFiles).content as AssetProxy;
const url = asset.toString();
const name = basename(path);
const blob = await fetch(url).then(res => res.blob());
const fileObj = new File([blob], name);
return {
id: url,
displayURL: url,
path,
name,
size: fileObj.size,
file: fileObj,
url,
};
}
normalizeAsset(assetProxy: AssetProxy) {
const fileObj = assetProxy.fileObj as File;
const { name, size } = fileObj;
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
const url = isError(objectUrl) ? '' : objectUrl;
const normalizedAsset = {
id: uuid(),
name,
size,
path: assetProxy.path,
url,
displayURL: url,
fileObj,
};
return normalizedAsset;
}
persistMedia(assetProxy: AssetProxy) {
const normalizedAsset = this.normalizeAsset(assetProxy);
writeFile(assetProxy.path, assetProxy, window.repoFiles);
return Promise.resolve(normalizedAsset);
}
deleteFiles(paths: string[]) {
paths.forEach(path => {
deleteFile(path, window.repoFiles);
});
return Promise.resolve();
}
async getDeployPreview() {
return null;
}
}

View File

@ -1,8 +0,0 @@
import TestBackend from './implementation';
import AuthenticationPage from './AuthenticationPage';
export const NetlifyCmsBackendTest = {
TestBackend,
AuthenticationPage,
};
export { TestBackend, AuthenticationPage };

View File

@ -1,102 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider, connect } from 'react-redux';
import { Router } from 'react-router-dom';
import { I18n } from 'react-polyglot';
import { GlobalStyles } from './ui';
import { store } from './store';
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}>
<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 NETLIFY_CMS_CORE_VERSION === 'string') {
console.info(`netlify-cms-core ${NETLIFY_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.
*/
render(<Root />, getRoot());
}
export default bootstrap;

View File

@ -1,353 +0,0 @@
import styled from '@emotion/styled';
import PropTypes from 'prop-types';
import React from 'react';
import { hot } from 'react-hot-loader';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { Redirect, Route, Switch } from 'react-router-dom';
import { ScrollSync } from 'react-scroll-sync';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser, logoutUser } from '../../actions/auth';
import { createNewEntry } from '../../actions/collections';
import { openMediaLibrary } from '../../actions/mediaLibrary';
import { currentBackend } from '../../backend';
import { EDITORIAL_WORKFLOW, SIMPLE } from '../../constants/publishModes';
import { history } from '../../routing/history';
import { colors, Loader } from '../../ui';
import Collection from '../Collection/Collection';
import Editor from '../Editor/Editor';
import MediaLibrary from '../MediaLibrary/MediaLibrary';
import Page from '../page/Page';
import Snackbars from '../snackbar/Snackbars';
import { Alert } from '../UI/Alert';
import { Confirm } from '../UI/Confirm';
import Workflow from '../Workflow/Workflow';
import Header from './Header';
import NotFoundPage from './NotFoundPage';
TopBarProgress.config({
barColors: {
0: colors.active,
'1.0': colors.active,
},
shadowBlur: 0,
barThickness: 2,
});
const AppRoot = styled.div`
width: 100%;
min-width: 1200px;
height: 100vh;
position: relative;
overflow-y: auto;
`;
const AppWrapper = styled.div`
width: 100%;
min-width: 1200px;
min-height: 100vh;
`;
const AppMainContainer = styled.div`
min-width: 1200px;
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 &&
(!collection.has('files') || collection.get('files').size > 1),
)
.first();
if (first) {
return `/collections/${first.get('name')}`;
} else {
throw new Error('Could not find a non hidden collection');
}
}
/**
* Returns default collection name if only one collection
*
* @param {Collection} collection
* @returns {string}
*/
function getDefaultCollectionPath(collection) {
if (collection.has('files') && collection.get('files').size === 1) {
return `/collections/${collection.get('name')}/entries/${collection
.get('files')
.first()
.get('name')}`;
}
return null;
}
function RouteInCollectionDefault({ collections, render, ...props }) {
const defaultPath = getDefaultPath(collections);
return (
<Route
{...props}
render={routeProps => {
const collectionExists = collections.get(routeProps.match.params.name);
if (!collectionExists) {
return <Redirect to={defaultPath} />;
}
const defaultCollectionPath = getDefaultCollectionPath(collectionExists);
if (defaultCollectionPath !== null) {
return <Redirect to={defaultCollectionPath} />;
}
return render(routeProps);
}}
/>
);
}
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,
scrollSyncEnabled: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
};
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>
{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,
scrollSyncEnabled,
} = 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 (
<ScrollSync enabled={scrollSyncEnabled}>
<AppRoot id="cms-root">
<AppWrapper className="cms-wrapper">
<Snackbars />
<Header
user={user}
collections={collections}
onCreateEntryClick={createNewEntry}
onLogoutClick={logoutUser}
openMediaLibrary={openMediaLibrary}
hasWorkflow={hasWorkflow}
displayUrl={config.display_url}
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}
<RouteInCollectionDefault
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 path="/page/:id" render={props => <Page {...props} />} />
<Route component={NotFoundPage} />
</Switch>
{useMediaLibrary ? <MediaLibrary /> : null}
<Alert />
<Confirm />
</AppMainContainer>
</AppWrapper>
</AppRoot>
</ScrollSync>
);
}
}
function mapStateToProps(state) {
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
const user = auth.user;
const isFetching = globalUI.isFetching;
const publishMode = config.publish_mode;
const useMediaLibrary = !mediaLibrary.get('externalLibrary');
const showMediaButton = mediaLibrary.get('showMediaButton');
const scrollSyncEnabled = scroll.isScrolling;
return {
auth,
config,
collections,
user,
isFetching,
publishMode,
showMediaButton,
useMediaLibrary,
scrollSyncEnabled,
};
}
const mapDispatchToProps = {
openMediaLibrary,
loginUser,
logoutUser,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(translate()(App)));

View File

@ -1,236 +0,0 @@
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 { connect } from 'react-redux';
import {
Icon,
Dropdown,
DropdownItem,
StyledDropdownButton,
colors,
lengths,
shadows,
buttons,
zIndex,
} from '../../ui';
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: 1200px;
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 {
${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;
`;
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,
isTestRepo: PropTypes.bool,
t: PropTypes.func.isRequired,
checkBackendStatus: PropTypes.func.isRequired,
};
intervalId;
componentDidMount() {
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,
isTestRepo,
t,
showMediaButton,
} = this.props;
const createableCollections = collections
.filter(collection => collection.get('create'))
.toList();
return (
<AppHeader>
<AppHeaderContent>
<nav>
<AppHeaderNavList>
<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>
{createableCollections.size > 0 && (
<Dropdown
renderButton={() => (
<AppHeaderQuickNewButton> {t('app.header.quickAdd')}</AppHeaderQuickNewButton>
)}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{createableCollections.map(collection => (
<DropdownItem
key={collection.get('name')}
label={collection.get('label_singular') || collection.get('label')}
onClick={() => this.handleCreatePostClick(collection.get('name'))}
/>
))}
</Dropdown>
)}
<SettingsDropdown
displayUrl={displayUrl}
isTestRepo={isTestRepo}
imageUrl={user?.avatar_url}
onLogoutClick={onLogoutClick}
/>
</AppHeaderActions>
</AppHeaderContent>
</AppHeader>
);
}
}
const mapDispatchToProps = {
checkBackendStatus,
};
export default connect(null, mapDispatchToProps)(translate()(Header));

View File

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

View File

@ -1,256 +0,0 @@
import styled from '@emotion/styled';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { changeViewStyle, filterByField, groupByField, sortByField } from '../../actions/entries';
import { SortDirection } from '../../interface';
import { getNewEntryUrl } from '../../lib/urlHelper';
import {
selectSortableFields,
selectViewFilters,
selectViewGroups,
} from '../../reducers/collections';
import {
selectEntriesFilter,
selectEntriesGroup,
selectEntriesSort,
selectViewStyle,
} from '../../reducers/entries';
import { components, lengths } from '../../ui';
import CollectionControls from './CollectionControls';
import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import Sidebar from './Sidebar';
import type { RouteComponentProps } from 'react-router-dom';
import type {
CmsSortableFieldsDefault,
TranslatedProps,
ViewFilter,
ViewGroup,
} from '../../interface';
import type { Collection, State } from '../../types/redux';
import type { StaticallyTypedRecord } from '../../types/immutable';
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};
`;
interface CollectionRouterParams {
name: string;
searchTerm?: string;
filterTerm?: string;
}
interface CollectionViewProps extends RouteComponentProps<CollectionRouterParams> {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
}
const CollectionView = ({
collection,
collections,
collectionName,
isSearchEnabled,
isSearchResults,
isSingleSearchResult,
searchTerm,
sortableFields,
onSortClick,
sort,
viewFilters,
viewGroups,
filterTerm,
t,
onFilterClick,
onGroupClick,
filter,
group,
onChangeViewStyle,
viewStyle,
}: ReturnType<typeof mergeProps>) => {
const [readyToLoad, setReadyToLoad] = useState(false);
const newEntryUrl = useMemo(() => {
let url = collection.get('create') ? getNewEntryUrl(collectionName) : '';
if (url && filterTerm) {
url = getNewEntryUrl(collectionName);
if (filterTerm) {
url = `${newEntryUrl}?path=${filterTerm}`;
}
}
return url;
}, [collection, collectionName, filterTerm]);
const searchResultKey = useMemo(
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
[isSingleSearchResult],
);
const renderEntriesCollection = useCallback(() => {
return (
<EntriesCollection collection={collection} viewStyle={viewStyle} filterTerm={filterTerm} readyToLoad={readyToLoad} />
);
}, [collection, filterTerm, viewStyle, readyToLoad]);
const renderEntriesSearch = useCallback(() => {
return (
<EntriesSearch
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
searchTerm={searchTerm}
/>
);
}, [searchTerm, collections, collection, isSingleSearchResult]);
useEffect(() => {
if (sort?.first()?.get('key')) {
setReadyToLoad(true);
return;
}
const defaultSort = collection.getIn(['sortable_fields', 'default']) as
| StaticallyTypedRecord<CmsSortableFieldsDefault>
| undefined;
if (!defaultSort || !defaultSort.get('field')) {
setReadyToLoad(true);
return;
}
let alive = true;
const sortEntries = async () => {
await onSortClick(
defaultSort.get('field'),
defaultSort.get('direction') ?? SortDirection.Ascending,
);
if (alive) {
setReadyToLoad(true);
}
};
sortEntries();
return () => {
alive = false;
};
}, [collection]);
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 ? renderEntriesSearch() : renderEntriesCollection()}
</CollectionMain>
</CollectionContainer>
);
};
function mapStateToProps(state: State, ownProps: TranslatedProps<CollectionViewProps>) {
const { collections } = state;
const isSearchEnabled = state.config && state.config.search != false;
const { isSearchResults, match, t } = ownProps;
const { name, searchTerm = '', filterTerm = '' } = match.params;
const collection: 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: ReturnType<typeof mapStateToProps>,
dispatchProps: typeof mapDispatchToProps,
ownProps: TranslatedProps<CollectionViewProps>,
) {
return {
...stateProps,
...ownProps,
onSortClick: (key: string, direction: SortDirection) =>
dispatchProps.sortByField(stateProps.collection, key, direction),
onFilterClick: (filter: ViewFilter) =>
dispatchProps.filterByField(stateProps.collection, filter),
onGroupClick: (group: ViewGroup) => dispatchProps.groupByField(stateProps.collection, group),
onChangeViewStyle: (viewStyle: string) => dispatchProps.changeViewStyle(viewStyle),
};
}
const ConnectedCollection = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(CollectionView);
export default translate()(ConnectedCollection);

View File

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

View File

@ -1,239 +0,0 @@
import React from 'react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { colorsRaw, colors, Icon, lengths, zIndex } from '../../ui';
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 32px;
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 32px;
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 32px;
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(),
};
componentDidUpdate(prevProps) {
if (prevProps.collection !== this.props.collection) {
const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps();
this.setState({ selectedCollectionIdx });
}
}
getSelectedSelectionBasedOnProps() {
const { collection, collections } = this.props;
return collection ? collections.keySeq().indexOf(collection.get('name')) : -1;
}
toggleSuggestions(visible) {
this.setState({ suggestionsVisible: visible });
}
selectNextSuggestion() {
const { collections } = this.props;
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1),
});
}
selectPreviousSuggestion() {
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1),
});
}
resetSelectedSuggestion() {
this.setState({
selectedCollectionIdx: -1,
});
}
submitSearch = () => {
const { onSubmit, collections } = this.props;
const { selectedCollectionIdx, query } = this.state;
this.toggleSuggestions(false);
if (selectedCollectionIdx !== -1) {
onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name']));
} else {
onSubmit(query);
}
};
handleKeyDown = event => {
const { suggestionsVisible } = this.state;
if (event.key === 'Enter') {
this.submitSearch();
}
if (suggestionsVisible) {
// allow closing of suggestions with escape key
if (event.key === 'Escape') {
this.toggleSuggestions(false);
}
if (event.key === 'ArrowDown') {
this.selectNextSuggestion();
event.preventDefault();
} else if (event.key === 'ArrowUp') {
this.selectPreviousSuggestion();
event.preventDefault();
}
}
};
handleQueryChange = query => {
this.setState({ query });
this.toggleSuggestions(query !== '');
if (query === '') {
this.resetSelectedSuggestion();
}
};
handleSuggestionClick = (event, idx) => {
this.setState({ selectedCollectionIdx: idx }, this.submitSearch);
event.preventDefault();
};
render() {
const { collections, t } = this.props;
const { suggestionsVisible, selectedCollectionIdx, query } = this.state;
return (
<SearchContainer
onBlur={() => this.toggleSuggestions(false)}
onFocus={() => this.toggleSuggestions(query !== '')}
>
<InputContainer>
<Icon type="search" />
<SearchInput
onChange={e => this.handleQueryChange(e.target.value)}
onKeyDown={this.handleKeyDown}
onClick={() => this.toggleSuggestions(true)}
placeholder={t('collection.sidebar.searchAll')}
value={query}
/>
</InputContainer>
{suggestionsVisible && (
<SuggestionsContainer>
<Suggestions>
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
<SuggestionItem
isActive={selectedCollectionIdx === -1}
onClick={e => this.handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')}
</SuggestionItem>
<SuggestionDivider />
{collections.toIndexedSeq().map((collection, idx) => (
<SuggestionItem
key={idx}
isActive={idx === selectedCollectionIdx}
onClick={e => this.handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
>
{collection.get('label')}
</SuggestionItem>
))}
</Suggestions>
</SuggestionsContainer>
)}
</SearchContainer>
);
}
}
export default translate()(CollectionSearch);

View File

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

View File

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

View File

@ -1,73 +0,0 @@
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 '../../../ui';
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,
}) {
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}
/>
{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,
};
export default translate()(Entries);

View File

@ -1,166 +0,0 @@
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';
import { colors } from '../../../ui';
import { Cursor } from '../../../lib/util';
import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
} from '../../../actions/entries';
import {
selectEntries,
selectEntriesLoaded,
selectIsFetching,
selectGroups,
} from '../../../reducers/entries';
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
import Entries from './Entries';
const GroupHeading = styled.h2`
font-size: 23px;
font-weight: 600;
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>
);
});
}
class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
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,
readyToLoad: PropTypes.bool,
};
componentDidMount() {
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
if (collection && !entriesLoaded && readyToLoad) {
loadEntries(collection);
}
}
componentDidUpdate(prevProps) {
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
if (!entriesLoaded && readyToLoad && !prevProps.readyToLoad) {
loadEntries(collection);
}
}
handleCursorActions = (cursor, action) => {
const { collection, traverseCollectionCursor } = this.props;
traverseCollectionCursor(collection, action);
};
render() {
const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = 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}
/>
);
};
if (groups && groups.length > 0) {
return withGroups(groups, entries, EntriesToRender, t);
}
return <EntriesToRender entries={entries} />;
}
}
export function filterNestedEntries(path, collectionFolder, entries) {
const filtered = entries.filter(e => {
const entryPath = e.get('path').slice(collectionFolder.length + 1);
if (!entryPath.startsWith(path)) {
return false;
}
// only show immediate children
if (path) {
// non root path
const trimmed = entryPath.slice(path.length + 1);
return trimmed.split('/').length === 2;
} else {
// root path
return entryPath.split('/').length <= 2;
}
});
return filtered;
}
function mapStateToProps(state, ownProps) {
const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
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);
}
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();
return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
}
const mapDispatchToProps = {
loadEntries: actionLoadEntries,
traverseCollectionCursor: actionTraverseCollectionCursor,
};
const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
export default translate()(ConnectedEntriesCollection);

View File

@ -1,91 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { Cursor } from '../../../lib/util';
import { selectSearchedEntries } 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,
};
componentDidMount() {
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 } = this.props;
return (
<Entries
cursor={this.getCursor()}
handleCursorActions={this.handleCursorActions}
collections={collections}
entries={entries}
isFetching={isFetching}
/>
);
}
}
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);
return { isFetching, page, collections, collectionNames, entries, searchTerm };
}
const mapDispatchToProps = {
searchEntries: actionSearchEntries,
clearSearch: actionClearSearch,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);

View File

@ -1,167 +0,0 @@
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 '../../../ui';
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 22px;
&: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;
`;
const CardHeading = styled.h2`
margin: 0 0 2px;
`;
const CardBody = styled.div`
padding: 16px 22px;
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;
`;
function EntryCard({
path,
summary,
image,
imageField,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
getAsset,
}) {
if (viewStyle === VIEW_STYLE_LIST) {
return (
<ListCard>
<ListCardLink to={path}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<ListCardTitle>{summary}</ListCardTitle>
</ListCardLink>
</ListCard>
);
}
if (viewStyle === VIEW_STYLE_GRID) {
return (
<GridCard>
<GridCardLink to={path}>
<CardBody hasImage={image}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>{summary}</CardHeading>
</CardBody>
{image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
</GridCardLink>
</GridCard>
);
}
}
function mapStateToProps(state, ownProps) {
const { entry, inferedFields, collection } = ownProps;
const entryData = entry.get('data');
const summary = selectEntryCollectionTitle(collection, entry);
let image = entryData.get(inferedFields.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') === inferedFields.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)(EntryCard);
export default ConnectedEntryCard;

View File

@ -1,87 +0,0 @@
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 } from 'immutable';
import { selectFields, selectInferedField } from '../../../reducers/collections';
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;
padding-left: 0;
`;
export default 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,
};
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 = selectInferedField(collection, 'title');
const descriptionField = selectInferedField(collection, 'description');
const imageField = selectInferedField(collection, 'image');
const fields = selectFields(collection);
const inferedFields = [titleField, descriptionField, imageField];
const remainingFields =
fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
return { titleField, descriptionField, imageField, remainingFields };
};
renderCardsForSingleCollection = () => {
const { collections, entries, viewStyle } = this.props;
const inferedFields = this.inferFields(collections);
const entryCardProps = { collection: collections, inferedFields, viewStyle };
return entries.map((entry, idx) => <EntryCard {...entryCardProps} entry={entry} 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 inferedFields = this.inferFields(collection);
const entryCardProps = { collection, entry, inferedFields, collectionLabel };
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>
);
}
}

View File

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

View File

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

View File

@ -1,309 +0,0 @@
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 PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { sortBy } from 'lodash';
import { Icon, colors, components } from '../../ui';
import { stringTemplate } from '../../lib/widgets';
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 * 20 + 12}px;
border-left: 2px solid #fff;
${Icon} {
margin-right: 8px;
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);
return sortedData.map(node => {
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 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 || node.children.some(c => c.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,
};
}
componentDidUpdate(prevProps) {
const { collection, entries, filterTerm } = this.props;
if (
collection !== prevProps.collection ||
entries !== prevProps.entries ||
filterTerm !== prevProps.filterTerm
) {
const expanded = {};
walk(this.state.treeData, node => {
if (node.expanded) {
expanded[node.path] = true;
}
});
const treeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(treeData, node => {
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
this.setState({ treeData });
}
}
onToggle = ({ node, expanded }) => {
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
const treeData = updateNode(this.state.treeData, node, node => ({
...node,
expanded,
}));
this.setState({ treeData, selected: node, useFilter: false });
} else {
// don't collapse non selected nodes when clicked
this.setState({ selected: node, useFilter: false });
}
};
render() {
const { treeData } = this.state;
const { collection } = this.props;
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
}
}
function mapStateToProps(state, ownProps) {
const { collection } = ownProps;
const entries = selectEntries(state.entries, collection) || List();
return { entries };
}
export default connect(mapStateToProps, null)(NestedCollection);

View File

@ -1,237 +0,0 @@
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 '../../ui';
import { searchCollections } from '../../actions/collections';
import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
import { getAdditionalLinks, getIcon } from '../../lib/registry';
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: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: ${colors.textLead};
`;
const SidebarNavList = styled.ul`
margin: 16px 0 0;
padding-left: 0;
list-style: none;
overflow: auto;
`;
const SidebarNavLink = styled(NavLink)`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
z-index: -1;
${Icon} {
margin-right: 0;
flex-shrink: 0;
}
${props => css`
&:hover,
&:active,
&.${props.activeClassName} {
${styles.sidebarNavLinkActive};
}
`};
`;
const AdditionalLink = styled.a`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
z-index: -1;
${Icon} {
margin-right: 0;
flex-shrink: 0;
}
&:hover,
&:active {
${styles.sidebarNavLinkActive};
}
`;
const IconWrapper = styled.div`
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
`;
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,
};
renderLink = (collection, filterTerm) => {
const collectionName = collection.get('name');
const iconName = collection.get('icon');
let icon = <Icon type="write" />;
if (iconName) {
const storedIcon = getIcon(iconName);
if (storedIcon) {
icon = storedIcon;
}
}
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}
{collection.get('label')}
</SidebarNavLink>
</li>
);
};
renderAdditionalLink = ({ id, title, data, iconName }) => {
let icon = <Icon type="write" />;
if (iconName) {
const storedIcon = getIcon(iconName);
if (storedIcon) {
icon = storedIcon;
}
}
const content = (
<>
<IconWrapper>{icon}</IconWrapper>
{title}
</>
);
return (
<li key={title}>
{typeof data === 'string' ? (
<AdditionalLink href={data} target="_blank" rel="noopener">
{content}
</AdditionalLink>
) : (
<SidebarNavLink to={`/page/${id}`} activeClassName="sidebar-active">
{content}
</SidebarNavLink>
)}
</li>
);
};
renderLink = (collection, filterTerm) => {
const collectionName = collection.get('name');
const iconName = collection.get('icon');
let icon = <Icon type="write" />;
if (iconName) {
const storedIcon = getIcon(iconName);
if (storedIcon) {
icon = storedIcon;
}
}
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}
>
<IconWrapper>{icon}</IconWrapper>
{collection.get('label')}
</SidebarNavLink>
</li>
);
};
render() {
const { collections, collection, isSearchEnabled, searchTerm, t, filterTerm } = this.props;
const additionalLinks = getAdditionalLinks();
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))}
{Object.values(additionalLinks).map(this.renderAdditionalLink)}
</SidebarNavList>
</SidebarContainer>
);
}
}
export default translate()(Sidebar);

View File

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

View File

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

View File

@ -1,570 +0,0 @@
import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { logoutUser } from '../../actions/auth';
import { loadDeployPreview } from '../../actions/deploys';
import {
deleteUnpublishedEntry,
publishUnpublishedEntry,
unpublishPublishedEntry,
updateUnpublishedEntryStatus,
} from '../../actions/editorialWorkflow';
import {
changeDraftField,
changeDraftFieldValidation,
createDraftDuplicateFromEntry,
createEmptyDraft,
deleteEntry,
deleteLocalBackup,
discardDraft,
loadEntries,
loadEntry,
loadLocalBackup,
persistEntry,
persistLocalBackup,
retrieveLocalBackup,
} from '../../actions/entries';
import { loadScroll, toggleScroll } from '../../actions/scroll';
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
import { selectDeployPreview, selectEntry, selectUnpublishedEntry } from '../../reducers';
import { selectFields } from '../../reducers/collections';
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
import { Loader } from '../../ui';
import alert from '../UI/Alert';
import confirm from '../UI/Confirm';
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,
toggleScroll: PropTypes.func.isRequired,
scrollSyncEnabled: PropTypes.bool.isRequired,
loadScroll: PropTypes.func.isRequired,
};
componentDidMount() {
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);
}
}
async checkLocalBackup(prevProps) {
const { t, hasChanged, localBackup, loadLocalBackup, entryDraft, collection } = this.props;
if (!prevProps.localBackup && localBackup) {
const confirmLoadBackup = await confirm({
title: 'editor.editor.confirmLoadBackupTitle',
body: 'editor.editor.confirmLoadBackupBody',
});
if (confirmLoadBackup) {
loadLocalBackup();
} else {
this.deleteBackup();
}
}
if (hasChanged) {
this.createBackup(entryDraft.get('entry'), collection);
}
}
componentDidUpdate(prevProps) {
this.checkLocalBackup(prevProps);
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 } =
this.props;
if (entryDraft.get('hasChanged')) {
alert({
title: 'editor.editor.onUpdatingWithUnsavedChangesTitle',
body: 'editor.editor.onUpdatingWithUnsavedChangesBody',
});
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,
} = this.props;
if (currentStatus !== status.last()) {
alert({
title: 'editor.editor.onPublishingNotReadyTitle',
body: 'editor.editor.onPublishingNotReadyBody',
});
return;
} else if (entryDraft.get('hasChanged')) {
alert({
title: 'editor.editor.onPublishingWithUnsavedChangesTitle',
body: 'editor.editor.onPublishingWithUnsavedChangesBody',
});
return;
} else if (
!(await confirm({
title: 'editor.editor.onPublishingTitle',
body: 'editor.editor.onPublishingBody',
}))
) {
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 } = this.props;
if (
!(await confirm({
title: 'editor.editor.onUnpublishingTitle',
body: 'editor.editor.onUnpublishingBody',
color: 'error',
}))
) {
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 = async () => {
const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
if (entryDraft.get('hasChanged')) {
if (
!(await confirm({
title: 'editor.editor.onDeleteWithUnsavedChangesTitle',
body: 'editor.editor.onDeleteWithUnsavedChangesBody',
color: 'error',
}))
) {
return;
}
} else if (
!(await confirm({
title: 'editor.editor.onDeletePublishedEntryTitle',
body: 'editor.editor.onDeletePublishedEntryBody',
color: 'error',
}))
) {
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 } =
this.props;
if (
entryDraft.get('hasChanged') &&
!(await confirm({
title: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesTitle',
body: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesBody',
color: 'error',
}))
) {
return;
} else if (
!(await confirm({
title: 'editor.editor.onDeleteUnpublishedChangesTitle',
body: 'editor.editor.onDeleteUnpublishedChangesBody',
color: 'error',
}))
) {
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,
toggleScroll,
scrollSyncEnabled,
loadScroll,
} = 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}
toggleScroll={toggleScroll}
scrollSyncEnabled={scrollSyncEnabled}
loadScroll={loadScroll}
t={t}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { collections, entryDraft, auth, config, entries, globalUI, scroll } = 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('files') && collection.get('files').size === 1) {
editorBackLink = '/';
}
if (collection.has('nested') && slug) {
const pathParts = slug.split('/');
if (pathParts.length > 2) {
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
}
}
const scrollSyncEnabled = scroll.isScrolling;
return {
collection,
collections,
newEntry,
entryDraft,
fields,
slug,
entry,
user,
hasChanged,
displayUrl,
hasWorkflow,
useOpenAuthoring,
isModification,
collectionEntriesLoaded,
currentStatus,
deployPreview,
localBackup,
draftKey,
publishedEntry,
unPublishedEntry,
editorBackLink,
scrollSyncEnabled,
};
}
const mapDispatchToProps = {
changeDraftField,
changeDraftFieldValidation,
loadEntry,
loadEntries,
loadDeployPreview,
loadLocalBackup,
retrieveLocalBackup,
persistLocalBackup,
deleteLocalBackup,
createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
persistEntry,
deleteEntry,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
toggleScroll,
loadScroll,
};
export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor)));

View File

@ -1,431 +0,0 @@
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, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';
import {
FieldLabel,
colors,
transitions,
lengths,
borders,
} from '../../../ui';
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;
background: #ccc;
`,
hidden: `
visibility: hidden;
`,
};
const ControlContainer = styled.div`
margin-top: 16px;
`;
const ControlErrorsList = styled.ul`
list-style-type: none;
font-size: 12px;
color: ${colors.errorText};
margin-bottom: 5px;
text-align: right;
text-transform: uppercase;
position: relative;
font-weight: 600;
top: 20px;
`;
export const ControlHint = styled.p`
margin-bottom: 0;
padding: 3px 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}>
{label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`}
</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,
processControlRef: 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,
};
static defaultProps = {
parentIds: [],
};
state = {
activeLabel: false,
};
uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`);
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,
processControlRef,
controlRef,
query,
queryHits,
isFetching,
clearSearch,
clearFieldErrors,
loadEntry,
className,
isSelected,
isEditorComponent,
isNewEditorComponent,
parentIds,
t,
validateMetaField,
isDisabled,
isHidden,
isFieldDuplicate,
isFieldHidden,
locale,
} = 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};
`}
>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
{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>
)}
<LabelComponent
field={field}
isActive={isSelected || this.state.styleActive}
hasErrors={hasErrors}
uniqueFieldId={this.uniqueFieldId}
isFieldOptional={isFieldOptional}
t={t}
/>
<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}
validator={widget.validator}
entry={entry}
collection={collection}
config={config}
field={field}
uniqueFieldId={this.uniqueFieldId}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(field, newValue, newMetadata)}
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}
ref={processControlRef && partial(processControlRef, field)}
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}
locale={locale}
/>
{fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
<ReactMarkdown
remarkPlugins={[gfm]}
allowedElements={['a', 'strong', 'em', 'del']}
unwrapDisallowed={true}
components={{
// eslint-disable-next-line no-unused-vars
a: ({ node, ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit' }}
/>
),
}}
>
{fieldHint}
</ReactMarkdown>
</ControlHint>
)}
</ControlContainer>
)}
</ClassNames>
);
}
}
function mapStateToProps(state) {
const { collections, entryDraft } = state;
const entry = entryDraft.get('entry');
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
const isLoadingAsset = selectIsLoadingAsset(state.medias);
async function loadEntry(collectionName, slug) {
const targetCollection = collections.get(collectionName);
if (targetCollection) {
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
return loadedEntry;
} else {
throw new Error(`Can't find collection '${collectionName}'`);
}
}
return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
isFetching: state.search.isFetching,
queryHits: state.search.queryHits,
config: state.config,
entry,
collection,
isLoadingAsset,
loadEntry,
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
};
}
function mapDispatchToProps(dispatch) {
const creators = bindActionCreators(
{
openMediaLibrary,
clearMediaControl,
removeMediaControl,
removeInsertedMedia,
persistMedia,
addAsset,
query,
clearSearch,
clearFieldErrors,
},
dispatch,
);
return {
...creators,
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...dispatchProps,
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry),
};
}
const ConnectedEditorControl = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(translate()(EditorControl));
export default ConnectedEditorControl;

View File

@ -1,251 +0,0 @@
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 '../../../ui';
import EditorControl from './EditorControl';
import {
getI18nInfo,
getLocaleDataPath,
hasI18n,
isFieldDuplicate,
isFieldHidden,
isFieldTranslatable,
} from '../../../lib/i18n';
const ControlPaneContainer = styled.div`
max-width: 1200px;
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,
};
componentValidate = {};
controlRef(field, wrappedControl) {
if (!wrappedControl) return;
const name = field.get('name');
this.componentValidate[name] =
wrappedControl.innerWrappedControl?.validate || wrappedControl.validate;
}
handleLocaleChange = val => {
this.setState({ selectedLocale: val });
this.props.onLocaleChange(val);
};
copyFromOtherLocale =
({ targetLocale, t }) =>
async sourceLocale => {
if (
!(await confirm({
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
body: {
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
options: { 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,
});
this.props.onChange(field, copyValue, undefined, i18n);
}
});
};
validate = async () => {
this.props.fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get('name')]();
});
};
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();
}
};
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}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
entry={entry}
collection={collection}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
locale={locale}
/>
);
})}
</ControlPaneContainer>
);
}
}
ControlPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
locale: PropTypes.string,
};

View File

@ -1,339 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map, List } from 'immutable';
import { getRemarkPlugins } from '../../../lib/registry';
import ValidationErrorTypes from '../../../constants/validationErrorTypes';
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,
validator: PropTypes.func,
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,
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,
controlRef: PropTypes.func,
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,
};
shouldComponentUpdate(nextProps) {
/**
* 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;
/**
* 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);
};
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, validator, value } = this.props;
const response = validator?.({ value, field, t });
if (response !== undefined) {
if (typeof response === 'boolean') {
return { error: !response };
} 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,
} = 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,
});
}
}

View File

@ -1,416 +0,0 @@
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 { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import {
colors,
colorsRaw,
components,
transitions,
IconButton,
zIndex,
} from '../../ui';
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 I18N_VISIBLE = 'cms.i18n-visible';
const styles = {
splitPane: css`
${components.card};
border-radius: 0;
height: 100%;
`,
pane: css`
height: 100%;
`,
};
const EditorToggle = styled(IconButton)`
margin-bottom: 12px;
`;
function ReactSplitPaneGlobalStyles() {
return (
<Global
styles={css`
.Resizer.vertical {
width: 21px;
cursor: col-resize;
position: relative;
transition: background-color ${transitions.main};
&:before {
content: '';
width: 2px;
height: 100%;
position: relative;
left: 10px;
background-color: ${colors.textFieldBorder};
display: block;
}
&:hover,
&:active {
background-color: ${colorsRaw.GrayLight};
}
}
`}
/>
);
}
const StyledSplitPane = styled.div`
display: grid;
grid-template-columns: min(864px, 50%) auto;;
height: 100%;
> div:nth-child(2)::before {
content: '';
width: 2px;
height: 100%;
position: relative;
background-color: rgb(223, 223, 227);
display: block;
}
`;
const NoPreviewContainer = styled.div`
${styles.splitPane};
`;
const EditorContainer = styled.div`
width: 100%;
min-width: 1200px;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
padding-top: 66px;
`;
const Editor = styled.div`
height: 100%;
margin: 0 auto;
position: relative;
background-color: ${colorsRaw.white};
`;
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: fixed;
bottom: 3px;
right: 12px;
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 = {
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
};
constructor(props) {
super(props);
this.props.loadScroll();
}
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 { toggleScroll } = this.props;
toggleScroll();
};
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,
scrollSyncEnabled,
t,
} = this.props;
const previewEnabled = isPreviewEnabled(collection, entry);
const collectionI18nEnabled = hasI18n(collection);
const { locales, defaultLocale } = getI18nInfo(this.props.collection);
const editorProps = {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
onChange,
onValidate,
};
const leftPanelLocale = this.state.leftPanelLocale || locales?.[0];
const editor = (
<ControlPaneContainer id="control-pane" overFlow>
<EditorControlPane
{...editorProps}
ref={c => (this.controlPaneRef = c)}
locale={leftPanelLocale}
t={t}
onLocaleChange={this.handleLeftPanelLocaleChange}
/>
</ControlPaneContainer>
);
const editor2 = (
<ControlPaneContainer overFlow={!this.props.scrollSyncEnabled}>
<EditorControlPane {...editorProps} locale={locales?.[1]} t={t} />
</ControlPaneContainer>
);
const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, leftPanelLocale, defaultLocale)
: entry;
const editorWithPreview = (
<>
<ReactSplitPaneGlobalStyles />
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<PreviewPaneContainer>
<EditorPreviewPane
collection={collection}
entry={previewEntry}
fields={fields}
fieldsMetaData={fieldsMetaData}
locale={leftPanelLocale}
/>
</PreviewPaneContainer>
</StyledSplitPane>
</>
);
const editorWithEditor = (
<ScrollSync enabled={this.props.scrollSyncEnabled}>
<div>
<StyledSplitPane>
<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 && (
<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,
toggleScroll: PropTypes.func.isRequired,
scrollSyncEnabled: PropTypes.bool.isRequired,
loadScroll: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
export default EditorInterface;

View File

@ -1,44 +0,0 @@
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`
overflow-y: auto;
height: 100%;
padding: 24px;
box-sizing: border-box;
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
`;
/**
* Use a stateful component so that child components can effectively utilize
* `shouldComponentUpdate`.
*/
export default class Preview extends React.Component {
render() {
const { collection, fields, widgetFor } = this.props;
if (!collection || !fields) {
return null;
}
return (
<PreviewContainer>
{fields.filter(isVisible).map(field => (
<div key={field.get('name')}>{widgetFor(field.get('name'))}</div>
))}
</PreviewContainer>
);
}
}
Preview.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
getAsset: PropTypes.func.isRequired,
widgetFor: PropTypes.func.isRequired,
};

View File

@ -1,52 +0,0 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
/* eslint-disable func-style */
import styled from '@emotion/styled';
import { CmsWidgetPreviewProps } from 'netlify-cms-core';
import React, { ComponentType, ReactNode, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { ScrollSyncPane } from 'react-scroll-sync';
interface PreviewContentProps {
previewComponent?:
| React.ReactElement<unknown, string | React.JSXElementConstructor<any>>
| ComponentType<CmsWidgetPreviewProps>;
previewProps: CmsWidgetPreviewProps;
}
const StyledPreviewContent = styled.div`
width: calc(100% - min(864px, 50%));
top: 66px;
right: 0;
position: absolute;
height: calc(100% - 66px);
overflow-y: auto;
`;
const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => {
const element = useMemo(() => document.getElementById('cms-root'), []);
return useMemo(() => {
if (!element) {
return null;
}
let children: ReactNode;
if (!previewComponent) {
children = null;
} else if (React.isValidElement(previewComponent)) {
children = React.cloneElement(previewComponent, previewProps);
} else {
children = React.createElement(previewComponent, previewProps);
}
return ReactDOM.createPortal(
<ScrollSyncPane>
<StyledPreviewContent className="preview-content">{children}</StyledPreviewContent>
</ScrollSyncPane>,
element,
'preview-content'
);
}, [previewComponent, previewProps, element]);
};
export default PreviewContent;

View File

@ -1,272 +0,0 @@
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 { connect } from 'react-redux';
import { lengths } from '../../../ui';
import {
resolveWidget,
getPreviewTemplate,
getPreviewStyles,
getRemarkPlugins,
} from '../../../lib/registry';
import { ErrorBoundary } from '../../UI';
import { selectTemplateName, selectInferedField, selectField } from '../../../reducers/collections';
import { boundGetAsset } from '../../../actions/media';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
import EditorPreviewContent from './EditorPreviewContent';
import PreviewHOC from './PreviewHOC';
import EditorPreview from './EditorPreview';
const PreviewPaneFrame = styled.div`
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: ${lengths.borderRadius};
overflow: auto;
`;
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}
/>
);
};
inferedFields = {};
inferFields() {
const titleField = selectInferedField(this.props.collection, 'title');
const shortTitleField = selectInferedField(this.props.collection, 'shortTitle');
const authorField = selectInferedField(this.props.collection, 'author');
this.inferedFields = {};
if (titleField) this.inferedFields[titleField] = INFERABLE_FIELDS.title;
if (shortTitleField) this.inferedFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
if (authorField) this.inferedFields[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 inferedField = Object.entries(this.inferedFields)
.filter(([key]) => {
const fieldToMatch = selectField(this.props.collection, key);
return fieldToMatch === field;
})
.map(([, value]) => value)[0];
if (inferedField) {
value = inferedField.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 value = entry.getIn(['data', field.get('name')]);
const metadata = fieldsMetaData.get(field.get('name'), Map());
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),
]),
),
});
};
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 previewProps = {
...this.props,
widgetFor: this.widgetFor,
widgetsFor: this.widgetsFor,
};
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}>
<EditorPreviewContent
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
/>
</PreviewPaneFrame>
</ErrorBoundary>
);
}
}
PreviewPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
const isLoadingAsset = selectIsLoadingAsset(state.medias);
return { isLoadingAsset, config: state.config };
}
function mapDispatchToProps(dispatch) {
return {
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...dispatchProps,
...ownProps,
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
};
}
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PreviewPane);

View File

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

View File

@ -1,687 +0,0 @@
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 '../../ui';
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.teal};
`,
};
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: fixed;
top: 0;
left: 0;
width: 100%;
min-width: 1200px;
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 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() {
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 key="delete-button" 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
key="preview-ready-button"
rel="noopener noreferrer"
target="_blank"
href={url}
>
<span>{label}</span>
<Icon type="new-tab" size="xsmall" />
</PreviewLink>
) : (
<RefreshPreviewButton key="preview-pending-button" 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 key="status-tooltip">
{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
key="workflow-status-pending-publish"
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
key="workflow-new-publish-controls"
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{canCreate ? (
<>
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
/>
<DropdownItem
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
key="workflow-existing-publish-controls"
dropdownTopOverlap="40px"
dropdownWidth="150px"
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
key="simple-existing-publish-controls"
dropdownTopOverlap="40px"
dropdownWidth="150px"
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 key="simple-new-publish-controls">
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
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
? [
this.renderWorkflowStatusControls(),
this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish }),
]
: !isNewEntry &&
this.renderExistingEntryWorkflowPublishControls({ canCreate, canPublish, canDelete }),
(!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 key="back-changed">{t('editor.editorToolbar.unsavedChanges')}</BackStatusChanged>
) : (
<BackStatusUnchanged key="back-unchanged">{t('editor.editorToolbar.changesSaved')}</BackStatusUnchanged>
)}
</div>
</ToolbarSectionBackLink>
<ToolbarSectionMain>
<ToolbarSubSectionFirst>
{hasWorkflow ? this.renderWorkflowControls() : this.renderSimpleControls()}
</ToolbarSubSectionFirst>
<ToolbarSubSectionLast>
{hasWorkflow
? this.renderWorkflowDeployPreviewControls()
: this.renderSimpleDeployPreviewControls()}
</ToolbarSubSectionLast>
</ToolbarSectionMain>
<ToolbarSectionMeta>
<SettingsDropdown
displayUrl={displayUrl}
imageUrl={user?.avatar_url}
onLogoutClick={onLogoutClick}
/>
</ToolbarSectionMeta>
</ToolbarContainer>
);
}
}
export default translate()(EditorToolbar);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,404 +0,0 @@
import fuzzy from 'fuzzy';
import { map, orderBy } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
closeMediaLibrary as closeMediaLibraryAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
loadMedia as loadMediaAction,
loadMediaDisplayURL as loadMediaDisplayURLAction,
persistMedia as persistMediaAction,
} from '../../actions/mediaLibrary';
import { fileExtension } from '../../lib/util';
import { selectMediaFiles } from '../../reducers/mediaLibrary';
import alert from '../UI/Alert';
import confirm from '../UI/Confirm';
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: '',
};
componentDidMount() {
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: '' });
}
}
componentDidUpdate(prevProps) {
const isOpening = !prevProps.isVisible && this.props.isVisible;
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
this.props.loadMedia({ privateUpload: this.props.privateUpload });
}
}
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, 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) {
alert({
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
body: {
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
options: {
size: Math.floor(maxFileSize / 1000),
},
},
});
} else {
await persistMedia(file, { privateUpload, field });
this.setState({ selectedFile: this.props.files[0] });
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 = async () => {
const { selectedFile } = this.state;
const { files, deleteMedia, privateUpload, t } = this.props;
if (
!(await confirm({
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
body: 'mediaLibrary.mediaLibrary.onDeleteBody',
color: 'error',
}))
) {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, { privateUpload }).then(() => {
this.setState({ selectedFile: {} });
});
};
/**
* Downloads the selected file.
*/
handleDownload = () => {
const { selectedFile } = this.state;
const { displayURLs } = this.props;
const url = displayURLs.getIn([selectedFile.id, 'url']) || selectedFile.url;
if (!url) {
return;
}
const filename = selectedFile.name;
const element = document.createElement('a');
element.setAttribute('href', url);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
this.setState({ selectedFile: {} });
};
/**
*
*/
handleLoadMore = () => {
const { loadMedia, dynamicSearchQuery, page, privateUpload } = this.props;
loadMedia({ query: dynamicSearchQuery, page: page + 1, privateUpload });
};
/**
* Executes media library search for implementations that support dynamic
* search via request. For these implementations, the Enter key must be
* pressed to execute search. If assets are being stored directly through
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
handleSearchKeyDown = async event => {
const { dynamicSearch, loadMedia, privateUpload } = this.props;
if (event.key === 'Enter' && dynamicSearch) {
await loadMedia({ query: this.state.query, privateUpload });
this.scrollToTop();
}
};
scrollToTop = () => {
this.scrollContainerRef.scrollTop = 0;
};
/**
* Updates query state as the user types in the search field.
*/
handleSearchChange = event => {
this.setState({ query: event.target.value });
};
/**
* Filters files that do not match the query. Not used for dynamic search.
*/
queryFilter = (query, files) => {
/**
* Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the
* query.
*/
const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
const matchFiles = matches.map((match, queryIndex) => {
const file = files[match.index];
return { ...file, queryIndex };
});
return matchFiles;
};
render() {
const {
isVisible,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
privateUpload,
displayURLs,
t,
} = this.props;
return (
<MediaLibraryModal
isVisible={isVisible}
canInsert={canInsert}
files={files}
dynamicSearch={dynamicSearch}
dynamicSearchActive={dynamicSearchActive}
forImage={forImage}
isLoading={isLoading}
isPersisting={isPersisting}
isDeleting={isDeleting}
hasNextPage={hasNextPage}
isPaginating={isPaginating}
privateUpload={privateUpload}
query={this.state.query}
selectedFile={this.state.selectedFile}
handleFilter={this.filterImages}
handleQuery={this.queryFilter}
toTableData={this.toTableData}
handleClose={this.handleClose}
handleSearchChange={this.handleSearchChange}
handleSearchKeyDown={this.handleSearchKeyDown}
handlePersist={this.handlePersist}
handleDelete={this.handleDelete}
handleInsert={this.handleInsert}
handleDownload={this.handleDownload}
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
handleAssetClick={this.handleAssetClick}
handleLoadMore={this.handleLoadMore}
displayURLs={displayURLs}
loadDisplayURL={this.loadDisplayURL}
t={t}
/>
);
}
}
function mapStateToProps(state) {
const { mediaLibrary } = state;
const field = mediaLibrary.get('field');
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: selectMediaFiles(state, field),
displayURLs: mediaLibrary.get('displayURLs'),
dynamicSearch: mediaLibrary.get('dynamicSearch'),
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
forImage: mediaLibrary.get('forImage'),
isLoading: mediaLibrary.get('isLoading'),
isPersisting: mediaLibrary.get('isPersisting'),
isDeleting: mediaLibrary.get('isDeleting'),
privateUpload: mediaLibrary.get('privateUpload'),
config: mediaLibrary.get('config'),
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
field,
};
return { ...mediaLibraryProps };
}
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
loadMediaDisplayURL: loadMediaDisplayURLAction,
closeMediaLibrary: closeMediaLibraryAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(translate()(MediaLibrary));

View File

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

View File

@ -1,129 +0,0 @@
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 '../../ui';
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 src={url} />
) : (
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
)}
</CardImageWrapper>
<CardText>{text}</CardText>
</Card>
);
}
componentDidMount() {
const { displayURL, loadDisplayURL } = this.props;
if (!displayURL.get('url')) {
loadDisplayURL();
}
}
}
MediaLibraryCard.propTypes = {
isSelected: PropTypes.bool,
displayURL: ImmutablePropTypes.map.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
draftText: PropTypes.string.isRequired,
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
margin: PropTypes.string.isRequired,
isPrivate: PropTypes.bool,
type: PropTypes.string,
isViewableImage: PropTypes.bool.isRequired,
loadDisplayURL: PropTypes.func.isRequired,
isDraft: PropTypes.bool,
};
export default MediaLibraryCard;

View File

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

View File

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

View File

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

View File

@ -1,62 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, lengths, colors, zIndex } from '../../ui';
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;

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