Feat: editorial workflow bitbucket gitlab (#3014)

* refactor: typescript the backends

* feat: support multiple files upload for GitLab and BitBucket

* fix: load entry media files from media folder or UI state

* chore: cleanup log message

* chore: code cleanup

* refactor: typescript the test backend

* refactor: cleanup getEntry unsued variables

* refactor: moved shared backend code to lib util

* chore: rename files to preserve history

* fix: bind readFile method to API classes

* test(e2e): switch to chrome in cypress tests

* refactor: extract common api methods

* refactor: remove most of immutable js usage from backends

* feat(backend-gitlab): initial editorial workflow support

* feat(backend-gitlab): implement missing workflow methods

* chore: fix lint error

* feat(backend-gitlab): support files deletion

* test(e2e): add gitlab cypress tests

* feat(backend-bitbucket): implement missing editorial workflow methods

* test(e2e): add BitBucket backend e2e tests

* build: update node version to 12 on netlify builds

* fix(backend-bitbucket): extract BitBucket avatar url

* test: fix git-gateway AuthenticationPage test

* test(e2e): fix some backend tests

* test(e2e): fix tests

* test(e2e): add git-gateway editorial workflow test

* chore: code cleanup

* test(e2e): revert back to electron

* test(e2e): add non editorial workflow tests

* fix(git-gateway-gitlab): don't call unpublishedEntry in simple workflow

gitlab git-gateway doesn't support editorial workflow APIs yet. This change makes sure not to call them in simple workflow

* refactor(backend-bitbucket): switch to diffstat API instead of raw diff

* chore: fix test

* test(e2e): add more git-gateway tests

* fix: post rebase typescript fixes

* test(e2e): fix tests

* fix: fix parsing of content key and add tests

* refactor: rename test file

* test(unit): add getStatues unit tests

* chore: update cypress

* docs: update beta docs
This commit is contained in:
Erez Rokah
2020-01-15 00:15:14 +02:00
committed by Shawn Erquhart
parent 4ff5bc2ee0
commit 6f221ab3c1
251 changed files with 70910 additions and 15974 deletions

View File

@ -78,7 +78,7 @@ describe('mediaLibrary', () => {
jest.clearAllMocks();
});
it('should not persist media in editorial workflow', () => {
it('should not persist media when editing draft', () => {
const { getBlobSHA } = require('netlify-cms-lib-util');
getBlobSHA.mockReturnValue('000000000000000');
@ -88,7 +88,6 @@ describe('mediaLibrary', () => {
const store = mockStore({
config: Map({
publish_mode: 'editorial_workflow',
media_folder: 'static/media',
}),
collections: Map({
@ -132,52 +131,7 @@ describe('mediaLibrary', () => {
});
});
it('should persist media when not in editorial workflow', () => {
const { sanitizeSlug } = require('../../lib/urlHelper');
sanitizeSlug.mockReturnValue('name.png');
const store = mockStore({
config: Map({
media_folder: 'static/media',
}),
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false, collection: 'posts' }),
}),
});
const file = new File([''], 'name.png');
const assetProxy = { path: 'static/media/name.png' };
createAssetProxy.mockReturnValue(assetProxy);
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1]).toEqual({
type: 'ADD_ASSET',
payload: { path: 'static/media/name.png' },
});
expect(actions[2]).toEqual({
type: 'MEDIA_PERSIST_SUCCESS',
payload: {
file: { id: 'id' },
},
});
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
expect(backend.persistMedia).toHaveBeenCalledWith(store.getState().config, assetProxy);
});
});
it('should persist media when draft is empty', () => {
it('should persist media when not editing draft', () => {
const store = mockStore({
config: Map({
media_folder: 'static/media',

View File

@ -7,7 +7,7 @@ import { getIntegrationProvider } from '../integrations';
import { selectIntegration, selectPublishedSlugs } from '../reducers';
import { selectFields } from '../reducers/collections';
import { selectCollectionEntriesCursor } from '../reducers/cursors';
import { Cursor } from 'netlify-cms-lib-util';
import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util';
import { createEntry, EntryValue } from '../valueObjects/Entry';
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import ValidationErrorTypes from '../constants/validationErrorTypes';
@ -23,7 +23,7 @@ import {
} from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction, Dispatch } from 'redux';
import { waitForMediaLibraryToLoad } from './mediaLibrary';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil';
const { notifSend } = notifActions;
@ -108,7 +108,7 @@ export function entriesLoaded(
collection: Collection,
entries: EntryValue[],
pagination: number | null,
cursor: typeof Cursor,
cursor: Cursor,
append = true,
) {
return {
@ -261,7 +261,7 @@ export function loadLocalBackup() {
};
}
export function addDraftEntryMediaFile(file: MediaFile) {
export function addDraftEntryMediaFile(file: ImplementationMediaFile) {
return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file };
}
@ -270,7 +270,7 @@ export function removeDraftEntryMediaFile({ id }: { id: string }) {
}
export function persistLocalBackup(entry: EntryMap, collection: Collection) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
@ -309,7 +309,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
}
export function deleteLocalBackup(collection: Collection, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
return backend.deleteLocalDraftBackup(collection, slug);
@ -351,7 +351,7 @@ const appendActions = fromJS({
['append_next']: { action: 'next', append: true },
});
const addAppendActionsToCursor = (cursor: typeof Cursor) => {
const addAppendActionsToCursor = (cursor: Cursor) => {
return Cursor.create(cursor).updateStore('actions', (actions: Set<string>) => {
return actions.union(
appendActions
@ -393,11 +393,11 @@ export function loadEntries(collection: Collection, page = 0) {
})
: Cursor.create(response.cursor),
}))
.then((response: { cursor: typeof Cursor; pagination: number; entries: EntryValue[] }) =>
.then((response: { cursor: Cursor; pagination: number; entries: EntryValue[] }) =>
dispatch(
entriesLoaded(
collection,
response.cursor.meta.get('usingOldPaginationAPI')
response.cursor.meta!.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
@ -422,8 +422,8 @@ export function loadEntries(collection: Collection, page = 0) {
};
}
function traverseCursor(backend: Backend, cursor: typeof Cursor, action: string) {
if (!cursor.actions.has(action)) {
function traverseCursor(backend: Backend, cursor: Cursor, action: string) {
if (!cursor.actions!.has(action)) {
throw new Error(`The current cursor does not support the pagination action "${action}".`);
}
return backend.traverseCursor(cursor, action);
@ -445,8 +445,8 @@ export function traverseCollectionCursor(collection: Collection, action: string)
// Handle cursors representing pages in the old, integer-based
// pagination API
if (cursor.meta.get('usingOldPaginationAPI', false)) {
return dispatch(loadEntries(collection, cursor.data.get('nextPage')));
if (cursor.meta!.get('usingOldPaginationAPI', false)) {
return dispatch(loadEntries(collection, cursor.data!.get('nextPage') as number));
}
try {
@ -625,6 +625,10 @@ export function persistEntry(collection: Collection) {
dismissAfter: 4000,
}),
);
// re-load media library if entry had media files
if (assetProxies.length > 0) {
dispatch(loadMedia());
}
dispatch(entryPersisted(collection, serializedEntry, slug));
})
.catch((error: Error) => {

View File

@ -1,5 +1,5 @@
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import { Collection, State, MediaFile } from '../types/redux';
import { Collection, State } from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { isAbsolutePath } from 'netlify-cms-lib-util';
@ -49,7 +49,7 @@ export function getAsset({ collection, entryPath, path }: GetAssetArgs) {
} else {
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file: MediaFile | null = selectMediaFileByPath(state, resolvedPath);
const file = selectMediaFileByPath(state, resolvedPath);
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);

View File

@ -1,20 +1,22 @@
import { Map } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { getBlobSHA } from 'netlify-cms-lib-util';
import { getBlobSHA, ImplementationMediaFile } from 'netlify-cms-lib-util';
import { currentBackend } from '../backend';
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectIntegration } from '../reducers';
import { selectMediaFilePath, selectMediaFilePublicPath } from '../reducers/entries';
import {
selectMediaFilePath,
selectMediaFilePublicPath,
selectEditingDraft,
} from '../reducers/entries';
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
import { getIntegrationProvider } from '../integrations';
import { addAsset, removeAsset } from './media';
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
import { sanitizeSlug } from '../lib/urlHelper';
import { State, MediaFile, DisplayURLState } from '../types/redux';
import { State, MediaFile, DisplayURLState, MediaLibraryInstance } from '../types/redux';
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { MediaLibraryInstance } from '../mediaLibrary';
import { selectEditingWorkflowDraft } from '../reducers/editorialWorkflow';
import { waitUntilWithTimeout } from './waitUntil';
const { notifSend } = notifActions;
@ -49,7 +51,7 @@ export function createMediaLibrary(instance: MediaLibraryInstance) {
}
export function clearMediaControl(id: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
@ -59,7 +61,7 @@ export function clearMediaControl(id: string) {
}
export function removeMediaControl(id: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
@ -150,7 +152,7 @@ export function loadMedia(
resolve(
backend
.getMedia()
.then((files: MediaFile[]) => dispatch(mediaLoaded(files)))
.then(files => dispatch(mediaLoaded(files)))
.catch((error: { status?: number }) => {
console.error(error);
if (error.status === 404) {
@ -177,7 +179,7 @@ function createMediaFileFromAsset({
file: File;
assetProxy: AssetProxy;
draft: boolean;
}): MediaFile {
}): ImplementationMediaFile {
const mediaFile = {
id,
name: file.name,
@ -200,7 +202,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.get('slug'));
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
const editingDraft = selectEditingWorkflowDraft(state);
const editingDraft = selectEditingDraft(state.entryDraft);
/**
* Check for existing files of the same name before persisting. If no asset
@ -255,7 +257,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
dispatch(addAsset(assetProxy));
let mediaFile: MediaFile;
let mediaFile: ImplementationMediaFile;
if (integration) {
const id = await getBlobSHA(file);
// integration assets are persisted immediately, thus draft is false
@ -314,7 +316,7 @@ export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
dispatch(removeAsset(file.path));
dispatch(removeDraftEntryMediaFile({ id: file.id }));
} else {
const editingDraft = selectEditingWorkflowDraft(state);
const editingDraft = selectEditingDraft(state.entryDraft);
dispatch(mediaDeleting());
dispatch(removeAsset(file.path));
@ -395,7 +397,7 @@ interface MediaOptions {
privateUpload?: boolean;
}
export function mediaLoaded(files: MediaFile[], opts: MediaOptions = {}) {
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts },
@ -411,7 +413,7 @@ export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST };
}
export function mediaPersisted(file: MediaFile, opts: MediaOptions = {}) {
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_PERSIST_SUCCESS,

View File

@ -3,10 +3,10 @@ import { List } from 'immutable';
import { stripIndent } from 'common-tags';
import * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats';
import { selectUseWorkflow } from './reducers/config';
import { selectMediaFilePath, selectMediaFolder } from './reducers/entries';
import { selectIntegration } from './reducers/integrations';
import {
selectListMethod,
selectEntrySlug,
selectEntryPath,
selectFileEntryLabel,
@ -24,8 +24,16 @@ import {
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
EditorialWorkflowError,
Implementation as BackendImplementation,
DisplayURL,
ImplementationEntry,
ImplementationMediaFile,
Credentials,
User,
getPathDepth,
Config as ImplementationConfig,
} from 'netlify-cms-lib-util';
import { EDITORIAL_WORKFLOW, status } from './constants/publishModes';
import { status } from './constants/publishModes';
import {
SLUG_MISSING_REQUIRED_DATE,
compileStringTemplate,
@ -38,16 +46,14 @@ import {
EntryMap,
Config,
SlugConfig,
DisplayURL,
FilterRule,
Collections,
MediaFile,
EntryDraft,
CollectionFile,
State,
} from './types/redux';
import AssetProxy from './valueObjects/AssetProxy';
import { selectEditingWorkflowDraft } from './reducers/editorialWorkflow';
import { FOLDER, FILES } from './constants/collectionTypes';
export class LocalStorageAuthStore {
storageKey = 'netlify-cms-user';
@ -153,87 +159,6 @@ function createPreviewUrl(
return `${basePath}/${previewPath}`;
}
interface ImplementationInitOptions {
useWorkflow: boolean;
updateUserCredentials: (credentials: Credentials) => void;
initialWorkflowStatus: string;
}
interface ImplementationEntry {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
file: { path: string; label: string };
metaData: { collection: string };
isModification?: boolean;
slug: string;
mediaFiles: MediaFile[];
}
interface Implementation {
authComponent: () => void;
restoreUser: (user: User) => Promise<User>;
init: (config: Config, options: ImplementationInitOptions) => Implementation;
authenticate: (credentials: Credentials) => Promise<User>;
logout: () => Promise<void>;
getToken: () => Promise<string>;
unpublishedEntry?: (collection: Collection, slug: string) => Promise<ImplementationEntry>;
getEntry: (collection: Collection, slug: string, path: string) => Promise<ImplementationEntry>;
allEntriesByFolder?: (
collection: Collection,
extension: string,
) => Promise<ImplementationEntry[]>;
traverseCursor: (
cursor: typeof Cursor,
action: unknown,
) => Promise<{ entries: ImplementationEntry[]; cursor: typeof Cursor }>;
entriesByFolder: (collection: Collection, extension: string) => Promise<ImplementationEntry[]>;
entriesByFiles: (collection: Collection, extension: string) => Promise<ImplementationEntry[]>;
unpublishedEntries: () => Promise<ImplementationEntry[]>;
getMediaDisplayURL?: (displayURL: DisplayURL) => Promise<string>;
getMedia: (folder?: string) => Promise<MediaFile[]>;
getMediaFile: (path: string) => Promise<MediaFile>;
getDeployPreview: (
collection: Collection,
slug: string,
) => Promise<{ url: string; status: string }>;
persistEntry: (
obj: { path: string; slug: string; raw: string },
assetProxies: AssetProxy[],
opts: {
newEntry: boolean;
parsedData: { title: string; description: string };
commitMessage: string;
collectionName: string;
useWorkflow: boolean;
unpublished: boolean;
status?: string;
},
) => Promise<void>;
persistMedia: (file: AssetProxy, opts: { commitMessage: string }) => Promise<MediaFile>;
deleteFile: (
path: string,
commitMessage: string,
opts?: { collection: Collection; slug: string },
) => Promise<void>;
updateUnpublishedEntryStatus: (
collection: string,
slug: string,
newStatus: string,
) => Promise<void>;
publishUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
deleteUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
}
type Credentials = {};
interface User {
backendName: string;
login: string;
name: string;
useOpenAuthoring: boolean;
}
interface AuthStore {
retrieve: () => User;
store: (user: User) => void;
@ -246,18 +171,10 @@ interface BackendOptions {
config?: Config;
}
interface BackupMediaFile extends MediaFile {
file?: File;
}
export interface ImplementationMediaFile extends MediaFile {
file?: File;
}
interface BackupEntry {
raw: string;
path: string;
mediaFiles: BackupMediaFile[];
mediaFiles: ImplementationMediaFile[];
}
interface PersistArgs {
@ -270,6 +187,16 @@ interface PersistArgs {
status?: string;
}
interface ImplementationInitOptions {
useWorkflow: boolean;
updateUserCredentials: (credentials: Credentials) => void;
initialWorkflowStatus: string;
}
type Implementation = BackendImplementation & {
init: (config: ImplementationConfig, options: ImplementationInitOptions) => Implementation;
};
export class Backend {
implementation: Implementation;
backendName: string;
@ -284,8 +211,8 @@ export class Backend {
// We can't reliably run this on exit, so we do cleanup on load.
this.deleteAnonymousBackup();
this.config = config as Config;
this.implementation = implementation.init(this.config, {
useWorkflow: this.config.get('publish_mode') === EDITORIAL_WORKFLOW,
this.implementation = implementation.init(this.config.toJS(), {
useWorkflow: selectUseWorkflow(this.config),
updateUserCredentials: this.updateUserCredentials,
initialWorkflowStatus: status.first(),
});
@ -300,12 +227,12 @@ export class Backend {
if (this.user) {
return this.user;
}
const stored = this.authStore?.retrieve();
const stored = this.authStore!.retrieve();
if (stored && stored.backendName === this.backendName) {
return Promise.resolve(this.implementation.restoreUser(stored)).then(user => {
this.user = { ...user, backendName: this.backendName };
// return confirmed/rehydrated user object instead of stored
this.authStore?.store(this.user);
this.authStore!.store(this.user as User);
return this.user;
});
}
@ -313,10 +240,10 @@ export class Backend {
}
updateUserCredentials = (updatedCredentials: Credentials) => {
const storedUser = this.authStore?.retrieve();
const storedUser = this.authStore!.retrieve();
if (storedUser && storedUser.backendName === this.backendName) {
this.user = { ...storedUser, ...updatedCredentials };
this.authStore?.store(this.user as User);
this.authStore!.store(this.user as User);
return this.user;
}
};
@ -346,10 +273,10 @@ export class Backend {
getToken = () => this.implementation.getToken();
async entryExist(collection: Collection, path: string, slug: string) {
async entryExist(collection: Collection, path: string, slug: string, useWorkflow: boolean) {
const unpublishedEntry =
this.implementation.unpublishedEntry &&
(await this.implementation.unpublishedEntry(collection, slug).catch(error => {
useWorkflow &&
(await this.implementation.unpublishedEntry(collection.get('name'), slug).catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
return Promise.resolve(false);
}
@ -359,7 +286,7 @@ export class Backend {
if (unpublishedEntry) return unpublishedEntry;
const publishedEntry = await this.implementation
.getEntry(collection, slug, path)
.getEntry(path)
.then(({ data }) => data)
.catch(() => {
return Promise.resolve(false);
@ -371,9 +298,10 @@ export class Backend {
async generateUniqueSlug(
collection: Collection,
entryData: EntryMap,
slugConfig: SlugConfig,
config: Config,
usedSlugs: List<string>,
) {
const slugConfig = config.get('slug');
const slug: string = slugFormatter(collection, entryData, slugConfig);
let i = 1;
let uniqueSlug = slug;
@ -385,6 +313,7 @@ export class Backend {
collection,
selectEntryPath(collection, uniqueSlug) as string,
uniqueSlug,
selectUseWorkflow(config),
))
) {
uniqueSlug = `${slug}${sanitizeChar(' ', slugConfig)}${i++}`;
@ -411,24 +340,42 @@ export class Backend {
}
listEntries(collection: Collection) {
const listMethod = this.implementation[selectListMethod(collection)];
const extension = selectFolderEntryExtension(collection);
return listMethod
.call(this.implementation, collection, extension)
.then((loadedEntries: ImplementationEntry[]) => ({
entries: this.processEntries(loadedEntries, collection),
/*
let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.get('type');
if (collectionType === FOLDER) {
listMethod = () =>
this.implementation.entriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
);
} else if (collectionType === FILES) {
const files = collection
.get('files')!
.map(collectionFile => ({
path: collectionFile!.get('file'),
label: collectionFile!.get('label'),
}))
.toArray();
listMethod = () => this.implementation.entriesByFiles(files);
} else {
throw new Error(`Unknown collection type: ${collectionType}`);
}
return listMethod().then((loadedEntries: ImplementationEntry[]) => ({
entries: this.processEntries(loadedEntries, collection),
/*
Wrap cursors so we can tell which collection the cursor is
from. This is done to prevent traverseCursor from requiring a
`collection` argument.
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: 'collectionEntries',
collection,
}),
}));
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: 'collectionEntries',
collection,
}),
}));
}
// The same as listEntries, except that if a cursor with the "next"
@ -440,14 +387,18 @@ export class Backend {
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection);
return this.implementation
.allEntriesByFolder(collection, extension)
.allEntriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
)
.then(entries => this.processEntries(entries, collection));
}
const response = await this.listEntries(collection);
const { entries } = response;
let { cursor } = response;
while (cursor && cursor.actions.includes('next')) {
while (cursor && cursor.actions!.includes('next')) {
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next');
entries.push(...newEntries);
cursor = newCursor;
@ -513,19 +464,19 @@ export class Backend {
return { query: searchTerm, hits };
}
traverseCursor(cursor: typeof Cursor, action: string) {
traverseCursor(cursor: Cursor, action: string) {
const [data, unwrappedCursor] = cursor.unwrapData();
// TODO: stop assuming all cursors are for collections
const collection: Collection = data.get('collection');
return this.implementation
.traverseCursor(unwrappedCursor, action)
.then(async ({ entries, cursor: newCursor }) => ({
const collection = data.get('collection') as Collection;
return this.implementation!.traverseCursor!(unwrappedCursor, action).then(
async ({ entries, cursor: newCursor }) => ({
entries: this.processEntries(entries, collection),
cursor: Cursor.create(newCursor).wrapData({
cursorType: 'collectionEntries',
collection,
}),
}));
}),
);
}
async getLocalDraftBackup(collection: Collection, slug: string) {
@ -560,14 +511,14 @@ export class Backend {
return;
}
const mediaFiles = await Promise.all<BackupMediaFile>(
const mediaFiles = await Promise.all<ImplementationMediaFile>(
entry
.get('mediaFiles')
.toJS()
.map(async (file: MediaFile) => {
.map(async (file: ImplementationMediaFile) => {
// make sure to serialize the file
if (file.url?.startsWith('blob:')) {
const blob = await fetch(file.url).then(res => res.blob());
const blob = await fetch(file.url as string).then(res => res.blob());
return { ...file, file: new File([blob], file.name) };
}
return file;
@ -598,14 +549,13 @@ export class Backend {
const path = selectEntryPath(collection, slug) as string;
const label = selectFileEntryLabel(collection, slug);
const workflowDraft = selectEditingWorkflowDraft(state);
const integration = selectIntegration(state.integrations, null, 'assetStore');
const [loadedEntry, mediaFiles] = await Promise.all([
this.implementation.getEntry(collection, slug, path),
workflowDraft && !integration
this.implementation.getEntry(path),
collection.has('media_folder') && !integration
? this.implementation.getMedia(selectMediaFolder(state.config, collection, path))
: Promise.resolve([]),
: Promise.resolve(state.mediaLibrary.get('files') || []),
]);
const entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, {
@ -649,17 +599,15 @@ export class Backend {
}
unpublishedEntries(collections: Collections) {
return this.implementation
.unpublishedEntries()
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
return this.implementation.unpublishedEntries!()
.then(entries =>
entries.map(loadedEntry => {
const collectionName = loadedEntry.metaData.collection;
const collectionName = loadedEntry.metaData!.collection;
const collection = collections.find(c => c.get('name') === collectionName);
const entry = createEntry(collectionName, loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
label: selectFileEntryLabel(collection, loadedEntry.slug),
label: selectFileEntryLabel(collection, loadedEntry.slug!),
});
entry.metaData = loadedEntry.metaData;
return entry;
@ -678,8 +626,7 @@ export class Backend {
}
unpublishedEntry(collection: Collection, slug: string) {
return this.implementation
.unpublishedEntry?.(collection, slug)
return this.implementation!.unpublishedEntry!(collection.get('name') as string, slug)
.then(loadedEntry => {
const entry = createEntry(collection.get('name'), loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
@ -741,7 +688,7 @@ export class Backend {
count = 0;
while (!deployPreview && count < maxAttempts) {
count++;
deployPreview = await this.implementation.getDeployPreview(collection, slug);
deployPreview = await this.implementation.getDeployPreview(collection.get('name'), slug);
if (!deployPreview) {
await new Promise(resolve => setTimeout(() => resolve(), interval));
}
@ -795,7 +742,7 @@ export class Backend {
const slug = await this.generateUniqueSlug(
collection,
entryDraft.getIn(['entry', 'data']),
config.get('slug'),
config,
usedSlugs,
);
const path = selectEntryPath(collection, slug) as string;
@ -836,7 +783,7 @@ export class Backend {
user.useOpenAuthoring,
);
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
const useWorkflow = selectUseWorkflow(config);
const collectionName = collection.get('name');
@ -892,7 +839,7 @@ export class Backend {
},
user.useOpenAuthoring,
);
return this.implementation.deleteFile(path, commitMessage, { collection, slug });
return this.implementation.deleteFile(path, commitMessage);
}
async deleteMedia(config: Config, path: string) {
@ -917,15 +864,15 @@ export class Backend {
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
return this.implementation.updateUnpublishedEntryStatus!(collection, slug, newStatus);
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.implementation.publishUnpublishedEntry(collection, slug);
return this.implementation.publishUnpublishedEntry!(collection, slug);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.implementation.deleteUnpublishedEntry(collection, slug);
return this.implementation.deleteUnpublishedEntry!(collection, slug);
}
entryToRaw(collection: Collection, entry: EntryMap): string {
@ -939,13 +886,13 @@ export class Backend {
if (fields) {
return collection
.get('fields')
.map(f => f?.get('name'))
.map(f => f!.get('name'))
.toArray();
}
const files = collection.get('files');
const file = (files || List<CollectionFile>())
.filter(f => f?.get('name') === entry.get('slug'))
.filter(f => f!.get('name') === entry.get('slug'))
.get(0);
if (file == null) {
@ -953,7 +900,7 @@ export class Backend {
}
return file
.get('fields')
.map(f => f?.get('name'))
.map(f => f!.get('name'))
.toArray();
}
@ -976,10 +923,11 @@ export function resolveBackend(config: Config) {
const authStore = new LocalStorageAuthStore();
if (!getBackend(name)) {
const backend = getBackend(name);
if (!backend) {
throw new Error(`Backend not found: ${name}`);
} else {
return new Backend(getBackend(name), { backendName: name, authStore, config });
return new Backend(backend, { backendName: name, authStore, config });
}
}

View File

@ -130,7 +130,7 @@ class App extends React.Component {
siteId: this.props.config.getIn(['backend', 'site_domain']),
base_url: this.props.config.getIn(['backend', 'base_url'], null),
authEndpoint: this.props.config.getIn(['backend', 'auth_endpoint']),
config: this.props.config,
config: this.props.config.toJS(),
clearHash: () => history.replace('/'),
t,
})}

View File

@ -6,6 +6,7 @@ import { once } from 'lodash';
import { getMediaLibrary } from './lib/registry';
import store from './redux';
import { createMediaLibrary, insertMedia } from './actions/mediaLibrary';
import { MediaLibraryInstance } from './types/redux';
type MediaLibraryOptions = {};
@ -16,14 +17,6 @@ interface MediaLibrary {
}) => MediaLibraryInstance;
}
export interface MediaLibraryInstance {
show?: () => void;
hide?: () => void;
onClearControl?: (args: { id: string }) => void;
onRemoveControl?: (args: { id: string }) => void;
enableStandalone?: () => boolean;
}
const initializeMediaLibrary = once(async function initializeMediaLibrary(name, options) {
const lib = (getMediaLibrary(name) as unknown) as MediaLibrary;
const handleInsert = (url: string) => store.dispatch(insertMedia(url));

View File

@ -5,7 +5,6 @@ import reducer, {
selectMediaFilePath,
selectMediaFilePublicPath,
} from '../entries';
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
const initialState = OrderedMap({
posts: Map({ name: 'posts' }),
@ -73,33 +72,26 @@ describe('entries', () => {
});
describe('selectMediaFolder', () => {
it('should return global media folder when not in editorial workflow', () => {
expect(selectMediaFolder(Map({ media_folder: 'static/media' }))).toEqual('static/media');
});
it("should return global media folder when in editorial workflow and collection doesn't specify media_folder", () => {
it("should return global media folder when collection doesn't specify media_folder", () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts' }),
),
selectMediaFolder(Map({ media_folder: 'static/media' }), Map({ name: 'posts' })),
).toEqual('static/media');
});
it('should return draft media folder when in editorial workflow, collection specifies media_folder and entry path is null', () => {
it('should return draft media folder when collection specifies media_folder and entry path is null', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
),
).toEqual('posts/DRAFT_MEDIA_FILES');
});
it('should return relative media folder when in editorial workflow, collection specifies media_folder and entry path is not null', () => {
it('should return relative media folder when collection specifies media_folder and entry path is not null', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
'posts/title/index.md',
),
@ -109,7 +101,7 @@ describe('entries', () => {
it('should resolve relative media folder', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
'posts/title/index.md',
),
@ -126,19 +118,14 @@ describe('entries', () => {
it('should resolve path from global media folder when absolute path', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
null,
null,
'/media/image.png',
),
selectMediaFilePath(Map({ media_folder: 'static/media' }), null, null, '/media/image.png'),
).toBe('static/media/image.png');
});
it('should resolve path from global media folder when relative path for collection with no media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts' }),
null,
'image.png',
@ -149,7 +136,7 @@ describe('entries', () => {
it('should resolve path from collection media folder when relative path for collection with media folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
'image.png',
@ -160,7 +147,7 @@ describe('entries', () => {
it('should handle relative media_folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
'posts/title/index.md',
'image.png',
@ -176,26 +163,16 @@ describe('entries', () => {
);
});
it('should resolve path from public folder when not in editorial workflow', () => {
it('should resolve path from public folder for collection with no media folder', () => {
expect(
selectMediaFilePublicPath(Map({ public_folder: '/media' }), null, '/media/image.png'),
).toBe('/media/image.png');
});
it('should resolve path from public folder when in editorial workflow for collection with no public folder', () => {
it('should resolve path from collection media folder for collection with public folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ name: 'posts', folder: 'posts' }),
'image.png',
),
).toBe('/media/image.png');
});
it('should resolve path from collection media folder when in editorial workflow for collection with public folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '' }),
'image.png',
),
@ -205,7 +182,7 @@ describe('entries', () => {
it('should handle relative public_folder', () => {
expect(
selectMediaFilePublicPath(
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '../../static/media/' }),
'image.png',
),

View File

@ -7,7 +7,7 @@ import mediaLibrary, {
} from '../mediaLibrary';
jest.mock('uuid/v4');
jest.mock('Reducers/editorialWorkflow');
jest.mock('Reducers/entries');
jest.mock('Reducers');
describe('mediaLibrary', () => {
@ -43,10 +43,10 @@ describe('mediaLibrary', () => {
);
});
it('should select draft media files when editing a workflow draft', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
it('should select draft media files when editing a draft', () => {
const { selectEditingDraft } = require('Reducers/entries');
selectEditingWorkflowDraft.mockReturnValue(true);
selectEditingDraft.mockReturnValue(true);
const state = {
entryDraft: fromJS({ entry: { mediaFiles: [{ id: 1 }] } }),
@ -55,10 +55,10 @@ describe('mediaLibrary', () => {
expect(selectMediaFiles(state)).toEqual([{ key: 1, id: 1 }]);
});
it('should select global media files when not editing a workflow draft', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
it('should select global media files when not editing a draft', () => {
const { selectEditingDraft } = require('Reducers/entries');
selectEditingWorkflowDraft.mockReturnValue(false);
selectEditingDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1 }] }),
@ -80,9 +80,9 @@ describe('mediaLibrary', () => {
});
it('should return media file by path', () => {
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
const { selectEditingDraft } = require('Reducers/entries');
selectEditingWorkflowDraft.mockReturnValue(false);
selectEditingDraft.mockReturnValue(false);
const state = {
mediaLibrary: Map({ files: [{ id: 1, path: 'path' }] }),

View File

@ -36,11 +36,6 @@ const collections = (state = null, action: CollectionsAction) => {
}
};
enum ListMethod {
ENTRIES_BY_FOLDER = 'entriesByFolder',
ENTRIES_BY_FILES = 'entriesByFiles',
}
const selectors = {
[FOLDER]: {
entryExtension(collection: Collection) {
@ -65,9 +60,6 @@ const selectors = {
return slug;
},
listMethod() {
return ListMethod.ENTRIES_BY_FOLDER;
},
allowNewEntries(collection: Collection) {
return collection.get('create');
},
@ -102,16 +94,13 @@ const selectors = {
const files = collection.get('files');
return files && files.find(f => f?.get('file') === path).get('label');
},
listMethod() {
return ListMethod.ENTRIES_BY_FILES;
},
allowNewEntries() {
return false;
},
allowDeletion(collection: Collection) {
return collection.get('delete', false);
},
templateName(collection: Collection, slug: string) {
templateName(_collection: Collection, slug: string) {
return slug;
},
},
@ -127,8 +116,6 @@ export const selectEntryPath = (collection: Collection, slug: string) =>
selectors[collection.get('type')].entryPath(collection, slug);
export const selectEntrySlug = (collection: Collection, path: string) =>
selectors[collection.get('type')].entrySlug(collection, path);
export const selectListMethod = (collection: Collection) =>
selectors[collection.get('type')].listMethod();
export const selectAllowNewEntries = (collection: Collection) =>
selectors[collection.get('type')].allowNewEntries(collection);
export const selectAllowDeletion = (collection: Collection) =>

View File

@ -1,6 +1,7 @@
import { Map } from 'immutable';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from '../actions/config';
import { Config, ConfigAction } from '../types/redux';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
const config = (state = Map({ isFetching: true }), action: ConfigAction) => {
switch (action.type) {
@ -24,4 +25,7 @@ const config = (state = Map({ isFetching: true }), action: ConfigAction) => {
export const selectLocale = (state: Config) => state.get('locale', 'en') as string;
export const selectUseWorkflow = (state: Config) =>
state.get('publish_mode') === EDITORIAL_WORKFLOW;
export default config;

View File

@ -1,6 +1,6 @@
import { Map, List, fromJS } from 'immutable';
import { startsWith } from 'lodash';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
import {
UNPUBLISHED_ENTRY_REQUEST,
UNPUBLISHED_ENTRY_REDIRECT,
@ -16,32 +16,33 @@ import {
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
UNPUBLISHED_ENTRY_DELETE_SUCCESS,
} from 'Actions/editorialWorkflow';
import { CONFIG_SUCCESS } from 'Actions/config';
} from '../actions/editorialWorkflow';
import { CONFIG_SUCCESS } from '../actions/config';
import { EditorialWorkflowAction, EditorialWorkflow, Entities } from '../types/redux';
const unpublishedEntries = (state = Map(), action) => {
const unpublishedEntries = (state = Map(), action: EditorialWorkflowAction) => {
switch (action.type) {
case CONFIG_SUCCESS: {
const publishMode = action.payload && action.payload.get('publish_mode');
if (publishMode === EDITORIAL_WORKFLOW) {
// Editorial workflow state is explicetelly initiated after the config.
// Editorial workflow state is explicitly initiated after the config.
return Map({ entities: Map(), pages: Map() });
}
return state;
}
case UNPUBLISHED_ENTRY_REQUEST:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isFetching'],
true,
);
case UNPUBLISHED_ENTRY_REDIRECT:
return state.deleteIn(['entities', `${action.payload.collection}.${action.payload.slug}`]);
return state.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
case UNPUBLISHED_ENTRY_SUCCESS:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
fromJS(action.payload.entry),
['entities', `${action.payload!.collection}.${action.payload!.entry.slug}`],
fromJS(action.payload!.entry),
);
case UNPUBLISHED_ENTRIES_REQUEST:
@ -49,7 +50,7 @@ const unpublishedEntries = (state = Map(), action) => {
case UNPUBLISHED_ENTRIES_SUCCESS:
return state.withMutations(map => {
action.payload.entries.forEach(entry =>
action.payload!.entries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
@ -58,35 +59,38 @@ const unpublishedEntries = (state = Map(), action) => {
map.set(
'pages',
Map({
...action.payload.pages,
ids: List(action.payload.entries.map(entry => entry.slug)),
...action.payload!.pages,
ids: List(action.payload!.entries.map(entry => entry.slug)),
}),
);
});
case UNPUBLISHED_ENTRY_PERSIST_REQUEST:
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
// Update Optimistically
return state.withMutations(map => {
map.setIn(
['entities', `${action.payload.collection}.${action.payload.entry.get('slug')}`],
fromJS(action.payload.entry),
['entities', `${action.payload!.collection}.${action.payload!.entry.get('slug')}`],
fromJS(action.payload!.entry),
);
map.setIn(
[
'entities',
`${action.payload.collection}.${action.payload.entry.get('slug')}`,
`${action.payload!.collection}.${action.payload!.entry.get('slug')}`,
'isPersisting',
],
true,
);
map.updateIn(['pages', 'ids'], List(), list => list.push(action.payload.entry.get('slug')));
map.updateIn(['pages', 'ids'], List(), list =>
list.push(action.payload!.entry.get('slug')),
);
});
}
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
// Update Optimistically
return state.deleteIn([
'entities',
`${action.payload.collection}.${action.payload.slug}`,
`${action.payload!.collection}.${action.payload!.slug}`,
'isPersisting',
]);
@ -94,11 +98,16 @@ const unpublishedEntries = (state = Map(), action) => {
// Update Optimistically
return state.withMutations(map => {
map.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'metaData', 'status'],
action.payload.newStatus,
[
'entities',
`${action.payload!.collection}.${action.payload!.slug}`,
'metaData',
'status',
],
action.payload!.newStatus,
);
map.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isUpdatingStatus'],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isUpdatingStatus'],
true,
);
});
@ -106,55 +115,49 @@ const unpublishedEntries = (state = Map(), action) => {
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
case UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isUpdatingStatus'],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isUpdatingStatus'],
false,
);
case UNPUBLISHED_ENTRY_PUBLISH_REQUEST:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isPublishing'],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'isPublishing'],
true,
);
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
case UNPUBLISHED_ENTRY_PUBLISH_FAILURE:
return state.withMutations(map => {
map.deleteIn(['entities', `${action.payload.collection}.${action.payload.slug}`]);
map.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
});
case UNPUBLISHED_ENTRY_DELETE_SUCCESS:
return state.deleteIn(['entities', `${action.payload.collection}.${action.payload.slug}`]);
return state.deleteIn(['entities', `${action.payload!.collection}.${action.payload!.slug}`]);
default:
return state;
}
};
export const selectUnpublishedEntry = (state, collection, slug) =>
state && state.getIn(['entities', `${collection}.${slug}`]);
export const selectUnpublishedEntry = (
state: EditorialWorkflow,
collection: string,
slug: string,
) => state && state.getIn(['entities', `${collection}.${slug}`]);
export const selectUnpublishedEntriesByStatus = (state, status) => {
export const selectUnpublishedEntriesByStatus = (state: EditorialWorkflow, status: string) => {
if (!state) return null;
return state
.get('entities')
.filter(entry => entry.getIn(['metaData', 'status']) === status)
.valueSeq();
const entities = state.get('entities') as Entities;
return entities.filter(entry => entry.getIn(['metaData', 'status']) === status).valueSeq();
};
export const selectUnpublishedSlugs = (state, collection) => {
export const selectUnpublishedSlugs = (state: EditorialWorkflow, collection: string) => {
if (!state.get('entities')) return null;
return state
.get('entities')
.filter((v, k) => startsWith(k, `${collection}.`))
const entities = state.get('entities') as Entities;
return entities
.filter((_v, k) => startsWith(k as string, `${collection}.`))
.map(entry => entry.get('slug'))
.valueSeq();
};
export const selectEditingWorkflowDraft = state => {
const entry = state.entryDraft.get('entry');
const useWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
const workflowDraft = entry && !entry.isEmpty() && useWorkflow;
return workflowDraft;
};
export default unpublishedEntries;

View File

@ -23,9 +23,9 @@ import {
EntryFailurePayload,
EntryDeletePayload,
EntriesRequestPayload,
EntryDraft,
} from '../types/redux';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util/src';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
let collection: string;
let loadedEntries: EntryObject[];
@ -144,8 +144,7 @@ export const selectMediaFolder = (
) => {
let mediaFolder = config.get('media_folder');
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
if (useWorkflow && collection && collection.has('media_folder')) {
if (collection && collection.has('media_folder')) {
if (entryPath) {
const entryDir = dirname(entryPath);
mediaFolder = join(entryDir, collection.get('media_folder') as string);
@ -189,12 +188,17 @@ export const selectMediaFilePublicPath = (
let publicFolder = config.get('public_folder');
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
if (useWorkflow && collection && collection.has('public_folder')) {
if (collection && collection.has('public_folder')) {
publicFolder = collection.get('public_folder') as string;
}
return join(publicFolder, basename(mediaPath));
};
export const selectEditingDraft = (state: EntryDraft) => {
const entry = state.get('entry');
const workflowDraft = entry && !entry.isEmpty();
return workflowDraft;
};
export default entries;

View File

@ -18,11 +18,29 @@ import {
MEDIA_DISPLAY_URL_REQUEST,
MEDIA_DISPLAY_URL_SUCCESS,
MEDIA_DISPLAY_URL_FAILURE,
} from 'Actions/mediaLibrary';
import { selectEditingWorkflowDraft } from 'Reducers/editorialWorkflow';
import { selectIntegration } from 'Reducers';
} from '../actions/mediaLibrary';
import { selectEditingDraft } from './entries';
import { selectIntegration } from './';
import {
State,
MediaLibraryAction,
MediaLibraryInstance,
MediaFile,
MediaFileMap,
DisplayURLState,
} from '../types/redux';
const defaultState = {
const defaultState: {
isVisible: boolean;
showMediaButton: boolean;
controlMedia: Map<string, string>;
displayURLs: Map<string, string>;
externalLibrary?: MediaLibraryInstance;
controlID?: string;
page?: number;
files?: MediaFile[];
config: Map<string, string>;
} = {
isVisible: false,
showMediaButton: true,
controlMedia: Map(),
@ -30,7 +48,7 @@ const defaultState = {
config: Map(),
};
const mediaLibrary = (state = Map(defaultState), action) => {
const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) => {
switch (action.type) {
case MEDIA_LIBRARY_CREATE:
return state.withMutations(map => {
@ -104,7 +122,7 @@ const mediaLibrary = (state = Map(defaultState), action) => {
map.set('dynamicSearchQuery', dynamicSearchQuery);
map.set('dynamicSearchActive', !!dynamicSearchQuery);
if (page && page > 1) {
const updatedFiles = map.get('files').concat(filesWithKeys);
const updatedFiles = (map.get('files') as MediaFile[]).concat(filesWithKeys);
map.set('files', updatedFiles);
} else {
map.set('files', filesWithKeys);
@ -128,7 +146,8 @@ const mediaLibrary = (state = Map(defaultState), action) => {
}
return state.withMutations(map => {
const fileWithKey = { ...file, key: uuid() };
const updatedFiles = [fileWithKey, ...map.get('files')];
const files = map.get('files') as MediaFile[];
const updatedFiles = [fileWithKey, ...files];
map.set('files', updatedFiles);
map.set('isPersisting', false);
});
@ -149,9 +168,8 @@ const mediaLibrary = (state = Map(defaultState), action) => {
return state;
}
return state.withMutations(map => {
const updatedFiles = map
.get('files')
.filter(file => (key ? file.key !== key : file.id !== id));
const files = map.get('files') as MediaFile[];
const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id));
map.set('files', updatedFiles);
map.deleteIn(['displayURLs', id]);
map.set('isDeleting', false);
@ -191,17 +209,17 @@ const mediaLibrary = (state = Map(defaultState), action) => {
}
};
export function selectMediaFiles(state) {
export function selectMediaFiles(state: State) {
const { mediaLibrary, entryDraft } = state;
const workflowDraft = selectEditingWorkflowDraft(state);
const editingDraft = selectEditingDraft(state.entryDraft);
const integration = selectIntegration(state, null, 'assetStore');
let files;
if (workflowDraft && !integration) {
files = entryDraft
.getIn(['entry', 'mediaFiles'], List())
.toJS()
.map(file => ({ key: file.id, ...file }));
if (editingDraft && !integration) {
const entryFiles = entryDraft
.getIn(['entry', 'mediaFiles'], List<MediaFileMap>())
.toJS() as MediaFile[];
files = entryFiles.map(file => ({ key: file.id, ...file }));
} else {
files = mediaLibrary.get('files') || [];
}
@ -209,14 +227,17 @@ export function selectMediaFiles(state) {
return files;
}
export function selectMediaFileByPath(state, path) {
export function selectMediaFileByPath(state: State, path: string) {
const files = selectMediaFiles(state);
const file = files.find(file => file.path === path);
return file;
}
export function selectMediaDisplayURL(state, id) {
const displayUrlState = state.mediaLibrary.getIn(['displayURLs', id], Map());
export function selectMediaDisplayURL(state: State, id: string) {
const displayUrlState = state.mediaLibrary.getIn(
['displayURLs', id],
(Map() as unknown) as DisplayURLState,
);
return displayUrlState;
}

View File

@ -21,4 +21,11 @@ export interface StaticallyTypedRecord<T> {
some<K extends keyof T>(predicate: (value: T[K], key: K, iter: this) => boolean): boolean;
mapKeys<K extends keyof T, V>(mapFunc: (key: K, value: StaticallyTypedRecord<T>) => V): V[];
find<K extends keyof T>(findFunc: (value: T[K]) => boolean): T[K];
filter<K extends keyof T>(
predicate: (value: T[K], key: K, iter: this) => boolean,
): StaticallyTypedRecord<T>;
valueSeq<K extends keyof T>(): T[K][];
map<K extends keyof T, V>(
mapFunc: (value: T[K]) => V,
): StaticallyTypedRecord<{ [key: string]: V }>;
}

View File

@ -2,6 +2,7 @@ import { Action } from 'redux';
import { StaticallyTypedRecord } from './immutable';
import { Map, List } from 'immutable';
import AssetProxy from '../valueObjects/AssetProxy';
import { ImplementationMediaFile } from 'netlify-cms-lib-util';
export type SlugConfig = StaticallyTypedRecord<{
encoding: string;
@ -11,6 +12,17 @@ export type SlugConfig = StaticallyTypedRecord<{
type BackendObject = {
name: string;
repo?: string | null;
open_authoring?: boolean;
branch?: string;
api_root?: string;
squash_merges?: boolean;
use_graphql?: boolean;
preview_context?: string;
identity_url?: string;
gateway_url?: string;
large_media_url?: string;
use_large_media_transforms_in_media_library?: boolean;
};
type Backend = StaticallyTypedRecord<Backend> & BackendObject;
@ -24,6 +36,8 @@ export type Config = StaticallyTypedRecord<{
locale?: string;
slug: SlugConfig;
media_folder_relative?: boolean;
base_url?: string;
site_id?: string;
site_url?: string;
show_preview_links?: boolean;
}>;
@ -36,7 +50,7 @@ type Pages = StaticallyTypedRecord<PagesObject>;
type EntitiesObject = { [key: string]: EntryMap };
type Entities = StaticallyTypedRecord<EntitiesObject>;
export type Entities = StaticallyTypedRecord<EntitiesObject>;
export type Entries = StaticallyTypedRecord<{
pages: Pages & PagesObject;
@ -45,7 +59,10 @@ export type Entries = StaticallyTypedRecord<{
export type Deploys = StaticallyTypedRecord<{}>;
export type EditorialWorkflow = StaticallyTypedRecord<{}>;
export type EditorialWorkflow = StaticallyTypedRecord<{
pages: Pages & PagesObject;
entities: Entities & EntitiesObject;
}>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type EntryObject = {
@ -56,6 +73,7 @@ export type EntryObject = {
collection: string;
mediaFiles: List<MediaFileMap>;
newRecord: boolean;
metaData: { status: string };
};
export type EntryMap = StaticallyTypedRecord<EntryObject>;
@ -120,7 +138,7 @@ export type Collections = StaticallyTypedRecord<{ [path: string]: Collection & C
export type Medias = StaticallyTypedRecord<{ [path: string]: AssetProxy | undefined }>;
interface MediaLibraryInstance {
export interface MediaLibraryInstance {
show: (args: {
id?: string;
value?: string;
@ -136,23 +154,17 @@ interface MediaLibraryInstance {
export type DisplayURL = { id: string; path: string } | string;
export interface MediaFile {
name: string;
id: string;
size?: number;
displayURL?: DisplayURL;
path: string;
draft?: boolean;
url?: string;
}
export type MediaFile = ImplementationMediaFile & { key?: string };
export type MediaFileMap = StaticallyTypedRecord<MediaFile>;
export type DisplayURLState = StaticallyTypedRecord<{
type DisplayURLStateObject = {
isFetching: boolean;
url?: string;
err?: Error;
}>;
};
export type DisplayURLState = StaticallyTypedRecord<DisplayURLStateObject>;
interface DisplayURLsObject {
[id: string]: DisplayURLState;
@ -262,3 +274,43 @@ export interface EntriesAction extends Action<string> {
export interface CollectionsAction extends Action<string> {
payload?: StaticallyTypedRecord<{ collections: List<Collection> }>;
}
export interface EditorialWorkflowAction extends Action<string> {
payload?: StaticallyTypedRecord<{ publish_mode: string }> & {
collection: string;
entry: { slug: string };
} & {
collection: string;
slug: string;
} & {
pages: [];
entries: { collection: string; slug: string }[];
} & {
collection: string;
entry: StaticallyTypedRecord<{ slug: string }>;
} & {
collection: string;
slug: string;
newStatus: string;
};
}
export interface MediaLibraryAction extends Action<string> {
payload: MediaLibraryInstance & {
controlID: string;
forImage: boolean;
privateUpload: boolean;
config: Map<string, string>;
} & { mediaPath: string | string[] } & { page: number } & {
files: MediaFile[];
page: number;
canPaginate: boolean;
dynamicSearch: boolean;
dynamicSearchQuery: boolean;
} & {
file: MediaFile;
privateUpload: boolean;
} & {
file: { id: string; key: string; privateUpload: boolean };
} & { key: string } & { url: string } & { err: Error };
}

View File

@ -1,5 +1,5 @@
import { isBoolean } from 'lodash';
import { ImplementationMediaFile } from '../backend';
import { ImplementationMediaFile } from 'netlify-cms-lib-util';
interface Options {
partial?: boolean;