feat(backend-github): add pagination (#3379)

This commit is contained in:
Erez Rokah 2020-03-05 11:58:49 +01:00 committed by GitHub
parent 5339735994
commit 39f1307e3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 295 additions and 7 deletions

View File

@ -258,6 +258,9 @@ export default class GitGateway implements Implementation {
entriesByFolder(folder: string, extension: string, depth: number) { entriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.entriesByFolder(folder, extension, depth); return this.backend!.entriesByFolder(folder, extension, depth);
} }
allEntriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.allEntriesByFolder(folder, extension, depth);
}
entriesByFiles(files: ImplementationFile[]) { entriesByFiles(files: ImplementationFile[]) {
return this.backend!.entriesByFiles(files); return this.backend!.entriesByFiles(files);
} }
@ -511,6 +514,6 @@ export default class GitGateway implements Implementation {
return this.backend!.publishUnpublishedEntry(collection, slug); return this.backend!.publishUnpublishedEntry(collection, slug);
} }
traverseCursor(cursor: Cursor, action: string) { traverseCursor(cursor: Cursor, action: string) {
return (this.backend as GitLabBackend | BitbucketBackend).traverseCursor!(cursor, action); return this.backend!.traverseCursor!(cursor, action);
} }
} }

View File

@ -1,4 +1,5 @@
import GitHubImplementation from '../implementation'; import GitHubImplementation from '../implementation';
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {});
@ -214,4 +215,185 @@ describe('github backend implementation', () => {
]); ]);
}); });
}); });
describe('entriesByFolder', () => {
const listFiles = jest.fn();
const readFile = jest.fn();
const mockAPI = {
listFiles,
readFile,
originRepoURL: 'originRepoURL',
};
it('should return entries and cursor', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const files = [];
const count = 1501;
for (let i = 0; i < count; i++) {
const id = `${i}`.padStart(`${count}`.length, '0');
files.push({
id,
path: `posts/post-${id}.md`,
});
}
listFiles.mockResolvedValue(files);
readFile.mockImplementation((path, id) => Promise.resolve(`${id}`));
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
expectedEntries[CURSOR_COMPATIBILITY_SYMBOL] = expectedCursor;
const result = await gitHubImplementation.entriesByFolder('posts', 'md', 1);
expect(result).toEqual(expectedEntries);
expect(listFiles).toHaveBeenCalledTimes(1);
expect(listFiles).toHaveBeenCalledWith('posts', { depth: 1, repoURL: 'originRepoURL' });
expect(readFile).toHaveBeenCalledTimes(20);
});
});
describe('traverseCursor', () => {
const listFiles = jest.fn();
const readFile = jest.fn((path, id) => Promise.resolve(`${id}`));
const mockAPI = {
listFiles,
readFile,
originRepoURL: 'originRepoURL',
};
const files = [];
const count = 1501;
for (let i = 0; i < count; i++) {
const id = `${i}`.padStart(`${count}`.length, '0');
files.push({
id,
path: `posts/post-${id}.md`,
});
}
it('should handle next action', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(20, 40)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['prev', 'first', 'next', 'last'],
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await gitHubImplementation.traverseCursor(cursor, 'next');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle prev action', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['prev', 'first', 'next', 'last'],
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await gitHubImplementation.traverseCursor(cursor, 'prev');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle last action', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(1500)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['prev', 'first'],
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await gitHubImplementation.traverseCursor(cursor, 'last');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
it('should handle first action', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
const cursor = Cursor.create({
actions: ['prev', 'first'],
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const expectedEntries = files
.slice(0, 20)
.map(({ id, path }) => ({ data: id, file: { path, id } }));
const expectedCursor = Cursor.create({
actions: ['next', 'last'],
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
data: { files },
});
const result = await gitHubImplementation.traverseCursor(cursor, 'first');
expect(result).toEqual({
entries: expectedEntries,
cursor: expectedCursor,
});
});
});
}); });

View File

@ -3,6 +3,8 @@ import semaphore, { Semaphore } from 'semaphore';
import trimStart from 'lodash/trimStart'; import trimStart from 'lodash/trimStart';
import { stripIndent } from 'common-tags'; import { stripIndent } from 'common-tags';
import { import {
CURSOR_COMPATIBILITY_SYMBOL,
Cursor,
asyncLock, asyncLock,
basename, basename,
AsyncLock, AsyncLock,
@ -29,13 +31,15 @@ import {
} from 'netlify-cms-lib-util'; } from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import API, { Entry } from './API'; import API, { Entry, API_NAME } from './API';
import GraphQLAPI from './GraphQLAPI'; import GraphQLAPI from './GraphQLAPI';
type GitHubUser = Octokit.UsersGetAuthenticatedResponse; type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
const MAX_CONCURRENT_DOWNLOADS = 10; const MAX_CONCURRENT_DOWNLOADS = 10;
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
export default class GitHub implements Implementation { export default class GitHub implements Implementation {
lock: AsyncLock; lock: AsyncLock;
api: API | null; api: API | null;
@ -281,19 +285,72 @@ export default class GitHub implements Implementation {
return Promise.resolve(this.token); 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) { async entriesByFolder(folder: string, extension: string, depth: number) {
const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL; const repoURL = this.api!.originRepoURL;
let cursor: Cursor;
const listFiles = () => const listFiles = () =>
this.api!.listFiles(folder, { this.api!.listFiles(folder, {
repoURL, repoURL,
depth, depth,
}).then(filterByPropExtension(extension, 'path')); }).then(files => {
const filtered = filterByPropExtension(extension, 'path')(files);
const result = this.getCursorAndFiles(filtered, 1);
cursor = result.cursor;
return result.files;
});
const readFile = (path: string, id: string | null | undefined) => const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }) as Promise<string>; this.api!.readFile(path, id, { repoURL }) as Promise<string>;
return entriesByFolder(listFiles, readFile, 'GitHub'); const files = await entriesByFolder(listFiles, readFile, API_NAME);
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @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 => {
return filterByPropExtension(extension, 'path')(files);
});
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
const files = await entriesByFolder(listFiles, readFile, API_NAME);
return files;
} }
entriesByFiles(files: ImplementationFile[]) { entriesByFiles(files: ImplementationFile[]) {
@ -385,6 +442,49 @@ export default class GitHub implements Implementation {
return this.api!.deleteFile(path, commitMessage); return this.api!.deleteFile(path, 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;
}
}
return {
entries: await Promise.all(
result.files.map(file =>
this.api!.readFile(file.path, file.id, { repoURL: this.api!.originRepoURL }).then(
data => ({
file,
data: data as string,
}),
),
),
),
cursor: result.cursor,
};
}
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) { loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = ( const readFile = (
path: string, path: string,

View File

@ -40,6 +40,7 @@ const jsToMap = (obj: {}) => {
const knownMetaKeys = Set([ const knownMetaKeys = Set([
'index', 'index',
'page',
'count', 'count',
'pageSize', 'pageSize',
'pageCount', 'pageCount',
@ -85,8 +86,10 @@ const getActionHandlers = (store: CursorStore, handler: ActionHandler) =>
export default class Cursor { export default class Cursor {
store?: CursorStore; store?: CursorStore;
actions?: Set<string>; actions?: Set<string>;
data?: Map<string, unknown>; // eslint-disable-next-line @typescript-eslint/no-explicit-any
meta?: Map<string, unknown>; data?: Map<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta?: Map<string, any>;
static create(...args: {}[]) { static create(...args: {}[]) {
return new Cursor(...args); return new Cursor(...args);