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:
committed by
Shawn Erquhart
parent
4ff5bc2ee0
commit
6f221ab3c1
305
packages/netlify-cms-lib-util/src/implementation.ts
Normal file
305
packages/netlify-cms-lib-util/src/implementation.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import semaphore, { Semaphore } from 'semaphore';
|
||||
import Cursor from './Cursor';
|
||||
import { AsyncLock } from './asyncLock';
|
||||
|
||||
export type DisplayURLObject = { id: string; path: string };
|
||||
|
||||
export type DisplayURL =
|
||||
| DisplayURLObject
|
||||
| string
|
||||
| { original: DisplayURL; path?: string; largeMedia?: 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 ImplementationEntry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: string;
|
||||
file: { path: string; label?: string; id?: string | null };
|
||||
slug?: string;
|
||||
mediaFiles?: ImplementationMediaFile[];
|
||||
metaData?: { collection: string; status: string };
|
||||
isModification?: boolean;
|
||||
}
|
||||
|
||||
export interface Map {
|
||||
get: <T>(key: string, defaultValue?: T) => T;
|
||||
getIn: <T>(key: string[], defaultValue?: T) => T;
|
||||
setIn: <T>(key: string[], value: T) => Map;
|
||||
set: <T>(key: string, value: T) => Map;
|
||||
}
|
||||
|
||||
export type AssetProxy = {
|
||||
path: string;
|
||||
fileObj?: File;
|
||||
toBase64?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type Entry = { path: string; slug: string; raw: string };
|
||||
|
||||
export type PersistOptions = {
|
||||
newEntry?: boolean;
|
||||
parsedData?: { title: string; description: string };
|
||||
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 type Config = {
|
||||
backend: {
|
||||
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;
|
||||
};
|
||||
media_folder: string;
|
||||
base_url?: string;
|
||||
site_id?: string;
|
||||
};
|
||||
|
||||
export interface 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: (obj: Entry, assetProxies: AssetProxy[], opts: PersistOptions) => Promise<void>;
|
||||
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
|
||||
deleteFile: (path: string, commitMessage: string) => Promise<void>;
|
||||
|
||||
unpublishedEntries: () => Promise<ImplementationEntry[]>;
|
||||
unpublishedEntry: (collection: string, slug: string) => Promise<ImplementationEntry>;
|
||||
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 }>;
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export type ImplementationFile = {
|
||||
id?: string | null | undefined;
|
||||
label?: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type Metadata = {
|
||||
objects: { entry: { path: string } };
|
||||
collection: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type ReadFile = (
|
||||
path: string,
|
||||
id: string | null | undefined,
|
||||
options: { parseText: boolean },
|
||||
) => Promise<string | Blob>;
|
||||
type ReadUnpublishedFile = (
|
||||
key: string,
|
||||
) => Promise<{ metaData: Metadata; fileData: string; isModification: boolean; slug: string }>;
|
||||
|
||||
const fetchFiles = async (files: ImplementationFile[], readFile: ReadFile, apiName: string) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [] as Promise<ImplementationEntry | { error: boolean }>[];
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
readFile(file.path, file.id, { parseText: true })
|
||||
.then(data => {
|
||||
resolve({ file, data: data as string });
|
||||
sem.leave();
|
||||
})
|
||||
.catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from ${apiName}: ${file.path}`);
|
||||
resolve({ error });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error),
|
||||
) as Promise<ImplementationEntry[]>;
|
||||
};
|
||||
|
||||
const fetchUnpublishedFiles = async (
|
||||
keys: string[],
|
||||
readUnpublishedFile: ReadUnpublishedFile,
|
||||
apiName: string,
|
||||
) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [] as Promise<ImplementationEntry | { error: boolean }>[];
|
||||
keys.forEach(key => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
readUnpublishedFile(key)
|
||||
.then(data => {
|
||||
if (data === null || data === undefined) {
|
||||
resolve({ error: true });
|
||||
sem.leave();
|
||||
} else {
|
||||
resolve({
|
||||
slug: data.slug,
|
||||
file: { path: data.metaData.objects.entry.path, id: null },
|
||||
data: data.fileData,
|
||||
metaData: data.metaData,
|
||||
isModification: data.isModification,
|
||||
});
|
||||
sem.leave();
|
||||
}
|
||||
})
|
||||
.catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from ${apiName}: ${key}`);
|
||||
resolve({ error });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !(loadedEntry as { error: boolean }).error),
|
||||
) as Promise<ImplementationEntry[]>;
|
||||
};
|
||||
|
||||
export const entriesByFolder = async (
|
||||
listFiles: () => Promise<ImplementationFile[]>,
|
||||
readFile: ReadFile,
|
||||
apiName: string,
|
||||
) => {
|
||||
const files = await listFiles();
|
||||
return fetchFiles(files, readFile, apiName);
|
||||
};
|
||||
|
||||
export const entriesByFiles = async (
|
||||
files: ImplementationFile[],
|
||||
readFile: ReadFile,
|
||||
apiName: string,
|
||||
) => {
|
||||
return fetchFiles(files, readFile, apiName);
|
||||
};
|
||||
|
||||
export const unpublishedEntries = async (
|
||||
listEntriesKeys: () => Promise<string[]>,
|
||||
readUnpublishedFile: ReadUnpublishedFile,
|
||||
apiName: string,
|
||||
) => {
|
||||
try {
|
||||
const keys = await listEntriesKeys();
|
||||
const entries = await fetchUnpublishedFiles(keys, readUnpublishedFile, apiName);
|
||||
return entries;
|
||||
} catch (error) {
|
||||
if (error.message === 'Not Found') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMediaAsBlob = async (path: string, id: string | null, readFile: ReadFile) => {
|
||||
let blob: Blob;
|
||||
if (path.match(/.svg$/)) {
|
||||
const text = (await readFile(path, id, { parseText: true })) as string;
|
||||
blob = new Blob([text], { type: 'image/svg+xml' });
|
||||
} else {
|
||||
blob = (await readFile(path, id, { parseText: false })) as Blob;
|
||||
}
|
||||
return blob;
|
||||
};
|
||||
|
||||
export const getMediaDisplayURL = async (
|
||||
displayURL: DisplayURL,
|
||||
readFile: ReadFile,
|
||||
semaphore: Semaphore,
|
||||
) => {
|
||||
const { path, id } = displayURL as DisplayURLObject;
|
||||
return new Promise<string>((resolve, reject) =>
|
||||
semaphore.take(() =>
|
||||
getMediaAsBlob(path, id, readFile)
|
||||
.then(blob => URL.createObjectURL(blob))
|
||||
.then(resolve, reject)
|
||||
.finally(() => semaphore.leave()),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const runWithLock = async (lock: AsyncLock, func: Function, message: string) => {
|
||||
try {
|
||||
const acquired = await lock.acquire();
|
||||
if (!acquired) {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
const result = await func();
|
||||
return result;
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user