diff --git a/packages/decap-cms-backend-github/src/API.ts b/packages/decap-cms-backend-github/src/API.ts index 5120c0286195..08ecc2892574 100644 --- a/packages/decap-cms-backend-github/src/API.ts +++ b/packages/decap-cms-backend-github/src/API.ts @@ -37,6 +37,9 @@ import type { PersistOptions, FetchError, ApiRequest, + Note, + IssueState, + CommentData, } from 'decap-cms-lib-util'; import type { Semaphore } from 'semaphore'; import type { Endpoints } from '@octokit/types'; @@ -65,6 +68,8 @@ type GitHubLabel = Omit< export const API_NAME = 'GitHub'; +const { fetchWithTimeout: fetch } = unsentRequest; + export const MOCK_PULL_REQUEST = -1; export interface Config { @@ -89,6 +94,26 @@ interface TreeFile { raw?: string; } +interface GitHubIssue { + id: number; + number: number; + title: string; + body: string; + state: 'open' | 'closed'; + comments: number; + html_url: string; + created_at: string; + updated_at: string; + user: { + login: string; + avatar_url: string; + } | null; + labels: Array<{ + name: string; + color: string; + }>; +} + interface TreeFileForUpdate { sha: string | null; path: string; @@ -1204,6 +1229,7 @@ export default class API { const pullRequest = await this.getBranchPullRequest(branch); await this.mergePR(pullRequest); await this.deleteBranch(branch); + await this.closeIssueOnPublish(collectionName, slug); } async createRef(type: string, name: string, sha: string) { @@ -1497,4 +1523,463 @@ export default class API { const pullRequest = await this.getBranchPullRequest(branch); return pullRequest.head.sha; } + + /** + * Constants for note formatting to aid with PR comment to note conversion + */ + private static readonly NOTE_STATUS_RESOLVED = 'RESOLVED'; + private static readonly NOTE_STATUS_OPEN = 'OPEN'; + private static readonly NOTES_LABEL = 'decap-cms-notes'; + private static readonly NOTE_ISSUE_PREFIX = 'Notes: '; + // In Github we hide Decap Notes metadata in a HTML comment, that way we can track status of whether or not a note has been resolved (similar to GDocs) + private static readonly NOTE_REGEX = + /^([\s\S]+)$/; + + /** + * Format a note for PR comment display + */ + private formatNoteForGithub(note: Note): string { + const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN; + + return ` +${note.content}`; + } + + /** + * Parse a GitHub comment into a Note object + */ + parseCommentToNote(comment: GitHubIssue): Note { + if (!comment || !comment.body || !comment.user) { + throw new Error('Invalid comment structure'); + } + + const structuredMatch = comment.body.match(API.NOTE_REGEX); + + const content = structuredMatch ? structuredMatch[2].trim() : comment.body; + const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false; + + if (!content.trim()) { + throw new Error('Empty note content'); + } + + return { + id: comment.id.toString(), + author: comment.user.login, + avatarUrl: comment.user.avatar_url, + timestamp: comment.created_at, + content, + resolved, + entrySlug: '', + }; + } + + /** + * Create a GitHub issue for storing notes for a specific entry + */ + async createEntryIssue( + collectionName: string, + slug: string, + entryTitle?: string, + ): Promise { + const title = `${API.NOTE_ISSUE_PREFIX}${entryTitle || `${collectionName}/${slug}`}`; + const body = `This issue tracks notes for entry: \`${collectionName}/${slug}\`\n\n---\n*This issue was created automatically by Decap CMS for note management.*`; + + const response: GitHubIssue = await this.request(`${this.repoURL}/issues`, { + method: 'POST', + body: JSON.stringify({ + title, + body, + labels: [API.NOTES_LABEL, `collection:${collectionName}`], + }), + }); + + return response; + } + + /** + * Find existing issue for an entry (returns null if not found) + */ + async findEntryIssue(collectionName: string, slug: string): Promise { + // Search for existing issue + const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body`; + + try { + const searchResponse = await this.request('/search/issues', { + params: { q: searchQuery }, + }); + + if (searchResponse.items && searchResponse.items.length > 0) { + return searchResponse.items[0]; + } + return null; + } catch (error) { + console.warn('Failed to search for existing notes issue:', error); + return null; + } + } + /** + * Get issue with ETag support for conditional requests + * Returns { status: 304 } if not modified, or { status: 200, data, etag } if modified + */ + async getIssueWithETag( + issueNumber: number, + etag: string | null, + ): Promise< + | { status: 304; data?: never; etag?: never } + | { status: 200; data: IssueState; etag: string | null } + > { + try { + const headers: Record = { + Authorization: `${this.tokenKeyword} ${this.token}`, + }; + + if (etag) { + headers['If-None-Match'] = etag; + } + + const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, { + headers, + }); + + if (response.status === 304) { + return { status: 304 }; + } + + if (response.status === 200) { + const issue = await response.json(); + const newETag = response.headers.get('ETag'); + + const commentsResponse = await fetch( + `${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`, + { headers }, + ); + const commentsRaw: GitHubIssue[] = await commentsResponse.json(); + + const comments: CommentData[] = commentsRaw.map(comment => ({ + id: comment.id, + body: comment.body, + user: comment.user, + created_at: comment.created_at, + updated_at: comment.updated_at, + })); + + const issueState: IssueState = { + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + updated_at: issue.updated_at, + comments, + labels: issue.labels, + html_url: issue.html_url, + }; + + return { + status: 200, + data: issueState, + etag: newETag, + }; + } + + throw new Error(`Unexpected status: ${response.status}`); + } catch (error) { + if (error.status === 304) { + return { status: 304 }; + } + throw error; + } + } + + /** + * Get the current state of an issue (without ETag) + */ + async getIssueState(issueNumber: number): Promise { + const response = await this.getIssueWithETag(issueNumber, null); + if (response.status === 200 && response.data) { + return response.data; + } + throw new Error('Failed to get issue state'); + } + /** + * Get comments from a GitHub issue + */ + private async getIssueComments(issueNumber: number): Promise { + try { + const response: GitHubIssue[] = await this.request( + `${this.repoURL}/issues/${issueNumber}/comments`, + ); + return Array.isArray(response) ? response : []; + } catch (error) { + console.error('Failed to get issue comments:', error); + return []; + } + } + + /** + * Create a comment on a GitHub issue + */ + async createIssueComment(issueNumber: number, note: Note): Promise { + try { + const response: GitHubIssue = await this.request( + `${this.repoURL}/issues/${issueNumber}/comments`, + { + method: 'POST', + body: JSON.stringify({ + body: this.formatNoteForGithub(note), + }), + }, + ); + + return response.id.toString(); + } catch (error) { + console.error('Failed to create issue comment:', error); + throw new APIError('Failed to create note', error.status || 500, API_NAME); + } + } + + /** + * Update a GitHub issue comment + */ + async updateIssueComment(commentId: string | number, note: Note): Promise { + try { + await this.request(`${this.repoURL}/issues/comments/${commentId}`, { + method: 'PATCH', + body: JSON.stringify({ + body: this.formatNoteForGithub(note), + }), + }); + } catch (error) { + console.error('Failed to update issue comment:', error); + throw new APIError('Failed to update note', error.status || 500, API_NAME); + } + } + + /** + * Delete a GitHub issue comment + */ + async deleteIssueComment(commentId: string | number): Promise { + try { + await this.request(`${this.repoURL}/issues/comments/${commentId}`, { + method: 'DELETE', + }); + } catch (error) { + console.error('Failed to delete issue comment:', error); + throw new APIError('Failed to delete note', error.status || 500, API_NAME); + } + } + + /** + * Close the notes issue when an entry is published + */ + async closeIssueOnPublish(collectionName: string, slug: string): Promise { + try { + const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body state:open`; + const searchResponse = await this.request('/search/issues', { + params: { q: searchQuery }, + }); + + if (searchResponse.items && searchResponse.items.length > 0) { + const issue = searchResponse.items[0]; + await this.request(`${this.repoURL}/issues/${issue.number}`, { + method: 'PATCH', + body: JSON.stringify({ + state: 'closed', + labels: [ + ...(issue.labels || []).map((l: { name: string }) => l.name), + 'entry-published', + ], + }), + }); + } + } catch (error) { + console.warn('Failed to close notes issue on publish:', error); + } + } + + /** + * Reopen the notes issue when an entry is unpublished + */ + async reopenIssueOnUnpublish(collectionName: string, slug: string): Promise { + try { + const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body`; + const searchResponse = await this.request('/search/issues', { + params: { q: searchQuery }, + }); + + if (searchResponse.items && searchResponse.items.length > 0) { + const issue = searchResponse.items[0]; + // Remove 'entry-published' or 'entry-deleted' labels and reopen + const updatedLabels = (issue.labels || []) + .map((l: { name: string }) => l.name) + .filter((name: string) => name !== 'entry-published' && name !== 'entry-deleted'); + + await this.request(`${this.repoURL}/issues/${issue.number}`, { + method: 'PATCH', + body: JSON.stringify({ + state: 'open', + labels: updatedLabels, + }), + }); + } + } catch (error) { + console.warn('Failed to reopen notes issue on unpublish:', error); + } + } + + /** + * Get all notes for an entry + */ + async getEntryNotes(collectionName: string, slug: string): Promise { + try { + const issue = await this.findEntryIssue(collectionName, slug); + if (!issue) { + return []; // No issue means no notes yet + } + const comments = await this.getIssueComments(issue.number); + const issueUrl = issue.html_url; // Get the issue URL once + + // Add issueUrl to each note + return comments.map(comment => ({ + ...this.parseCommentToNote(comment), + issueUrl, // Add the issue URL to each note (this info is picked up by the UI to direct users to the source of the Notes in Github) + })); + } catch (error) { + console.error('Failed to get entry notes:', error); + return []; + } + } + + /** + * Add a note to any entry + */ + async addNoteToEntry( + collectionName: string, + slug: string, + note: Note, + entryTitle?: string, + ): Promise<{ commentId: string; issueUrl: string }> { + try { + let issue = await this.findEntryIssue(collectionName, slug); + if (!issue) { + issue = await this.createEntryIssue(collectionName, slug, entryTitle); + } + const commentId = await this.createIssueComment(issue.number, note); + return { + commentId, + issueUrl: issue.html_url, + }; + } catch (error) { + console.error('Failed to add note to entry:', error); + throw new APIError('Failed to create note', error.status || 500, API_NAME); + } + } + + async updateEntryNote(noteId: string, note: Note): Promise { + try { + await this.updateIssueComment(noteId, note); + } catch (error) { + console.error('Failed to update entry note:', error); + throw new APIError('Failed to update note', error.status || 500, API_NAME); + } + } + + async deleteEntryNote(noteId: string): Promise { + try { + await this.deleteIssueComment(noteId); + } catch (error) { + console.error('Failed to delete entry note:', error); + throw new APIError('Failed to delete note', error.status || 500, API_NAME); + } + } + + /** + * Get all entries that have notes (useful for showing notes indicator in UI) + */ + async getEntriesWithNotes(): Promise< + Array<{ collection: string; slug: string; noteCount: number }> + > { + try { + const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} state:open`; + const searchResponse = await this.request('/search/issues', { + params: { q: searchQuery, per_page: 100 }, + }); + + const entriesWithNotes = []; + + for (const issue of searchResponse.items || []) { + // Extract collection/slug from issue body + const match = issue.body.match(/entry: `(.+)\/(.+)`/); + if (match) { + const [, collection, slug] = match; + entriesWithNotes.push({ + collection, + slug, + noteCount: issue.comments, + }); + } + } + + return entriesWithNotes; + } catch (error) { + console.error('Failed to get entries with notes:', error); + return []; + } + } + + /** + * Close notes issue when entry is deleted + */ + async closeEntryNotesIssue(collectionName: string, slug: string): Promise { + try { + const searchQuery = `repo:${this.repo} label:${API.NOTES_LABEL} "${collectionName}/${slug}" in:body state:open`; + const searchResponse = await this.request('/search/issues', { + params: { q: searchQuery }, + }); + + if (searchResponse.items && searchResponse.items.length > 0) { + const issue = searchResponse.items[0]; + await this.request(`${this.repoURL}/issues/${issue.number}`, { + method: 'PATCH', + body: JSON.stringify({ + state: 'closed', + labels: [...(issue.labels || []).map((l: { name: string }) => l.name), 'entry-deleted'], + }), + }); + } + } catch (error) { + console.warn('Failed to close notes issue:', error); + } + } + + /** + * Get PR metadata from branch name + */ + async getPRMetadataFromBranch(branchName: string): Promise<{ + id: string; + url: string; + author: string; + createdAt: string; + } | null> { + try { + const response: GitHubPull[] = await this.request(`${this.originRepoURL}/pulls`, { + params: { + head: await this.getHeadReference(branchName), + state: 'open', + }, + }); + + const pr = response[0]; + if (!pr) return null; + + return { + id: pr.number.toString(), + url: pr.html_url, + author: pr.user?.login || 'unknown', + createdAt: pr.created_at, + }; + } catch (error) { + console.error('Failed to get PR metadata:', error); + return null; + } + } } diff --git a/packages/decap-cms-backend-github/src/__tests__/implementation.spec.js b/packages/decap-cms-backend-github/src/__tests__/implementation.spec.js index 85e649966f81..df8122d69736 100644 --- a/packages/decap-cms-backend-github/src/__tests__/implementation.spec.js +++ b/packages/decap-cms-backend-github/src/__tests__/implementation.spec.js @@ -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, @@ -358,4 +369,480 @@ 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({ + commentId: 'comment-123', + issueUrl: 'https://github.com/owner/repo/issues/1', + }); + 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({ + commentId: 'comment-123', + issueUrl: 'https://github.com/owner/repo/issues/1', + }); + 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'); + }); + }); + describe('notes polling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start notes polling', async () => { + const gitHubImplementation = new GitHubImplementation(config); + gitHubImplementation.pollingManager = { + watchIssueWithRetry: jest.fn().mockResolvedValue(() => {}), + getStatus: jest.fn().mockReturnValue({ currentWatch: null }), + }; + + const callbacks = { + onUpdate: jest.fn(), + onChange: jest.fn(), + }; + + await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks); + + expect(gitHubImplementation.pollingManager.watchIssueWithRetry).toHaveBeenCalledWith( + 'posts', + 'my-post', + callbacks, + 5, + 2000, + ); + }); + + it('should not start polling if already watching same entry', async () => { + const gitHubImplementation = new GitHubImplementation(config); + gitHubImplementation.pollingManager = { + watchIssueWithRetry: jest.fn().mockResolvedValue(() => {}), + getStatus: jest.fn().mockReturnValue({ currentWatch: 'posts/my-post' }), + }; + + const callbacks = { onUpdate: jest.fn() }; + + await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks); + + expect(gitHubImplementation.pollingManager.watchIssueWithRetry).not.toHaveBeenCalled(); + }); + + it('should stop notes polling', async () => { + const gitHubImplementation = new GitHubImplementation(config); + const unwatchFn = jest.fn(); + gitHubImplementation.unwatchFunctions.set('posts/my-post', unwatchFn); + + await gitHubImplementation.stopNotesPolling('posts', 'my-post'); + + expect(unwatchFn).toHaveBeenCalledTimes(1); + expect(gitHubImplementation.unwatchFunctions.has('posts/my-post')).toBe(false); + }); + + it('should handle stopping polling when not active', async () => { + const gitHubImplementation = new GitHubImplementation(config); + + await expect( + gitHubImplementation.stopNotesPolling('posts', 'my-post'), + ).resolves.not.toThrow(); + }); + + it('should refresh notes immediately', async () => { + const gitHubImplementation = new GitHubImplementation(config); + gitHubImplementation.pollingManager = { + checkIssueNow: jest.fn().mockResolvedValue(undefined), + }; + + await gitHubImplementation.refreshNotesNow('posts', 'my-post'); + + expect(gitHubImplementation.pollingManager.checkIssueNow).toHaveBeenCalledWith( + 'posts', + 'my-post', + ); + }); + + it('should throw error when refreshing without polling manager', async () => { + const gitHubImplementation = new GitHubImplementation(config); + gitHubImplementation.pollingManager = undefined; + + await expect(gitHubImplementation.refreshNotesNow('posts', 'my-post')).rejects.toThrow( + 'Polling manager not initialized', + ); + }); + it('should start polling and store unwatch function', async () => { + const gitHubImplementation = new GitHubImplementation(config); + const unwatchFn = jest.fn(); + + gitHubImplementation.pollingManager = { + watchIssueWithRetry: jest.fn().mockResolvedValue(unwatchFn), + getStatus: jest.fn().mockReturnValue({ currentWatch: null }), + }; + + const callbacks = { + onUpdate: jest.fn(), + onChange: jest.fn(), + }; + + await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks); + + expect(gitHubImplementation.pollingManager.watchIssueWithRetry).toHaveBeenCalledWith( + 'posts', + 'my-post', + callbacks, + 5, + 2000, + ); + expect(gitHubImplementation.unwatchFunctions.get('posts/my-post')).toBe(unwatchFn); + }); + + it('should stop existing polling before starting new one', async () => { + const gitHubImplementation = new GitHubImplementation(config); + const oldUnwatchFn = jest.fn(); + const newUnwatchFn = jest.fn(); + + gitHubImplementation.unwatchFunctions.set('posts/my-post', oldUnwatchFn); + gitHubImplementation.pollingManager = { + watchIssueWithRetry: jest.fn().mockResolvedValue(newUnwatchFn), + getStatus: jest.fn().mockReturnValue({ currentWatch: 'posts/other-post' }), + }; + + const callbacks = { onUpdate: jest.fn() }; + + await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks); + + expect(oldUnwatchFn).toHaveBeenCalledTimes(1); + expect(gitHubImplementation.unwatchFunctions.get('posts/my-post')).toBe(newUnwatchFn); + }); + + it('should handle polling manager error gracefully', async () => { + const gitHubImplementation = new GitHubImplementation(config); + + gitHubImplementation.pollingManager = { + watchIssueWithRetry: jest.fn().mockRejectedValue(new Error('Failed to find issue')), + getStatus: jest.fn().mockReturnValue({ currentWatch: null }), + }; + + const callbacks = { onUpdate: jest.fn() }; + + await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks); + + // Should not throw, just log error + expect(console.error).toHaveBeenCalledWith( + '[DecapNotes Polling] Failed to start polling after retries:', + expect.any(Error), + ); + }); + }); + }); }); diff --git a/packages/decap-cms-backend-github/src/implementation.tsx b/packages/decap-cms-backend-github/src/implementation.tsx index 1c3f852ad700..f23db7d9adb4 100644 --- a/packages/decap-cms-backend-github/src/implementation.tsx +++ b/packages/decap-cms-backend-github/src/implementation.tsx @@ -24,6 +24,7 @@ import { import AuthenticationPage from './AuthenticationPage'; import API, { API_NAME } from './API'; +import { ETagPollingManager } from './polling'; import GraphQLAPI from './GraphQLAPI'; import type { Endpoints } from '@octokit/types'; @@ -39,6 +40,8 @@ import type { ImplementationFile, UnpublishedEntryMediaFile, Entry, + Note, + IssueChange, } from 'decap-cms-lib-util'; import type { Semaphore } from 'semaphore'; @@ -90,6 +93,8 @@ export default class GitHub implements Implementation { [key: string]: Promise; }; _mediaDisplayURLSem?: Semaphore; + pollingManager?: ETagPollingManager; + unwatchFunctions: Map void> = new Map(); constructor(config: Config, options = {}) { this.options = { @@ -381,12 +386,22 @@ export default class GitHub implements Implementation { // } // } + if (this.api && !this.pollingManager) { + this.pollingManager = new ETagPollingManager(this.api, 15000); + } // Authorized user return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring }; } logout() { this.token = null; + // Clean up polling + if (this.pollingManager) { + this.pollingManager.destroy(); + this.pollingManager = undefined; + } + this.unwatchFunctions.clear(); + if (this.api && this.api.reset && typeof this.api.reset === 'function') { return this.api.reset(); } @@ -710,7 +725,15 @@ export default class GitHub implements Implementation { // deleteUnpublishedEntry is a transactional operation return runWithLock( this.lock, - () => this.api!.deleteUnpublishedEntry(collection, slug), + async () => { + await this.api!.deleteUnpublishedEntry(collection, slug); + // Clean up associated notes issue + try { + await this.api!.closeEntryNotesIssue(collection, slug); + } catch (error) { + console.warn('Failed to close notes issue during entry deletion:', error); + } + }, 'Failed to acquire delete entry lock', ); } @@ -719,8 +742,186 @@ export default class GitHub implements Implementation { // publishUnpublishedEntry is a transactional operation return runWithLock( this.lock, - () => this.api!.publishUnpublishedEntry(collection, slug), + async () => { + this.api!.publishUnpublishedEntry(collection, slug), + await this.api!.closeIssueOnPublish(collection, slug); + }, 'Failed to acquire publish entry lock', ); } + + // Notes implementation, which is an abstraction to Github's PR issue comments. + + // Notes implementation using GitHub Issues + async getNotes(collection: string, slug: string): Promise { + try { + const notes = await this.api!.getEntryNotes(collection, slug); + return notes.map(note => ({ ...note, entrySlug: slug })); + } catch (error) { + console.error('Failed to get notes:', error); + return []; + } + } + + async addNote(collection: string, slug: string, noteData: Omit): Promise { + const currentUser = await this.currentUser({ token: this.token! }); + + const note: Note = { + ...noteData, + id: 'temp-' + Date.now(), + author: currentUser.login || currentUser.name || '', + avatarUrl: currentUser.avatar_url, + entrySlug: slug, + timestamp: noteData.timestamp || new Date().toISOString(), + resolved: noteData.resolved || false, + issueUrl: undefined, + }; + + // Get entry title for better issue naming + let entryTitle: string | undefined; + try { + const entryData = await this.getEntry(`${collection}/${slug}.md`); + const titleMatch = entryData.data.match(/^title:\s*["']?([^"'\n]+)["']?/m); + entryTitle = titleMatch ? titleMatch[1] : undefined; + } catch (error) { + // Entry not found or error reading, use undefined title + } + + const { commentId, issueUrl } = await this.api!.addNoteToEntry( + collection, + slug, + note, + entryTitle, + ); + + return { + ...note, + id: commentId, + issueUrl, + }; + } + + async updateNote( + collection: string, + slug: string, + noteId: string, + updates: Partial, + ): Promise { + const currentNotes = await this.getNotes(collection, slug); + const existingNote = currentNotes.find(note => note.id === noteId); + if (!existingNote) { + throw new Error(`Note with ID ${noteId} not found`); + } + + const updatedNote: Note = { + ...existingNote, + ...updates, + id: noteId, + entrySlug: slug, + }; + + await this.api!.updateEntryNote(noteId, updatedNote); + return updatedNote; + } + + async deleteNote(collection: string, slug: string, noteId: string): Promise { + const currentNotes = await this.getNotes(collection, slug); + const noteExists = currentNotes.some(note => note.id === noteId); + if (!noteExists) { + throw new Error(`Note with ID ${noteId} not found`); + } + + await this.api!.deleteEntryNote(noteId); + } + + async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise { + const currentNotes = await this.getNotes(collection, slug); + const note = currentNotes.find(n => n.id === noteId); + if (!note) { + throw new Error(`Note with ID ${noteId} not found`); + } + + return this.updateNote(collection, slug, noteId, { + resolved: !note.resolved, + }); + } + + async reopenIssueForUnpublishedEntry(collection: string, slug: string) { + await this.api!.reopenIssueOnUnpublish(collection, slug); + } + /** + * Start watching notes for changes + * Called from Redux action + */ + async startNotesPolling( + collection: string, + slug: string, + callbacks: { + onUpdate: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + }, + ): Promise { + if (!this.pollingManager) { + console.warn('[DecapNotes Polling] Polling manager not initialized'); + return; + } + + const issueKey = `${collection}/${slug}`; + + // Check if already watching this exact entry - if so, skip + if (this.pollingManager.getStatus().currentWatch === issueKey) { + return; + } + + // First, ensure any previous polling for this entry is completely stopped + const existingUnwatch = this.unwatchFunctions.get(issueKey); + if (existingUnwatch) { + existingUnwatch(); + this.unwatchFunctions.delete(issueKey); + } + + try { + const unwatchFn = await this.pollingManager.watchIssueWithRetry( + collection, + slug, + callbacks, + 5, // maxRetries - will try up to 5 times + 2000, // retryDelay - 2 seconds between attempts + ); + + // Store the new unwatch function + this.unwatchFunctions.set(issueKey, unwatchFn); + } catch (error) { + console.error('[DecapNotes Polling] Failed to start polling after retries:', error); + } + } + + /** + * Stop watching notes for changes + * Called from Redux action: dispatch(stopNotesPolling(collection, slug)) + * + * Ensures complete cleanup of polling for this entry + */ + async stopNotesPolling(collection: string, slug: string): Promise { + const issueKey = `${collection}/${slug}`; + const unwatchFn = this.unwatchFunctions.get(issueKey); + + if (unwatchFn) { + unwatchFn(); + this.unwatchFunctions.delete(issueKey); + } else { + console.log(`[DecapNotes Polling] No active polling found for ${issueKey}`); + } + } + + /** + * Manually refresh notes (force check now) + * Called from Redux action: dispatch(refreshNotesNow(collection, slug)) + */ + async refreshNotesNow(collection: string, slug: string): Promise { + if (!this.pollingManager) { + throw new Error('Polling manager not initialized'); + } + await this.pollingManager.checkIssueNow(collection, slug); + } } diff --git a/packages/decap-cms-backend-github/src/polling.ts b/packages/decap-cms-backend-github/src/polling.ts new file mode 100644 index 000000000000..aea1c59f9a0c --- /dev/null +++ b/packages/decap-cms-backend-github/src/polling.ts @@ -0,0 +1,472 @@ +/** + * GitHub Notes Polling System + * + * ETag-based polling manager that efficiently checks for changes in GitHub Issues + * Used for a real-time feel of notes updates without excessive API calls leveraging conditional requests (304) that don't count on Github rate limits. + * + * @module polling + */ + +import type { Note, IssueState, CommentData, IssueChange } from 'decap-cms-lib-util'; + +interface WatchedIssue { + issueNumber: number; + collection: string; + slug: string; + etag: string | null; + lastState: IssueState | null; + onUpdate?: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + retryCount?: number; + maxRetries?: number; +} + +export interface GitHubNotesAPI { + getIssueState(issueNumber: number): Promise; + getIssueWithETag( + issueNumber: number, + etag: string | null, + ): Promise< + | { status: 304; data?: never; etag?: never } + | { status: 200; data: IssueState; etag: string | null } + >; + parseCommentToNote(comment: CommentData): Note; + findEntryIssue(collection: string, slug: string): Promise<{ number: number } | null>; +} + +// Redux action types +export const NOTES_POLLING_START = 'NOTES_POLLING_START'; +export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP'; +export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE'; +export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED'; + +export class ETagPollingManager { + private currentWatch: WatchedIssue | null = null; + private currentIssueKey: string | null = null; + private pollingInterval = 15000; + private intervalId: NodeJS.Timeout | null = null; + private isDocumentVisible = true; + private api: GitHubNotesAPI; + private isPolling = false; + private pendingRetryTimeout: NodeJS.Timeout | null = null; + + constructor(api: GitHubNotesAPI, pollingInterval = 15000) { + this.api = api; + this.pollingInterval = pollingInterval; + this.setupVisibilityListener(); + } + + /** + * Setup Page Visibility API listener + * Pauses polling when tab is hidden + */ + private setupVisibilityListener() { + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + this.isDocumentVisible = !document.hidden; + + if (this.isDocumentVisible) { + this.startPolling(); + this.checkAllIssuesNow(); + } else { + this.stopPolling(); + } + }); + } + } + + /** + * Start watching an issue for changes + * This will automatically stop watching any previously watched issue + * + * @param issueNumber - GitHub issue number + * @param collection - Collection name + * @param slug - Entry slug + * @returns Function to stop watching + */ + async watchIssue( + issueNumber: number, + collection: string, + slug: string, + callbacks: { + onUpdate?: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + }, + initialState: IssueState | null = null, + ): Promise<() => void> { + const issueKey = this.getIssueKey(collection, slug); + + // STOP ANY EXISTING WATCH FIRST + if (this.currentWatch) { + this.stopCurrentWatch(); + } + + // Get initial state if not provided + if (!initialState) { + try { + initialState = await this.api.getIssueState(issueNumber); + } catch (error) { + console.error('[DecapNotes Polling] Failed to get initial state:', error); + } + } + + this.currentWatch = { + issueNumber, + collection, + slug, + etag: null, + lastState: initialState, + onUpdate: callbacks.onUpdate, + onChange: callbacks.onChange, + retryCount: 0, + maxRetries: 5, + }; + + this.currentIssueKey = issueKey; + + // Start polling if not already running + if (!this.intervalId && this.isDocumentVisible) { + this.startPolling(); + } + + // Do an immediate check + this.checkCurrentIssue(); + + // Return unwatch function + return () => this.stopCurrentWatch(); + } + + /** + * Watch issue with retry logic for newly created issues + */ + async watchIssueWithRetry( + collection: string, + slug: string, + callbacks: { + onUpdate?: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + }, + maxRetries = 5, + retryDelay = 2000, + ): Promise<() => void> { + const issueKey = this.getIssueKey(collection, slug); + + // STOP ANY EXISTING WATCH FIRST + if (this.currentWatch) { + this.stopCurrentWatch(); + } + + const attemptWatch = async (attempt: number): Promise<() => void> => { + try { + const issue = await this.api.findEntryIssue(collection, slug); + + if (issue) { + return await this.watchIssue(issue.number, collection, slug, callbacks); + } + + if (attempt < maxRetries) { + return new Promise((resolve, reject) => { + this.pendingRetryTimeout = setTimeout(async () => { + this.pendingRetryTimeout = null; + try { + const unwatchFn = await attemptWatch(attempt + 1); + resolve(unwatchFn); + } catch (error) { + reject(error); + } + }, retryDelay); + }); + } + + console.log( + `[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`, + ); + // Return a no-op unwatch function + return () => { + /* no-op */ + }; + } catch (error) { + console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error); + + if (attempt < maxRetries) { + return new Promise((resolve, reject) => { + this.pendingRetryTimeout = setTimeout(async () => { + this.pendingRetryTimeout = null; + try { + const unwatchFn = await attemptWatch(attempt + 1); + resolve(unwatchFn); + } catch (err) { + reject(err); + } + }, retryDelay); + }); + } + + throw error; + } + }; + + return attemptWatch(1); + } + + /** + * Stop watching the current issue - complete cleanup + */ + private stopCurrentWatch() { + if (!this.currentWatch) { + return; + } + + // Clear any pending retry timeout + if (this.pendingRetryTimeout) { + clearTimeout(this.pendingRetryTimeout); + this.pendingRetryTimeout = null; + } + + // Clear current watch + this.currentWatch = null; + this.currentIssueKey = null; + + // Stop polling since there's nothing to watch + this.stopPolling(); + } + + /** + * Start the polling loop + */ + private startPolling() { + if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return; + + console.log( + `[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`, + ); + + this.intervalId = setInterval(() => { + this.pollAllIssues(); + }, this.pollingInterval); + } + + /** + * Stop the polling loop + */ + private stopPolling() { + if (this.intervalId) { + console.log('[DecapNotes Polling] Stopping polling loop'); + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /** + * Poll current watched issue + */ + private async pollAllIssues() { + await this.checkCurrentIssue(); + } + + /** + * Check current issue for changes using ETag + */ + private async checkCurrentIssue() { + if (!this.currentWatch) { + return; + } + + if (this.isPolling) { + return; + } + + this.isPolling = true; + + try { + const watch = this.currentWatch; + + const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag); + + if (response.status === 304) { + return; + } + + if (response.status === 200) { + const newState: IssueState = response.data; + const newETag = response.etag; + + // Update ETag + watch.etag = newETag || null; + + // Detect specific changes + const changes = this.detectChanges(watch.lastState, newState); + + if (changes.length > 0) { + // Convert comments to notes + const newNotes = newState.comments.map(comment => ({ + ...this.api.parseCommentToNote(comment), + issueUrl: newState.html_url, + })); + + if (watch.onUpdate) { + watch.onUpdate(newNotes, changes); + } + + if (watch.onChange) { + changes.forEach(change => { + watch.onChange!(change); + }); + } + } + + // Update stored state + watch.lastState = newState; + } + } catch (error) { + if (error && typeof error === 'object' && 'status' in error && error.status !== 304) { + console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error); + } + } finally { + this.isPolling = false; + } + } + + /** + * Immediately check current issue + */ + private async checkAllIssuesNow() { + await this.checkCurrentIssue(); + } + + /** + * Manually trigger a check - only works if this is the current entry + */ + async checkIssueNow(collection: string, slug: string) { + const issueKey = this.getIssueKey(collection, slug); + + if (this.currentIssueKey !== issueKey) { + console.warn( + `[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`, + ); + return; + } + + await this.checkCurrentIssue(); + } + + /** + * Detect what changed between two states + */ + private detectChanges(previous: IssueState | null, current: IssueState): IssueChange[] { + if (!previous) { + return []; + } + + const changes: IssueChange[] = []; + + // New comments + const newComments = current.comments.filter( + comment => !previous.comments.some(prev => prev.id === comment.id), + ); + newComments.forEach(comment => { + changes.push({ + type: 'comment_added', + data: comment, + timestamp: comment.created_at, + }); + }); + + // Updated comments + current.comments.forEach(comment => { + const prevComment = previous.comments.find(prev => prev.id === comment.id); + if (prevComment && prevComment.updated_at !== comment.updated_at) { + changes.push({ + type: 'comment_updated', + data: comment, + previousData: prevComment, + timestamp: comment.updated_at, + }); + } + }); + + // Deleted comments + const deletedComments = previous.comments.filter( + prevComment => !current.comments.some(comment => comment.id === prevComment.id), + ); + deletedComments.forEach(comment => { + changes.push({ + type: 'comment_deleted', + data: comment, + timestamp: new Date().toISOString(), + }); + }); + + // Issue state changed + if (previous.state !== current.state) { + changes.push({ + type: 'issue_state_changed', + data: { from: previous.state, to: current.state }, + timestamp: current.updated_at, + }); + } + + // Labels changed + if (this.hasLabelsChanged(previous.labels, current.labels)) { + changes.push({ + type: 'issue_labels_changed', + data: { from: previous.labels, to: current.labels }, + timestamp: current.updated_at, + }); + } + + return changes; + } + + /** + * Check if labels changed + */ + private hasLabelsChanged( + previous: Array<{ name: string }>, + current: Array<{ name: string }>, + ): boolean { + if (previous.length !== current.length) return true; + const prevNames = previous.map(l => l.name).sort(); + const currNames = current.map(l => l.name).sort(); + return prevNames.join(',') !== currNames.join(','); + } + + /** + * Get issue key for storage + */ + private getIssueKey(collection: string, slug: string): string { + return `${collection}/${slug}`; + } + + /** + * Get polling status + */ + getStatus() { + return { + isPolling: this.intervalId !== null, + currentWatch: this.currentIssueKey, + watchedCount: this.currentWatch ? 1 : 0, + pollingInterval: this.pollingInterval, + isDocumentVisible: this.isDocumentVisible, + hasPendingRetry: this.pendingRetryTimeout !== null, + }; + } + + /** + * Clean up - stop all polling + */ + destroy() { + console.log('[DecapNotes Polling] Destroying polling manager'); + + // Clear pending retry + if (this.pendingRetryTimeout) { + clearTimeout(this.pendingRetryTimeout); + this.pendingRetryTimeout = null; + } + + // Stop current watch + this.stopCurrentWatch(); + } +} + +export default ETagPollingManager; diff --git a/packages/decap-cms-backend-proxy/src/implementation.ts b/packages/decap-cms-backend-proxy/src/implementation.ts index 710dda44476d..ae2f9a5e94ec 100644 --- a/packages/decap-cms-backend-proxy/src/implementation.ts +++ b/packages/decap-cms-backend-proxy/src/implementation.ts @@ -16,6 +16,7 @@ import type { Implementation, ImplementationFile, UnpublishedEntry, + Note, } from 'decap-cms-lib-util'; async function serializeAsset(assetProxy: AssetProxy) { @@ -247,6 +248,97 @@ export default class ProxyBackend implements Implementation { return deserializeMediaFile(file); } + async getNotes(collection: string, slug: string): Promise { + try { + const response = await this.request({ + action: 'getNotes', + params: { + branch: this.branch, + collection, + slug + }, + }); + return response.notes || []; + } catch (error) { + console.warn('Failed to get notes:', error); + return []; + } + } + + async addNote(collection: string, slug: string, note: Omit): Promise { + const response = await this.request({ + action: 'addNote', + params: { + branch: this.branch, + collection, + slug, + note + }, + }); + return response.note; + } + + async updateNote(collection: string, slug: string, noteId: string, updates: Partial): Promise { + const response = await this.request({ + action: 'updateNote', + params: { + branch: this.branch, + collection, + slug, + noteId, + updates + }, + }); + return response.note; + } + + async deleteNote(collection: string, slug: string, noteId: string): Promise { + await this.request({ + action: 'deleteNote', + params: { + branch: this.branch, + collection, + slug, + noteId + }, + }); + } + + async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise { + const response = await this.request({ + action: 'toggleNoteResolution', + params: { + branch: this.branch, + collection, + slug, + noteId + }, + }); + return response.note; + } + + async getPRMetadata(collection: string, slug: string): Promise<{ + id: string; + url: string; + author: string; + createdAt: string; + } | null> { + try { + const response = await this.request({ + action: 'getPRMetadata', + params: { + branch: this.branch, + collection, + slug + }, + }); + return response.metadata || null; + } catch (error) { + console.warn('Failed to get PR metadata:', error); + return null; + } + } + deleteFiles(paths: string[], commitMessage: string) { return this.request({ action: 'deleteFiles', diff --git a/packages/decap-cms-backend-test/src/implementation.ts b/packages/decap-cms-backend-test/src/implementation.ts index 62548eb42ded..1dbbdbddc043 100644 --- a/packages/decap-cms-backend-test/src/implementation.ts +++ b/packages/decap-cms-backend-test/src/implementation.ts @@ -16,6 +16,7 @@ import AuthenticationPage from './AuthenticationPage'; import type { Implementation, + Note, Entry, ImplementationEntry, AssetProxy, @@ -50,11 +51,13 @@ declare global { interface Window { repoFiles: RepoTree; repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry }; + repoNotes: { [key: string]: Note[] }; } } window.repoFiles = window.repoFiles || {}; window.repoFilesUnpublished = window.repoFilesUnpublished || []; +window.repoNotes = window.repoNotes || {}; function getFile(path: string, tree: RepoTree) { const segments = path.split('/'); @@ -394,6 +397,84 @@ export default class TestBackend implements Implementation { }; } + async getNotes(collection: string, slug: string): Promise { + const key = `${collection}/${slug}`; + return window.repoNotes[key] || []; + } + + async addNote(collection: string, slug: string, note: Omit): Promise { + const key = `${collection}/${slug}`; + const newNote: Note = { + ...note, + id: uuid(), + timestamp: new Date().toISOString(), + }; + + if (!window.repoNotes[key]) { + window.repoNotes[key] = []; + } + + window.repoNotes[key].push(newNote); + return newNote; + } + + async updateNote( + collection: string, + slug: string, + noteId: string, + updates: Partial, + ): Promise { + const key = `${collection}/${slug}`; + const notes = window.repoNotes[key] || []; + const noteIndex = notes.findIndex(note => note.id === noteId); + + if (noteIndex === -1) { + throw new Error(`Note with id ${noteId} not found`); + } + + const updatedNote = { + ...notes[noteIndex], + ...updates, + updatedAt: new Date().toISOString(), + }; + + window.repoNotes[key][noteIndex] = updatedNote; + return updatedNote; + } + + async deleteNote(collection: string, slug: string, noteId: string): Promise { + const key = `${collection}/${slug}`; + const notes = window.repoNotes[key] || []; + const noteIndex = notes.findIndex(note => note.id === noteId); + + if (noteIndex === -1) { + throw new Error(`Note with id ${noteId} not found`); + } + + window.repoNotes[key].splice(noteIndex, 1); + } + + async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise { + const key = `${collection}/${slug}`; + const notes = window.repoNotes[key] || []; + const note = notes.find(note => note.id === noteId); + + if (!note) { + throw new Error(`Note with id ${noteId} not found`); + } + + const updatedNote = { + ...note, + resolved: !note.resolved, + updatedAt: new Date().toISOString(), + }; + + const noteIndex = notes.findIndex(n => n.id === noteId); + window.repoNotes[key][noteIndex] = updatedNote; + + return updatedNote; + } + normalizeAsset(assetProxy: AssetProxy) { const fileObj = assetProxy.fileObj as File; const { name, size } = fileObj; diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index f29c08c4e2e6..43866a82b49e 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -416,6 +416,7 @@ declare module 'decap-cms-core' { i18n?: CmsI18nConfig; local_backend?: boolean | CmsLocalBackend; editor?: { + notes?: boolean; preview?: boolean; }; } diff --git a/packages/decap-cms-core/src/actions/__tests__/config.spec.js b/packages/decap-cms-core/src/actions/__tests__/config.spec.js index 16b64e8aa803..459c3469a9b4 100644 --- a/packages/decap-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/decap-cms-core/src/actions/__tests__/config.spec.js @@ -406,6 +406,7 @@ describe('config', () => { it('should set editor preview honoring global config before and specific config after', () => { const config = applyDefaults({ editor: { + notes: false, preview: false, }, collections: [ @@ -415,6 +416,7 @@ describe('config', () => { }, { editor: { + notes: false, preview: true, }, fields: [{ name: 'title' }], @@ -486,6 +488,10 @@ describe('config', () => { view_filters: [], view_groups: [], identifier_field: 'datetime', + editor: { + notes: false, + preview: true, + }, fields: [ { name: 'datetime', @@ -507,6 +513,10 @@ describe('config', () => { }, { sortable_fields: [], + editor: { + notes: false, + preview: true, + }, files: [ { name: 'file', @@ -537,6 +547,10 @@ describe('config', () => { publish: true, }, ], + editor: { + notes: false, + preview: true, + }, public_folder: '/', publish_mode: 'simple', slug: { clean_accents: false, encoding: 'unicode', sanitize_replacement: '-' }, @@ -947,6 +961,10 @@ describe('config', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_SUCCESS', payload: { + editor: { + notes: false, + preview: true, + }, backend: { repo: 'test-repo' }, collections: [], publish_mode: 'simple', @@ -981,6 +999,10 @@ describe('config', () => { type: 'CONFIG_SUCCESS', payload: { backend: { repo: 'github' }, + editor: { + notes: false, + preview: true, + }, collections: [], publish_mode: 'simple', slug: { encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }, diff --git a/packages/decap-cms-core/src/actions/__tests__/editorialWorkflow.spec.js b/packages/decap-cms-core/src/actions/__tests__/editorialWorkflow.spec.js index f03069a4907c..1a19e6617355 100644 --- a/packages/decap-cms-core/src/actions/__tests__/editorialWorkflow.spec.js +++ b/packages/decap-cms-core/src/actions/__tests__/editorialWorkflow.spec.js @@ -32,7 +32,9 @@ describe('editorialWorkflow actions', () => { }; const store = mockStore({ - config: fromJS({}), + config: fromJS({ + editor: { notes: true }, + }), collections: fromJS({ posts: { name: 'posts' }, }), @@ -79,7 +81,7 @@ describe('editorialWorkflow actions', () => { }); describe('publishUnpublishedEntry', () => { - it('should publish unpublished entry and report success', () => { + it('should publish unpublished entry and report success', async () => { const { currentBackend } = require('../../backend'); const entry = {}; @@ -87,6 +89,7 @@ describe('editorialWorkflow actions', () => { publishUnpublishedEntry: jest.fn().mockResolvedValue(), getEntry: jest.fn().mockResolvedValue(entry), getMedia: jest.fn().mockResolvedValue([]), + getNotes: jest.fn().mockResolvedValue([]), }; const store = mockStore({ diff --git a/packages/decap-cms-core/src/actions/config.ts b/packages/decap-cms-core/src/actions/config.ts index fd7028760387..beadc8eb522c 100644 --- a/packages/decap-cms-core/src/actions/config.ts +++ b/packages/decap-cms-core/src/actions/config.ts @@ -234,6 +234,17 @@ export function applyDefaults(originalConfig: CmsConfig) { config.slug = config.slug || {}; config.collections = config.collections || []; + if (!config.editor) { + config.editor = {}; + } + + if (!('preview' in config.editor)) { + config.editor.preview = true; + } + if (!('notes' in config.editor)) { + config.editor.notes = false; + } + // Use `site_url` as default `display_url`. if (!config.display_url && config.site_url) { config.display_url = config.site_url; @@ -379,7 +390,10 @@ export function applyDefaults(originalConfig: CmsConfig) { }); if (config.editor && !collection.editor) { - collection.editor = { preview: config.editor.preview }; + collection.editor = { + preview: config.editor.preview, + notes: config.editor.notes, + }; } } }); diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index cb4a0349d04d..ee5b9d817fdd 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -541,6 +541,7 @@ export function unpublishPublishedEntry(collection: Collection, slug: string) { status: status.get('PENDING_PUBLISH'), }), ) + .then(() => backend.reopenIssueForUnpublishedEntry(collection.get('name'), slug)) .then(() => { dispatch(unpublishedEntryPersisted(collection, entry)); dispatch(entryDeleted(collection, slug)); diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index f9f52236d93c..78e5db57958c 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -22,7 +22,7 @@ import { getProcessSegment } from '../lib/formatters'; import { hasI18n, duplicateDefaultI18nFields, serializeI18n, I18N, I18N_FIELD } from '../lib/i18n'; import { addNotification } from './notifications'; -import type { ImplementationMediaFile } from 'decap-cms-lib-util'; +import type { ImplementationMediaFile, Note, IssueChange } from 'decap-cms-lib-util'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { @@ -86,6 +86,24 @@ export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE'; export const CHANGE_VIEW_STYLE = 'CHANGE_VIEW_STYLE'; +export const DRAFT_NOTES_LOAD = 'DRAFT_NOTES_LOAD'; +export const DRAFT_NOTE_ADD = 'DRAFT_NOTE_ADD'; +export const DRAFT_NOTE_UPDATE = 'DRAFT_NOTE_UPDATE'; +export const DRAFT_NOTE_DELETE = 'DRAFT_NOTE_DELETE'; +export const NOTES_REQUEST = 'NOTES_REQUEST'; +export const NOTES_SUCCESS = 'NOTES_SUCCESS'; +export const NOTES_FAILURE = 'NOTES_FAILURE'; +export const NOTE_PERSIST_REQUEST = 'NOTE_PERSIST_REQUEST'; +export const NOTE_PERSIST_SUCCESS = 'NOTE_PERSIST_SUCCESS'; +export const NOTE_PERSIST_FAILURE = 'NOTE_PERSIST_FAILURE'; +export const NOTE_DELETE_REQUEST = 'NOTE_DELETE_REQUEST'; +export const NOTE_DELETE_SUCCESS = 'NOTE_DELETE_SUCCESS'; +export const NOTE_DELETE_FAILURE = 'NOTE_DELETE_FAILURE'; +export const NOTES_POLLING_START = 'NOTES_POLLING_START'; +export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP'; +export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE'; +export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED'; + /* * Simple Action Creators (Internal) * We still need to export them for tests @@ -460,6 +478,22 @@ export function removeDraftEntryMediaFile({ id }: { id: string }) { return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } }; } +export function loadNotesForEntry(notes: Note[]) { + return { type: DRAFT_NOTES_LOAD, payload: { notes } }; +} + +export function addNote(note: Note) { + return { type: DRAFT_NOTE_ADD, payload: { note } }; +} + +export function updateNote(noteId: string, updates: Partial) { + return { type: DRAFT_NOTE_UPDATE, payload: { id: noteId, updates } }; +} + +export function deleteNote(noteId: string) { + return { type: DRAFT_NOTE_DELETE, payload: { id: noteId } }; +} + export function persistLocalBackup(entry: EntryMap, collection: Collection) { return (_dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); @@ -536,6 +570,11 @@ export function loadEntry(collection: Collection, slug: string) { const loadedEntry = await tryLoadEntry(getState(), collection, slug); dispatch(entryLoaded(collection, loadedEntry)); dispatch(createDraftFromEntry(loadedEntry)); + const state = getState(); + const isNotesEnabled = state.config.editor?.notes ?? false; + if (isNotesEnabled) { + await dispatch(loadNotes(collection, slug)); + } } catch (error) { dispatch( addNotification({ @@ -1003,6 +1042,414 @@ export function deleteEntry(collection: Collection, slug: string) { }); }; } +export function notesLoading(collection: Collection, slug: string) { + return { + type: NOTES_REQUEST, + payload: { + collection: collection.get('name'), + slug, + }, + }; +} + +export function notesLoaded(collection: Collection, slug: string, notes: Note[]) { + return { + type: NOTES_SUCCESS, + payload: { + collection: collection.get('name'), + slug, + notes, + }, + }; +} + +export function notesLoadError(error: Error, collection: Collection, slug: string) { + return { + type: NOTES_FAILURE, + payload: { + error, + collection: collection.get('name'), + slug, + }, + }; +} + +export function notePersisting(collection: Collection, slug: string, note: Note) { + return { + type: NOTE_PERSIST_REQUEST, + payload: { + collection: collection.get('name'), + slug, + noteId: note.id, + }, + }; +} + +export function notePersisted(collection: Collection, slug: string, note: Note) { + return { + type: NOTE_PERSIST_SUCCESS, + payload: { + collection: collection.get('name'), + slug, + note, + }, + }; +} + +export function notePersistFail(collection: Collection, slug: string, note: Note, error: Error) { + return { + type: NOTE_PERSIST_FAILURE, + error: 'Failed to persist note', + payload: { + collection: collection.get('name'), + slug, + noteId: note.id, + error: error.toString(), + }, + }; +} + +export function noteDeleting(collection: Collection, slug: string, noteId: string) { + return { + type: NOTE_DELETE_REQUEST, + payload: { + collection: collection.get('name'), + slug, + noteId, + }, + }; +} + +export function noteDeleted(collection: Collection, slug: string, noteId: string) { + return { + type: NOTE_DELETE_SUCCESS, + payload: { + collection: collection.get('name'), + slug, + noteId, + }, + }; +} + +export function noteDeleteFail(collection: Collection, slug: string, noteId: string, error: Error) { + return { + type: NOTE_DELETE_FAILURE, + payload: { + collection: collection.get('name'), + slug, + noteId, + error: error.toString(), + }, + }; +} + +export function loadNotes(collection: Collection, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + dispatch(notesLoading(collection, slug)); + try { + const state = getState(); + const backend = currentBackend(state.config); + const notes = await backend.getNotes(collection.get('name'), slug); + + // Set entrySlug for all notes + const notesWithSlug = notes.map(note => ({ ...note, entrySlug: slug })); + + dispatch(notesLoaded(collection, slug, notesWithSlug)); + dispatch(loadNotesForEntry(notesWithSlug)); + dispatch(startNotesPolling(collection, slug)); + } catch (error) { + dispatch(notesLoadError(error, collection, slug)); + dispatch(loadNotesForEntry([])); // Start with empty notes + } + }; +} + +export function pollingStarted(collection: Collection, slug: string) { + return { + type: NOTES_POLLING_START, + payload: { + collection: collection.get('name'), + slug, + }, + }; +} + +export function pollingStopped(collection: Collection, slug: string) { + return { + type: NOTES_POLLING_STOP, + payload: { + collection: collection.get('name'), + slug, + }, + }; +} + +export function notesUpdatedFromPolling( + collection: Collection, + slug: string, + notes: Note[], + changes: IssueChange[], +) { + return { + type: NOTES_POLLING_UPDATE, + payload: { + collection: collection.get('name'), + slug, + notes, + changes, + }, + }; +} + +export function changeDetected(collection: Collection, slug: string, change: IssueChange) { + return { + type: NOTES_CHANGE_DETECTED, + payload: { + collection: collection.get('name'), + slug, + change, + }, + }; +} + +export function startNotesPolling(collection: Collection, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + try { + const state = getState(); + const backend = currentBackend(state.config); + + if (!backend.startNotesPolling) { + console.warn('[DecapNotes Polling] Backend does not support notes polling'); + return; + } + + const callbacks = { + onUpdate: (notes: Note[], changes: IssueChange[]) => { + dispatch(notesUpdatedFromPolling(collection, slug, notes, changes)); + dispatch(loadNotesForEntry(notes)); + }, + onChange: (change: IssueChange) => { + // Dispatch action for individual change notifications + dispatch(changeDetected(collection, slug, change)); + }, + }; + + await backend.startNotesPolling(collection.get('name'), slug, callbacks); + + dispatch(pollingStarted(collection, slug)); + } catch (error) { + console.error('[DecapNotes Polling] Failed to start notes polling:', error); + } + }; +} +/** + * Stop watching notes for an entry + */ +export function stopNotesPolling(collection: Collection, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + try { + const state = getState(); + const backend = currentBackend(state.config); + + if (!backend.stopNotesPolling) { + return; + } + + await backend.stopNotesPolling(collection.get('name'), slug); + + dispatch(pollingStopped(collection, slug)); + } catch (error) { + console.error('[Redux] Failed to stop notes polling:', error); + } + }; +} + +/** + * Manually refresh notes + */ +export function refreshNotesNow(collection: Collection, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + try { + const state = getState(); + const backend = currentBackend(state.config); + + if (!backend.refreshNotesNow) { + return dispatch(loadNotes(collection, slug)); + } + + await backend.refreshNotesNow(collection.get('name'), slug); + } catch (error) { + console.error('[DecapNotes Polling] Failed to refresh notes:', error); + } + }; +} + +export function persistNote(collection: Collection, slug: string, note: Omit) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const backend = currentBackend(getState().config); + dispatch(notePersisting(collection, slug, note as Note)); + try { + const savedNote = await backend.addNote(collection.get('name'), slug, note); + dispatch(notePersisted(collection, slug, savedNote)); + dispatch(addNote(savedNote)); + dispatch( + addNotification({ + message: { + key: 'ui.toast.noteAdded', + }, + type: 'success', + dismissAfter: 4000, + }), + ); + // After adding a note, polling gets restarted. In case the note is the first ever note persisted (which is the moment a Github Issue is created for example) this will help the Polling system to pickup the source of the Notes. + const state = getState(); + const isNotesEnabled = state.config.editor?.notes ?? false; + + if (isNotesEnabled) { + // Small delay to let the issue creation propagate + await new Promise(resolve => setTimeout(resolve, 1000)); + await dispatch(startNotesPolling(collection, slug)); + } + return savedNote; + } catch (error) { + dispatch(notePersistFail(collection, slug, note as Note, error)); + dispatch( + addNotification({ + message: { + details: error.message, + key: 'ui.toast.onFailToAddNote', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + }; +} + +export function updateNotePersist( + collection: Collection, + slug: string, + noteId: string, + updates: Partial, +) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const backend = currentBackend(state.config); + + // Create a temporary note object for the persist actions + const tempNote = { id: noteId, ...updates } as Note; + dispatch(notePersisting(collection, slug, tempNote)); + + try { + const updatedNote = await backend.updateNote(collection.get('name'), slug, noteId, updates); + dispatch(notePersisted(collection, slug, updatedNote)); + dispatch(updateNote(noteId, updates)); + + dispatch( + addNotification({ + message: { + key: 'ui.toast.noteUpdated', + }, + type: 'success', + dismissAfter: 4000, + }), + ); + + return updatedNote; + } catch (error) { + dispatch(notePersistFail(collection, slug, tempNote, error)); + dispatch( + addNotification({ + message: { + details: error.message, + key: 'ui.toast.onFailToUpdateNote', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + }; +} + +export function deleteNotePersist(collection: Collection, slug: string, noteId: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const backend = currentBackend(state.config); + + dispatch(noteDeleting(collection, slug, noteId)); + + try { + await backend.deleteNote(collection.get('name'), slug, noteId); + dispatch(noteDeleted(collection, slug, noteId)); + dispatch(deleteNote(noteId)); + + dispatch( + addNotification({ + message: { + key: 'ui.toast.noteDeleted', + }, + type: 'success', + dismissAfter: 4000, + }), + ); + } catch (error) { + dispatch(noteDeleteFail(collection, slug, noteId, error)); + dispatch( + addNotification({ + message: { + details: error.message, + key: 'ui.toast.onFailToDeleteNote', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + }; +} + +export function toggleNoteResolutionPersist(collection: Collection, slug: string, noteId: string) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const backend = currentBackend(state.config); + + // Create a temporary note object for the persist actions + const tempNote = { id: noteId } as Note; + dispatch(notePersisting(collection, slug, tempNote)); + + try { + const updatedNote = await backend.toggleNoteResolution(collection.get('name'), slug, noteId); + dispatch(notePersisted(collection, slug, updatedNote)); + dispatch(updateNote(noteId, { resolved: updatedNote.resolved })); + + dispatch( + addNotification({ + message: { + key: updatedNote.resolved ? 'ui.toast.noteResolved' : 'ui.toast.noteReopened', + }, + type: 'success', + dismissAfter: 4000, + }), + ); + + return updatedNote; + } catch (error) { + dispatch(notePersistFail(collection, slug, tempNote, error)); + dispatch( + addNotification({ + message: { + details: error.message, + key: 'ui.toast.onFailToToggleNote', + }, + type: 'error', + dismissAfter: 8000, + }), + ); + } + }; +} function getPathError( path: string | undefined, diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts index c998d645041d..6a40cd05c1c4 100644 --- a/packages/decap-cms-core/src/backend.ts +++ b/packages/decap-cms-core/src/backend.ts @@ -81,6 +81,8 @@ import type { UnpublishedEntry, DataFile, UnpublishedEntryDiff, + Note, + IssueChange, } from 'decap-cms-lib-util'; import type { Map } from 'immutable'; @@ -295,6 +297,17 @@ interface ImplementationInitOptions { type Implementation = BackendImplementation & { init: (config: CmsConfig, options: ImplementationInitOptions) => Implementation; + + startNotesPolling?: ( + collection: string, + slug: string, + callbacks: { + onUpdate?: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + }, + ) => Promise; + stopNotesPolling?: (collection: string, slug: string) => Promise; + refreshNotesNow?: (collection: string, slug: string) => Promise; }; function prepareMetaPath(path: string, collection: Collection) { @@ -562,6 +575,7 @@ export class Backend { } else { throw new Error(`Unknown collection type: ${collectionType}`); } + const loadedEntries = await listMethod(); /* Wrap cursors so we can tell which collection the cursor is @@ -581,6 +595,106 @@ export class Backend { }; } + async getNotes(collection: string, slug: string): Promise { + if (typeof this.implementation.getNotes === 'function') { + return this.implementation.getNotes(collection, slug); + } + + // If backend doesn't support notes, return empty array + console.warn(`Backend '${this.backendName}' does not support notes`); + return []; + } + + async addNote(collection: string, slug: string, note: Omit): Promise { + if (typeof this.implementation.addNote === 'function') { + return this.implementation.addNote(collection, slug, note); + } + + throw new Error(`Backend '${this.backendName}' does not support adding notes`); + } + + async updateNote( + collection: string, + slug: string, + noteId: string, + updates: Partial, + ): Promise { + if (typeof this.implementation.updateNote === 'function') { + return this.implementation.updateNote(collection, slug, noteId, updates); + } + + throw new Error(`Backend '${this.backendName}' does not support updating notes`); + } + + async deleteNote(collection: string, slug: string, noteId: string): Promise { + if (typeof this.implementation.deleteNote === 'function') { + return this.implementation.deleteNote(collection, slug, noteId); + } + + throw new Error(`Backend '${this.backendName}' does not support deleting notes`); + } + + async toggleNoteResolution(collection: string, slug: string, noteId: string): Promise { + if (typeof this.implementation.toggleNoteResolution === 'function') { + return this.implementation.toggleNoteResolution(collection, slug, noteId); + } + + throw new Error(`Backend '${this.backendName}' does not support toggling note resolution`); + } + + async startNotesPolling( + collection: string, + slug: string, + callbacks: { + onUpdate?: (notes: Note[], changes: IssueChange[]) => void; + onChange?: (change: IssueChange) => void; + }, + ): Promise { + if (!this.implementation.startNotesPolling) { + console.warn('Backend does not support notes polling'); + return; + } + return this.implementation.startNotesPolling(collection, slug, callbacks); + } + + async stopNotesPolling(collection: string, slug: string): Promise { + if (typeof this.implementation.stopNotesPolling === 'function') { + return this.implementation.stopNotesPolling(collection, slug); + } + } + + async refreshNotesNow(collection: string, slug: string): Promise { + if (typeof this.implementation.refreshNotesNow === 'function') { + return this.implementation.refreshNotesNow(collection, slug); + } + // Fallback: just reload notes without polling + console.warn(`Backend '${this.backendName}' does not support manual refresh`); + } + + reopenIssueForUnpublishedEntry(collection: string, slug: string) { + if (typeof this.implementation.reopenIssueForUnpublishedEntry === 'function') { + return this.implementation.reopenIssueForUnpublishedEntry(collection, slug); + } + // If backend doesn't support this, silently skip + return Promise.resolve(); + } + + async getPRMetadata( + collection: string, + slug: string, + ): Promise<{ + id: string; + url: string; + author: string; + createdAt: string; + } | null> { + if (typeof this.implementation.getPRMetadata === 'function') { + return this.implementation.getPRMetadata(collection, slug); + } + + return null; + } + // The same as listEntries, except that if a cursor with the "next" // action available is returned, it calls "next" on the cursor and // repeats the process. Once there is no available "next" action, it diff --git a/packages/decap-cms-core/src/components/Editor/Editor.js b/packages/decap-cms-core/src/components/Editor/Editor.js index ac2a4f84fd78..f77452f8cdbc 100644 --- a/packages/decap-cms-core/src/components/Editor/Editor.js +++ b/packages/decap-cms-core/src/components/Editor/Editor.js @@ -22,6 +22,11 @@ import { loadLocalBackup, retrieveLocalBackup, deleteLocalBackup, + loadNotesForEntry, + loadNotes, + persistNote, + updateNotePersist, + deleteNotePersist, } from '../../actions/entries'; import { updateUnpublishedEntryStatus, @@ -47,6 +52,7 @@ export class Editor extends React.Component { entry: ImmutablePropTypes.map, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, + loadNotes: PropTypes.func, persistEntry: PropTypes.func.isRequired, deleteEntry: PropTypes.func.isRequired, showDelete: PropTypes.bool.isRequired, @@ -176,6 +182,15 @@ export class Editor extends React.Component { } } + if ( + prevProps.entry !== this.props.entry && + this.props.entry && + !this.props.newEntry && + this.props.hasWorkflow + ) { + this.props.loadNotes(this.props.collection, this.props.slug); + } + if (this.props.hasChanged) { this.createBackup(this.props.entryDraft.get('entry'), this.props.collection); } @@ -336,6 +351,24 @@ export class Editor extends React.Component { } }; + handleNotesChange = (action, payload) => { + const { collection, slug } = this.props; + + switch (action) { + case 'ADD_NOTE': + this.props.persistNote(collection, slug, payload); + break; + case 'UPDATE_NOTE': + this.props.updateNotePersist(collection, slug, payload.id, payload.updates); + break; + case 'DELETE_NOTE': + this.props.deleteNotePersist(collection, slug, payload.id); + break; + default: + console.log('Unknown notes action:', action, payload); + } + }; + render() { const { entry, @@ -385,7 +418,9 @@ export class Editor extends React.Component { fields={fields} fieldsMetaData={entryDraft.get('fieldsMetaData')} fieldsErrors={entryDraft.get('fieldsErrors')} + notes={entryDraft.get('notes')} onChange={this.handleChangeDraftField} + onNotesChange={this.handleNotesChange} onValidate={changeDraftFieldValidation} onPersist={this.handlePersistEntry} onDelete={this.handleDeleteEntry} @@ -403,6 +438,7 @@ export class Editor extends React.Component { hasUnpublishedChanges={unpublishedEntry} isNewEntry={newEntry} isModification={isModification} + isPublished={isPublished} currentStatus={currentStatus} onLogoutClick={logoutUser} deployPreview={deployPreview} @@ -492,6 +528,11 @@ const mapDispatchToProps = { unpublishPublishedEntry, deleteUnpublishedEntry, logoutUser, + loadNotesForEntry, + loadNotes, + persistNote, + updateNotePersist, + deleteNotePersist, }; export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor))); diff --git a/packages/decap-cms-core/src/components/Editor/EditorInterface.js b/packages/decap-cms-core/src/components/Editor/EditorInterface.js index cad473866aa2..254bbfa5df4b 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/decap-cms-core/src/components/Editor/EditorInterface.js @@ -16,12 +16,14 @@ import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; import EditorControlPane from './EditorControlPane/EditorControlPane'; import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane'; +import EditorNotesPane from './EditorNotesPane/EditorNotesPane'; import EditorToolbar from './EditorToolbar'; import { hasI18n, getI18nInfo, getPreviewEntry } from '../../lib/i18n'; import { FILES } from '../../constants/collectionTypes'; import { getFileFromSlug } from '../../reducers/collections'; const PREVIEW_VISIBLE = 'cms.preview-visible'; +const NOTES_VISIBLE = 'cms.notes-visible'; const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled'; const SPLIT_PANE_POSITION = 'cms.split-pane-position'; const I18N_VISIBLE = 'cms.i18n-visible'; @@ -122,6 +124,12 @@ const ControlPaneContainer = styled(PreviewPaneContainer)` overflow-x: hidden; `; +const NotesPaneContainer = styled.div` + height: 100%; + pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')}; + overflow: hidden; +`; + const ViewControls = styled.div` position: absolute; top: 10px; @@ -132,12 +140,16 @@ const ViewControls = styled.div` function EditorContent({ i18nVisible, previewVisible, + notesVisible, editor, editorWithEditor, editorWithPreview, + editorWithNotes, }) { if (i18nVisible) { return editorWithEditor; + } else if (notesVisible) { + return editorWithNotes; } else if (previewVisible) { return editorWithPreview; } else { @@ -154,10 +166,24 @@ function isPreviewEnabled(collection, entry) { return collection.getIn(['editor', 'preview'], true); } +function isNotesEnabled(collection, entry, isNewEntry, isPublished, hasWorkflow) { + if (isNewEntry || !hasWorkflow) { + return false; + } + + if (collection.get('type') === FILES) { + const file = getFileFromSlug(collection, entry.get('slug')); + const notesEnabled = file?.getIn(['editor', 'notes']); + if (notesEnabled != null) return notesEnabled; + } + return collection.getIn(['editor', 'notes'], true); +} + class EditorInterface extends Component { state = { showEventBlocker: false, previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false', + notesVisible: localStorage.getItem(NOTES_VISIBLE) !== 'false', scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false', i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false', }; @@ -190,8 +216,26 @@ class EditorInterface extends Component { handleTogglePreview = () => { const newPreviewVisible = !this.state.previewVisible; - this.setState({ previewVisible: newPreviewVisible }); + this.setState({ + previewVisible: newPreviewVisible, + notesVisible: false, // Hide notes when showing preview + }); localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible); + localStorage.setItem(NOTES_VISIBLE, 'false'); + }; + + handleToggleNotes = () => { + const newNotesVisible = !this.state.notesVisible; + this.setState({ + notesVisible: newNotesVisible, + previewVisible: false, // Hide preview when showing notes + }); + localStorage.setItem(NOTES_VISIBLE, newNotesVisible); + localStorage.setItem(PREVIEW_VISIBLE, 'false'); + }; + + handleNotesChange = (action, payload) => { + this.props.onNotesChange(action, payload); }; handleToggleScrollSync = () => { @@ -234,6 +278,7 @@ class EditorInterface extends Component { hasUnpublishedChanges, isNewEntry, isModification, + isPublished, currentStatus, onLogoutClick, loadDeployPreview, @@ -246,6 +291,7 @@ class EditorInterface extends Component { const { scrollSyncEnabled, showEventBlocker } = this.state; const previewEnabled = isPreviewEnabled(collection, entry); + const notesEnabled = isNotesEnabled(collection, entry, isNewEntry, isPublished, hasWorkflow); const { locales, defaultLocale } = getI18nInfo(this.props.collection); const collectionI18nEnabled = hasI18n(collection) && locales.length > 1; @@ -310,6 +356,34 @@ class EditorInterface extends Component { ); + const editorWithNotes = ( + +
+ + localStorage.setItem(SPLIT_PANE_POSITION, size)} + onDragStarted={this.handleSplitPaneDragStart} + onDragFinished={this.handleSplitPaneDragFinished} + > + {editor} + + + + +
+
+ ); + const editorWithEditor = (
@@ -329,7 +403,8 @@ class EditorInterface extends Component { const i18nVisible = collectionI18nEnabled && this.state.i18nVisible; const previewVisible = previewEnabled && this.state.previewVisible; - const scrollSyncVisible = i18nVisible || previewVisible; + const notesVisible = notesEnabled && this.state.notesVisible; + const scrollSyncVisible = i18nVisible || previewVisible || notesVisible; return ( @@ -386,6 +461,15 @@ class EditorInterface extends Component { title={t('editor.editorInterface.togglePreview')} /> )} + {notesEnabled && ( + + )} {scrollSyncVisible && !collection.getIn(['editor', 'visualEditing']) && ( @@ -433,12 +519,15 @@ EditorInterface.propTypes = { hasUnpublishedChanges: PropTypes.bool, isNewEntry: PropTypes.bool, isModification: PropTypes.bool, + isPublished: PropTypes.bool, currentStatus: PropTypes.string, onLogoutClick: PropTypes.func.isRequired, deployPreview: PropTypes.object, loadDeployPreview: PropTypes.func.isRequired, draftKey: PropTypes.string.isRequired, t: PropTypes.func.isRequired, + notes: ImmutablePropTypes.list, + onNotesChange: PropTypes.func, }; export default EditorInterface; diff --git a/packages/decap-cms-core/src/components/Editor/EditorNotesPane/AddNoteForm.js b/packages/decap-cms-core/src/components/Editor/EditorNotesPane/AddNoteForm.js new file mode 100644 index 000000000000..108a510e1923 --- /dev/null +++ b/packages/decap-cms-core/src/components/Editor/EditorNotesPane/AddNoteForm.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styled from '@emotion/styled'; +import { colors, transitions } from 'decap-cms-ui-default'; + +const FormContainer = styled.div` + padding: 16px 28px; + border-top: 1px solid ${colors.textFieldBorder}; + background-color: ${colors.inputBackground}; +`; + +const TextArea = styled.textarea` + width: 100%; + min-height: 80px; + padding: 12px; + border: 1px solid ${colors.textFieldBorder}; + border-radius: 4px; + font-size: 14px; + font-family: inherit; + line-height: 1.4; + resize: vertical; + outline: none; + transition: border-color ${transitions.main}; + + &:focus { + border-color: ${colors.active}; + box-shadow: 0 0 0 2px ${colors.activeBackground}; + } + + &::placeholder { + color: ${colors.controlLabel}; + } +`; + +const FormActions = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; +`; + +const AddButton = styled.button` + background-color: ${colors.active}; + color: ${colors.textLight}; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all ${transitions.main}; + + &:hover:not(:disabled) { + background-color: ${colors.statusReadyText}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const Hint = styled.p` + font-size: 12px; + color: ${colors.controlLabel}; + margin: 4px 0 0; + font-style: italic; +`; + +class AddNoteForm extends Component { + static propTypes = { + onAdd: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + }; + + state = { + content: '', + isFocused: false, + }; + + handleContentChange = e => { + const content = e.target.value; + this.setState({ content }); + }; + + handleSubmit = e => { + e.preventDefault(); + const { content } = this.state; + const trimmedContent = content.trim(); + + if (trimmedContent) { + this.props.onAdd(trimmedContent); + this.setState({ content: '', isFocused: false }); + } + }; + + handleKeyDown = e => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.handleSubmit(e); + } + }; + + handleFocus = () => { + this.setState({ isFocused: true }); + }; + + handleBlur = e => { + const { relatedTarget } = e; + if (relatedTarget && relatedTarget.type === 'submit') { + return; // Do not update state if blur is caused by clicking the submit button + } + if (!this.state.content.trim()) { + this.setState({ isFocused: false }); + } + }; + + render() { + const { t } = this.props; + const { content } = this.state; + const canSubmit = content.trim().length > 0; + + return ( + +
+