Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
67187bb
feat: added interface for EditorNotesPane
EstoesMoises Jul 26, 2025
1aeff96
feat: crud with Redux complete for EditorNotesPane
EstoesMoises Jul 26, 2025
6c9e475
feat: notes functionality connected to Test Backend
EstoesMoises Jul 26, 2025
cdc52aa
feat: notespane now immediately saves the note and it works with prox…
EstoesMoises Jul 27, 2025
e04f2f0
feat: notespane now integrated with Github backend
EstoesMoises Jul 28, 2025
51abb33
feat: adding notes to test backend
EstoesMoises Jul 28, 2025
be1a1d6
chore: adding english locale to Note toasts
EstoesMoises Jul 28, 2025
8ea8cb2
chore: linting + notes will only work on EditorialWorkflow
EstoesMoises Jul 28, 2025
c97975d
feat: notes can be unresolved
EstoesMoises Jul 28, 2025
eb9e5c6
fix: removing char limit on notes
EstoesMoises Jul 28, 2025
039e84d
feat: avatar now visible in notes
EstoesMoises Jul 28, 2025
7117c47
fix: now getting author and avatar immediately on note creation + not…
EstoesMoises Jul 28, 2025
e2f38db
feat: you can only edit or delete your own notes
EstoesMoises Jul 28, 2025
d07a7bf
chore: adding error handling for API calls and fixing colours in Note…
EstoesMoises Jul 28, 2025
9048235
fix: passing tests
EstoesMoises Jul 28, 2025
5a4c192
fix: automated test account for Notes
EstoesMoises Jul 28, 2025
7eb8d07
Merge branch 'main' into 52-notes-pane
martinjagodic Jul 31, 2025
0636dfb
fix: inverted logic for initializing notesVisible
EstoesMoises Sep 22, 2025
706ec59
fix: more robust handleBlur function for notes
EstoesMoises Sep 22, 2025
240091b
feat: notes pane feature is now opt-in depending on config
EstoesMoises Sep 22, 2025
4edb039
chore: changed icon for notesPane to quote
EstoesMoises Sep 22, 2025
f3a14ef
feat: notes now use Github issues making notes usable for both publi…
EstoesMoises Sep 23, 2025
9265a4f
fix: removed condition that would loadNotes() twice
EstoesMoises Sep 25, 2025
722cbd5
feat: lifecycle for github issues when publishing/unpublishing entries
EstoesMoises Sep 25, 2025
fc8c647
chore: format
EstoesMoises Sep 25, 2025
4b444db
chore: added tests for notes on editorialWorkflow specs
EstoesMoises Sep 25, 2025
a143fde
chore: added tests for Notes Github implementation
EstoesMoises Sep 25, 2025
28ed3d2
chore: updated config specification test to account for the editor.no…
EstoesMoises Sep 25, 2025
4ae697f
Merge branch 'main' into 52-notes-pane
EstoesMoises Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
389 changes: 389 additions & 0 deletions packages/decap-cms-backend-github/src/API.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ describe('github backend implementation', () => {
},
};

const configWithNotes = {
backend: {
repo: 'owner/repo',
open_authoring: false,
api_root: 'https://api.github.com',
},
editor: {
notes: true,
},
};

const createObjectURL = jest.fn();
global.URL = {
createObjectURL,
Expand Down Expand Up @@ -358,4 +369,327 @@ describe('github backend implementation', () => {
});
});
});
describe('notes implementation', () => {
const mockAPI = {
getEntryNotes: jest.fn(),
addNoteToEntry: jest.fn(),
updateEntryNote: jest.fn(),
deleteEntryNote: jest.fn(),
closeEntryNotesIssue: jest.fn(),
closeIssueOnPublish: jest.fn(),
reopenIssueOnUnpublish: jest.fn(),
readFile: jest.fn(),
deleteUnpublishedEntry: jest.fn().mockResolvedValue(undefined),
publishUnpublishedEntry: jest.fn().mockResolvedValue(undefined),
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('getNotes', () => {
it('should retrieve notes for an entry', async () => {
const gitHubImplementation = new GitHubImplementation(configWithNotes);
gitHubImplementation.api = mockAPI;

const mockNotes = [
{
id: '1',
author: 'user1',
avatarUrl: 'https://avatar.url',
text: 'Test note',
timestamp: '2025-01-01T00:00:00Z',
resolved: false,
},
];

mockAPI.getEntryNotes.mockResolvedValue(mockNotes);

const result = await gitHubImplementation.getNotes('posts', 'my-post');

expect(result).toEqual([
{
...mockNotes[0],
entrySlug: 'my-post',
},
]);
expect(mockAPI.getEntryNotes).toHaveBeenCalledWith('posts', 'my-post');
});

it('should return empty array on error', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

mockAPI.getEntryNotes.mockRejectedValue(new Error('API Error'));

const result = await gitHubImplementation.getNotes('posts', 'my-post');

expect(result).toEqual([]);
expect(console.error).toHaveBeenCalledWith('Failed to get notes:', expect.any(Error));
});
});

describe('addNote', () => {
it('should add a note to an entry', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
gitHubImplementation.token = 'test-token';
gitHubImplementation.currentUser = jest.fn().mockResolvedValue({
login: 'testuser',
name: 'Test User',
avatar_url: 'https://avatar.url',
});

const noteData = {
text: 'New note',
timestamp: '2025-01-01T00:00:00Z',
resolved: false,
};

mockAPI.addNoteToEntry.mockResolvedValue('comment-123');
mockAPI.readFile.mockResolvedValue('title: My Post Title\n\nContent');

const result = await gitHubImplementation.addNote('posts', 'my-post', noteData);

expect(result).toMatchObject({
text: 'New note',
author: 'testuser',
avatarUrl: 'https://avatar.url',
entrySlug: 'my-post',
resolved: false,
id: 'comment-123',
});
expect(mockAPI.addNoteToEntry).toHaveBeenCalledWith(
'posts',
'my-post',
expect.objectContaining({
text: 'New note',
author: 'testuser',
}),
'My Post Title',
);
});

it('should handle missing entry title gracefully', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
gitHubImplementation.token = 'test-token';
gitHubImplementation.currentUser = jest.fn().mockResolvedValue({
login: 'testuser',
avatar_url: 'https://avatar.url',
});

const noteData = {
text: 'New note',
timestamp: '2025-01-01T00:00:00Z',
};

mockAPI.addNoteToEntry.mockResolvedValue('comment-123');
mockAPI.readFile.mockRejectedValue(new Error('Not found'));

const result = await gitHubImplementation.addNote('posts', 'my-post', noteData);

expect(mockAPI.addNoteToEntry).toHaveBeenCalledWith(
'posts',
'my-post',
expect.any(Object),
undefined,
);
expect(result.id).toBe('comment-123');
});
});

describe('updateNote', () => {
it('should update an existing note', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

const existingNotes = [
{
id: 'note-1',
author: 'user1',
avatarUrl: 'https://avatar.url',
text: 'Original text',
timestamp: '2025-01-01T00:00:00Z',
resolved: false,
entrySlug: 'my-post',
},
];

mockAPI.getEntryNotes.mockResolvedValue(existingNotes);
mockAPI.updateEntryNote.mockResolvedValue(undefined);

const updates = { text: 'Updated text', resolved: true };
const result = await gitHubImplementation.updateNote('posts', 'my-post', 'note-1', updates);

expect(result).toEqual({
...existingNotes[0],
text: 'Updated text',
resolved: true,
});
expect(mockAPI.updateEntryNote).toHaveBeenCalledWith('note-1', result);
});

it('should throw error if note not found', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

mockAPI.getEntryNotes.mockResolvedValue([]);

await expect(
gitHubImplementation.updateNote('posts', 'my-post', 'non-existent', {}),
).rejects.toThrow('Note with ID non-existent not found');
});
});

describe('deleteNote', () => {
it('should delete an existing note', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

const existingNotes = [
{
id: 'note-1',
author: 'user1',
text: 'Test note',
entrySlug: 'my-post',
},
];

mockAPI.getEntryNotes.mockResolvedValue(existingNotes);
mockAPI.deleteEntryNote.mockResolvedValue(undefined);

await gitHubImplementation.deleteNote('posts', 'my-post', 'note-1');

expect(mockAPI.deleteEntryNote).toHaveBeenCalledWith('note-1');
});

it('should throw error if note not found', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

mockAPI.getEntryNotes.mockResolvedValue([]);

await expect(
gitHubImplementation.deleteNote('posts', 'my-post', 'non-existent'),
).rejects.toThrow('Note with ID non-existent not found');
});
});

describe('toggleNoteResolution', () => {
it('should toggle note resolved status from false to true', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

const existingNotes = [
{
id: 'note-1',
author: 'user1',
avatarUrl: 'https://avatar.url',
text: 'Test note',
timestamp: '2025-01-01T00:00:00Z',
resolved: false,
entrySlug: 'my-post',
},
];

mockAPI.getEntryNotes.mockResolvedValue(existingNotes);
mockAPI.updateEntryNote.mockResolvedValue(undefined);

const result = await gitHubImplementation.toggleNoteResolution(
'posts',
'my-post',
'note-1',
);

expect(result.resolved).toBe(true);
expect(mockAPI.updateEntryNote).toHaveBeenCalledWith(
'note-1',
expect.objectContaining({ resolved: true }),
);
});

it('should toggle note resolved status from true to false', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

const existingNotes = [
{
id: 'note-1',
resolved: true,
entrySlug: 'my-post',
},
];

mockAPI.getEntryNotes.mockResolvedValue(existingNotes);
mockAPI.updateEntryNote.mockResolvedValue(undefined);

const result = await gitHubImplementation.toggleNoteResolution(
'posts',
'my-post',
'note-1',
);

expect(result.resolved).toBe(false);
});

it('should throw error if note not found', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

mockAPI.getEntryNotes.mockResolvedValue([]);

await expect(
gitHubImplementation.toggleNoteResolution('posts', 'my-post', 'non-existent'),
).rejects.toThrow('Note with ID non-existent not found');
});
});

describe('reopenIssueForUnpublishedEntry', () => {
it('should reopen issue for unpublished entry', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

mockAPI.reopenIssueOnUnpublish.mockResolvedValue(undefined);

await gitHubImplementation.reopenIssueForUnpublishedEntry('posts', 'my-post');

expect(mockAPI.reopenIssueOnUnpublish).toHaveBeenCalledWith('posts', 'my-post');
});
});

describe('deleteUnpublishedEntry with notes cleanup', () => {
it('should delete entry and close associated notes issue', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI; // Just use mockAPI directly

await gitHubImplementation.deleteUnpublishedEntry('posts', 'my-post');

expect(mockAPI.deleteUnpublishedEntry).toHaveBeenCalledWith('posts', 'my-post');
expect(mockAPI.closeEntryNotesIssue).toHaveBeenCalledWith('posts', 'my-post');
});

it('should continue deletion even if closing issue fails', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
mockAPI.closeEntryNotesIssue.mockRejectedValue(new Error('Issue close failed'));

await gitHubImplementation.deleteUnpublishedEntry('posts', 'my-post');

expect(mockAPI.deleteUnpublishedEntry).toHaveBeenCalledWith('posts', 'my-post');
});
});

describe('publishUnpublishedEntry with issue cleanup', () => {
it('should publish entry and close issue', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;

await gitHubImplementation.publishUnpublishedEntry('posts', 'my-post');

expect(mockAPI.publishUnpublishedEntry).toHaveBeenCalledWith('posts', 'my-post');
expect(mockAPI.closeIssueOnPublish).toHaveBeenCalledWith('posts', 'my-post');
});
});
});
});
Loading
Loading