Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ function makeFreshEditor(): { editor: Editor; dispatch: ReturnType<typeof vi.fn>
doc: {
resolve: () => ({ marks: () => [] }),
textContent: 'Hello world',
textBetween: vi.fn(() => 'Hello world'),
nodesBetween: vi.fn(),
},
};
tr.replaceWith.mockReturnValue(tr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ function makeEditor(text = 'Hello'): {
doc: {
resolve: () => ({ marks: () => [] }),
textContent: text,
textBetween: vi.fn(() => text),
nodesBetween: vi.fn(),
},
};
tr.replace.mockReturnValue(tr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function makeEditor(text = 'Hello'): {
textContent: text,
descendants: vi.fn(),
textBetween: vi.fn(() => text),
nodesBetween: vi.fn(),
},
};
tr.replaceWith.mockReturnValue(tr);
Expand Down
115 changes: 115 additions & 0 deletions packages/superdoc/src/components/CommentsLayer/CommentGroup.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h, ref } from 'vue';

let commentsStoreStub;

vi.mock('@superdoc/stores/comments-store', () => ({
useCommentsStore: () => commentsStoreStub,
}));

vi.mock('@superdoc/components/CommentsLayer/CommentDialog.vue', () => ({
default: defineComponent({
name: 'CommentDialogStub',
props: ['data', 'user', 'currentDocument', 'showGrouped'],
setup(props) {
return () =>
h('div', {
class: 'comment-dialog-stub',
'data-id': props.data.conversationId,
});
},
}),
}));

import CommentGroup from './CommentGroup.vue';

const clickOutsideDirective = { mounted: () => {}, unmounted: () => {} };

const makeConvo = (overrides = {}) => ({
conversationId: 'c-1',
selection: { documentId: 'doc-1' },
isFocused: false,
...overrides,
});

const mountGroup = (props = {}) =>
mount(CommentGroup, {
props: {
data: [makeConvo()],
currentDocument: { id: 'doc-1' },
parent: document.createElement('div'),
...props,
},
global: {
directives: { 'click-outside': clickOutsideDirective },
},
});

describe('CommentGroup.vue', () => {
beforeEach(() => {
commentsStoreStub = {
getCommentLocation: vi.fn(() => ({ top: 100, left: 20 })),
activeComment: ref(null),
};
});

it('renders the collapsed badge with the group count when no comment is active', () => {
const wrapper = mountGroup({
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
});
const bubble = wrapper.find('.number-bubble');
expect(bubble.exists()).toBe(true);
expect(bubble.text()).toBe('2');
});

it('applies the computed top style based on comment location', () => {
const wrapper = mountGroup();
const group = wrapper.find('.comments-group');
expect(group.attributes('style')).toMatch(/top:\s*90px/);
});

it('renders empty style when getCommentLocation returns null', () => {
commentsStoreStub.getCommentLocation.mockReturnValueOnce(null);
const wrapper = mountGroup();
// no top declared
expect(wrapper.find('.comments-group').attributes('style') || '').not.toMatch(/top:/);
});

it('renders nothing-style (no attr) when data is empty', () => {
const wrapper = mountGroup({ data: [] });
const style = wrapper.find('.comments-group').attributes('style');
expect(style === undefined || style === '').toBe(true);
});

it('expands and renders CommentDialogs when clicked', async () => {
const wrapper = mountGroup({
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
});
await wrapper.find('.comments-group').trigger('click');
expect(wrapper.find('.comments-group.expanded').exists()).toBe(true);
expect(wrapper.findAll('.comment-dialog-stub')).toHaveLength(2);
});

it('expands by default when the group contains the active comment', () => {
commentsStoreStub.activeComment.value = 'c-2';
const wrapper = mountGroup({
data: [makeConvo({ conversationId: 'c-1' }), makeConvo({ conversationId: 'c-2' })],
});
// when active, the collapsed node is suppressed and expanded renders only active convo
expect(wrapper.find('.comments-group.expanded').exists()).toBe(true);
const dialogs = wrapper.findAll('.comment-dialog-stub');
expect(dialogs).toHaveLength(1);
expect(dialogs[0].attributes('data-id')).toBe('c-2');
});

it('resolves getCommentLocation with the active convo selection first', () => {
commentsStoreStub.activeComment.value = 'c-2';
const data = [
makeConvo({ conversationId: 'c-1', selection: { documentId: 'doc-1', page: 1 } }),
makeConvo({ conversationId: 'c-2', selection: { documentId: 'doc-1', page: 9 } }),
];
mountGroup({ data });
expect(commentsStoreStub.getCommentLocation).toHaveBeenCalledWith(data[1].selection, expect.any(Object));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { conversation, comment } from './comment-schemas.js';

describe('comment-schemas', () => {
it('exposes a conversation template with null defaults', () => {
expect(conversation).toEqual({
conversationId: null,
documentId: null,
creatorEmail: null,
creatorName: null,
comments: [],
selection: null,
});
});

it('exposes a comment template with user/timestamp placeholders', () => {
expect(comment).toEqual({
comment: null,
user: { name: null, email: null },
timestamp: null,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { defineComponent, h } from 'vue';

vi.mock('./commentsList.vue', () => ({
default: defineComponent({
name: 'CommentsListStub',
setup() {
return () => h('div', { class: 'comments-list-stub' });
},
}),
}));

vi.mock('@superdoc/common', () => ({
vClickOutside: { mounted: vi.fn(), unmounted: vi.fn() },
}));

import { SuperComments } from './super-comments-list.js';

describe('SuperComments', () => {
let element;
const superdocStub = { id: 'sd-1' };

beforeEach(() => {
element = document.createElement('div');
document.body.appendChild(element);
});

it('mounts the Vue app into the provided element on construction', () => {
const instance = new SuperComments({ element, comments: [] }, superdocStub);
expect(instance.app).not.toBeNull();
expect(instance.element).toBe(element);
expect(element.querySelector('.comments-list-stub')).not.toBeNull();
});

it('exposes the merged config', () => {
const comments = [{ id: 'c-1' }];
const instance = new SuperComments({ element, comments }, superdocStub);
expect(instance.config.comments).toBe(comments);
expect(instance.config.element).toBe(element);
});

it('exposes the superdoc reference as $superdoc on the Vue app', () => {
const instance = new SuperComments({ element }, superdocStub);
expect(instance.app.config.globalProperties.$superdoc).toBe(superdocStub);
});

it('resolves element via selector when no element is provided', () => {
const el = document.createElement('div');
el.id = 'my-comments-host';
document.body.appendChild(el);
const instance = new SuperComments({ selector: 'my-comments-host' }, superdocStub);
expect(instance.element).toBe(el);
});

it('close() unmounts the app and clears refs', () => {
const instance = new SuperComments({ element }, superdocStub);
instance.close();
expect(instance.app).toBeNull();
expect(instance.container).toBeNull();
expect(instance.element).toBeNull();
});

it('close() is a no-op when there is no app', () => {
const instance = new SuperComments({ element }, superdocStub);
instance.close();
expect(() => instance.close()).not.toThrow();
});

it('open() re-creates the app after close', () => {
const instance = new SuperComments({ element }, superdocStub);
instance.close();
// Re-provide element and re-open
instance.element = document.body.appendChild(document.createElement('div'));
instance.open();
expect(instance.app).not.toBeNull();
});
});
30 changes: 30 additions & 0 deletions packages/superdoc/src/components/CommentsLayer/helpers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { formatDate } from './helpers.js';

describe('formatDate', () => {
it('formats a morning timestamp with AM and padded minutes', () => {
// 2024-01-15 09:05:00 local
const ts = new Date(2024, 0, 15, 9, 5).getTime();
expect(formatDate(ts)).toBe('9:05AM Jan 15');
});

it('formats an afternoon timestamp with PM', () => {
const ts = new Date(2024, 5, 1, 14, 30).getTime();
expect(formatDate(ts)).toBe('2:30PM Jun 1');
});

it('displays midnight as 12 AM', () => {
const ts = new Date(2024, 11, 31, 0, 0).getTime();
expect(formatDate(ts)).toBe('12:00AM Dec 31');
});

it('displays noon as 12 PM', () => {
const ts = new Date(2024, 6, 4, 12, 45).getTime();
expect(formatDate(ts)).toBe('12:45PM Jul 4');
});

it('pads single-digit minutes with a leading zero', () => {
const ts = new Date(2024, 2, 2, 8, 9).getTime();
expect(formatDate(ts)).toBe('8:09AM Mar 2');
});
});
Loading
Loading