@micha.bigler/ui-core-micha 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/WORK_ORDERS.md +5 -1
- package/dev/entries.jsx +147 -0
- package/dev/fixtures.js +26 -0
- package/dev/main.jsx +56 -0
- package/dev/mockTransport.jsx +128 -0
- package/dist/i18n/messagingTranslations.js +81 -0
- package/dist/index.js +14 -0
- package/dist/messaging/AttachmentList.js +43 -0
- package/dist/messaging/Composer.js +120 -0
- package/dist/messaging/ConversationLaunchers.js +39 -0
- package/dist/messaging/ConversationList.js +75 -0
- package/dist/messaging/MessageBubble.js +17 -0
- package/dist/messaging/MessagingProvider.js +317 -0
- package/dist/messaging/MessagingScopeConfig.js +28 -0
- package/dist/messaging/PollCard.js +53 -0
- package/dist/messaging/ReactionBar.js +44 -0
- package/dist/messaging/ReadTicks.js +25 -0
- package/dist/messaging/Thread.js +73 -0
- package/dist/messaging/api.js +51 -0
- package/dist/notifications/NotificationsProvider.js +2 -2
- package/dist/notifications/realtime.js +21 -1
- package/docs/messaging-deviations.md +36 -0
- package/index.html +12 -0
- package/package.json +4 -1
- package/src/i18n/messagingTranslations.ts +81 -0
- package/src/index.js +15 -0
- package/src/messaging/AttachmentList.jsx +38 -0
- package/src/messaging/Composer.jsx +110 -0
- package/src/messaging/ConversationLaunchers.jsx +51 -0
- package/src/messaging/ConversationList.jsx +78 -0
- package/src/messaging/MessageBubble.jsx +37 -0
- package/src/messaging/MessagingProvider.jsx +323 -0
- package/src/messaging/MessagingScopeConfig.jsx +24 -0
- package/src/messaging/PollCard.jsx +36 -0
- package/src/messaging/ReactionBar.jsx +46 -0
- package/src/messaging/ReadTicks.jsx +23 -0
- package/src/messaging/Thread.jsx +71 -0
- package/src/messaging/api.js +47 -0
- package/src/notifications/NotificationsProvider.jsx +2 -2
- package/src/notifications/realtime.jsx +21 -1
- package/tests/ConversationList.test.jsx +66 -0
- package/tests/MessagingProvider.test.jsx +50 -0
- package/tests/Thread.test.jsx +100 -0
- package/tests/messagingApi.test.js +32 -0
- package/tests/messagingComposer.test.jsx +111 -0
- package/tests/messagingExports.test.js +20 -0
- package/tests/messagingInteractions.test.jsx +112 -0
- package/tests/realtime.test.jsx +27 -0
- package/vite.config.mjs +6 -0
- package/work-orders/DX-1.md +166 -0
- package/work-orders/MSG-3.md +375 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : (options?.sender ? `${key}:${options.sender}` : key) }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
18
|
+
import { AttachmentList } from '../src/messaging/AttachmentList';
|
|
19
|
+
import { Composer } from '../src/messaging/Composer';
|
|
20
|
+
import { messagingReducer, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
21
|
+
|
|
22
|
+
function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
23
|
+
function makeApi() {
|
|
24
|
+
return {
|
|
25
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
26
|
+
createMessage: vi.fn(), uploadAttachments: vi.fn(), getAttachment: vi.fn().mockResolvedValue(new Blob(['file'])), getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function renderComposer(api, { userId } = {}) {
|
|
30
|
+
const tree = <MessagingProvider api={api} active={false}><Composer conversationId={7} /><State /></MessagingProvider>;
|
|
31
|
+
return render(userId ? <AuthContext.Provider value={{ user: { id: userId, username: 'me' } }}>{tree}</AuthContext.Provider> : tree);
|
|
32
|
+
}
|
|
33
|
+
function typeAndSend(text = 'hello') { fireEvent.change(screen.getByLabelText('MessagingComposer.MESSAGE'), { target: { value: text } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' })); }
|
|
34
|
+
const state = () => JSON.parse(screen.getByTestId('cache').textContent);
|
|
35
|
+
|
|
36
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
37
|
+
|
|
38
|
+
describe('Composer optimistic send and attachments', () => {
|
|
39
|
+
it('reconciles REST then websocket confirmation into exactly one row', () => {
|
|
40
|
+
const pending = { id: 'local-r1', conversation_id: 7, client_request_id: 'r1', status: 'pending' };
|
|
41
|
+
const rest = { id: 88, conversation_id: 7, body: 'hello', client_request_id: 'r1' };
|
|
42
|
+
let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
|
|
43
|
+
next = messagingReducer(next, { type: 'messageReconciled', message: rest });
|
|
44
|
+
next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: rest } });
|
|
45
|
+
expect(Object.values(next.messages)).toHaveLength(1);
|
|
46
|
+
expect(next.messages[88].client_request_id).toBe('r1');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reconciles websocket then REST confirmation into exactly one row', () => {
|
|
50
|
+
const pending = { id: 'local-r2', conversation_id: 7, client_request_id: 'r2', status: 'pending' };
|
|
51
|
+
const websocket = { id: 89, conversation_id: 7, body: 'hello', client_request_id: 'r2' };
|
|
52
|
+
let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
|
|
53
|
+
next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: websocket } });
|
|
54
|
+
next = messagingReducer(next, { type: 'messageReconciled', message: websocket });
|
|
55
|
+
expect(Object.values(next.messages)).toHaveLength(1);
|
|
56
|
+
expect(next.messages[89].client_request_id).toBe('r2');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('retries a failed send with the same idempotency key and optimistic row', async () => {
|
|
60
|
+
const api = makeApi(); api.createMessage.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ id: 90, conversation_id: 7, body: 'hello' });
|
|
61
|
+
renderComposer(api); typeAndSend(); await screen.findByRole('alert'); const first = api.createMessage.mock.calls[0][1].client_request_id;
|
|
62
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.RETRY' }));
|
|
63
|
+
await waitFor(() => expect(api.createMessage).toHaveBeenCalledTimes(2));
|
|
64
|
+
expect(api.createMessage.mock.calls[1][1].client_request_id).toBe(first);
|
|
65
|
+
await waitFor(() => expect(Object.keys(state().messages)).toEqual(['90']));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows the server validation message for a rejected attachment upload', async () => {
|
|
69
|
+
// dcm's ConversationAttachmentView translates a rejected upload into a
|
|
70
|
+
// field-keyed {"files": [...]} body (the MSG-2 chunk-4 fix), not {detail}.
|
|
71
|
+
const api = makeApi(); api.uploadAttachments.mockRejectedValue({ response: { data: { files: ['File type application/octet-stream is not allowed.'] } } });
|
|
72
|
+
renderComposer(api); const input = document.querySelector('input[type=file]'); const file = new File(['x'], 'bad.exe', { type: 'application/octet-stream' });
|
|
73
|
+
fireEvent.change(input, { target: { files: [file] } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' }));
|
|
74
|
+
await screen.findByText('MessagingComposer.UPLOAD_ERROR:File type application/octet-stream is not allowed.');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows the server validation message when it arrives as {detail} (the text-message error shape)', async () => {
|
|
78
|
+
const api = makeApi(); api.createMessage.mockRejectedValue({ response: { data: { detail: 'Posting is not permitted.' } } });
|
|
79
|
+
renderComposer(api); typeAndSend('hello');
|
|
80
|
+
await screen.findByText('MessagingComposer.SEND_ERROR:Posting is not permitted.');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('populates the optimistic row with the current user as sender, not "unknown sender"', async () => {
|
|
84
|
+
let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
|
|
85
|
+
renderComposer(api, { userId: 9 }); typeAndSend('hello');
|
|
86
|
+
await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
|
|
87
|
+
const [optimistic] = Object.values(state().messages);
|
|
88
|
+
expect(optimistic.status).toBe('pending');
|
|
89
|
+
expect(optimistic.sender).toEqual({ id: 9, display_name: 'me' });
|
|
90
|
+
resolveCreate({ id: 91, conversation_id: 7, body: 'hello' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('end-to-end: a real in-flight send reconciled by a WS frame arriving before the REST response settles produces exactly one row', async () => {
|
|
94
|
+
let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
|
|
95
|
+
renderComposer(api); typeAndSend('hello');
|
|
96
|
+
await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
|
|
97
|
+
const requestId = api.createMessage.mock.calls[0][1].client_request_id;
|
|
98
|
+
// The WS frame for this message's creation arrives while the REST POST is still in flight.
|
|
99
|
+
act(() => dispatchMessagingFrame({ envelope: 'messaging', type: 'message', conversation_id: 7, message: { id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId } }));
|
|
100
|
+
expect(Object.keys(state().messages)).toEqual(['91']);
|
|
101
|
+
// The REST response for the same send resolves afterward and must not create a second row.
|
|
102
|
+
await act(async () => resolveCreate({ id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId }));
|
|
103
|
+
expect(Object.keys(state().messages)).toEqual(['91']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('mounts Composer and AttachmentList independently', async () => {
|
|
107
|
+
const api = makeApi(); const { unmount } = renderComposer(api); expect(screen.getByLabelText('MessagingComposer.LABEL')).toBeTruthy(); unmount();
|
|
108
|
+
render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
|
|
109
|
+
expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AttachmentList, Composer, ConversationLaunchers, ConversationList, MessageBubble, MessagingProvider, MessagingScopeConfig, messagingTranslations, PollCard, ReactionBar, ReadTicks, Thread, useMessaging } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('messaging barrel exports', () => {
|
|
5
|
+
it('exports the provider, hook, standalone list and launchers', () => {
|
|
6
|
+
expect(MessagingProvider).toBeDefined();
|
|
7
|
+
expect(useMessaging).toBeDefined();
|
|
8
|
+
expect(ConversationList).toBeDefined();
|
|
9
|
+
expect(ConversationLaunchers).toBeDefined();
|
|
10
|
+
expect(Thread).toBeDefined();
|
|
11
|
+
expect(MessageBubble).toBeDefined();
|
|
12
|
+
expect(ReadTicks).toBeDefined();
|
|
13
|
+
expect(Composer).toBeDefined();
|
|
14
|
+
expect(AttachmentList).toBeDefined();
|
|
15
|
+
expect(ReactionBar).toBeDefined();
|
|
16
|
+
expect(PollCard).toBeDefined();
|
|
17
|
+
expect(MessagingScopeConfig).toBeDefined();
|
|
18
|
+
expect(messagingTranslations).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { Composer } from '../src/messaging/Composer';
|
|
10
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
11
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
12
|
+
import { ReactionBar } from '../src/messaging/ReactionBar';
|
|
13
|
+
import { MessagingScopeConfig } from '../src/messaging/MessagingScopeConfig';
|
|
14
|
+
|
|
15
|
+
function CacheState() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
16
|
+
|
|
17
|
+
const baseApi = () => ({ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), addReaction: vi.fn(), removeReaction: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), createPoll: vi.fn() });
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
describe('messaging reactions and polls', () => {
|
|
21
|
+
it('round-trips add and remove reactions through the cache-backed API', async () => {
|
|
22
|
+
const api = baseApi(); api.addReaction.mockResolvedValue({ reactions: [{ emoji: '👍', count: 1, reacted: true }] }); api.removeReaction.mockResolvedValue({ reactions: [] });
|
|
23
|
+
const { rerender } = render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith(7, '👍'));
|
|
25
|
+
rerender(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: true }] }} /></MessagingProvider>);
|
|
26
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.removeReaction).toHaveBeenCalledWith(7, '👍'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('uses single selection for a single poll and preserves selections for a multi poll', async () => {
|
|
30
|
+
const single = baseApi(); single.votePoll.mockResolvedValue({ poll: { id: 1, options: [] } });
|
|
31
|
+
const { unmount } = render(<MessagingProvider api={single} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'One', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: false }}} /></MessagingProvider>);
|
|
32
|
+
const choices = screen.getAllByRole('checkbox'); fireEvent.click(choices[0]); fireEvent.click(choices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2])); unmount();
|
|
33
|
+
const multiple = baseApi(); multiple.votePoll.mockResolvedValue({ poll: { id: 2, options: [] } }); render(<MessagingProvider api={multiple} active={false}><PollCard message={{ id: 8, poll: { id: 2, question: 'Many', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: true }}} /></MessagingProvider>);
|
|
34
|
+
const multiChoices = screen.getAllByRole('checkbox'); fireEvent.click(multiChoices[0]); fireEvent.click(multiChoices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('only exposes close to the creator/capability and surfaces a forbidden close', async () => {
|
|
38
|
+
const api = baseApi(); api.closePoll.mockRejectedValue({ response: { data: { detail: 'Not permitted' } } });
|
|
39
|
+
const { rerender } = render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'Q', options: [], created_by: 2 }}} /></MessagingProvider>);
|
|
40
|
+
expect(screen.queryByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeNull();
|
|
41
|
+
rerender(<MessagingProvider api={api} active={false}><PollCard canClose message={{ id: 7, poll: { id: 1, question: 'Q', options: [] }}} /></MessagingProvider>);
|
|
42
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })); await screen.findByRole('alert');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('creates polls with an idempotency key', async () => {
|
|
46
|
+
const api = baseApi(); api.createPoll.mockResolvedValue({ id: 70, conversation_id: 4, poll: { id: 1 } });
|
|
47
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.CREATE' })); fireEvent.change(screen.getByLabelText('MessagingPoll.QUESTION'), { target: { value: 'Where?' } });
|
|
49
|
+
const options = screen.getAllByLabelText(/MessagingPoll.OPTION/); fireEvent.change(options[0], { target: { value: 'Here' } }); fireEvent.change(options[1], { target: { value: 'There' } });
|
|
50
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'MessagingPoll.CREATE' })[0]); await waitFor(() => expect(api.createPoll).toHaveBeenCalled());
|
|
51
|
+
expect(api.createPoll.mock.calls[0][2].idempotencyKey).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('mounts the scope config as an independent provider collaborator', async () => {
|
|
55
|
+
const api = baseApi(); api.getConversationConfig = vi.fn().mockResolvedValue({ dm_policy: 'all', group_chat_enabled: true, everyone_can_post: false });
|
|
56
|
+
render(<MessagingProvider api={api} active={false}><MessagingScopeConfig conversationId={4} /></MessagingProvider>);
|
|
57
|
+
await screen.findByLabelText('MessagingConfig.LABEL');
|
|
58
|
+
expect(api.getConversationConfig).toHaveBeenCalledWith(4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('creates an announcement with a title, body and host-supplied link target', async () => {
|
|
62
|
+
const api = baseApi(); api.createMessage = vi.fn().mockResolvedValue({ id: 90, conversation_id: 4, kind: 'announcement', title: 'Heads up' });
|
|
63
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement linkTarget="/events/4/info" /></MessagingProvider>);
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }));
|
|
65
|
+
fireEvent.change(screen.getByLabelText('MessagingAnnouncement.TITLE'), { target: { value: 'Heads up' } });
|
|
66
|
+
fireEvent.change(screen.getByLabelText('MessagingAnnouncement.BODY'), { target: { value: 'Schedule changed.' } });
|
|
67
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'MessagingAnnouncement.SEND' })[0]);
|
|
68
|
+
await waitFor(() => expect(api.createMessage).toHaveBeenCalled());
|
|
69
|
+
const [, payload] = api.createMessage.mock.calls[0];
|
|
70
|
+
expect(payload).toMatchObject({ kind: 'announcement', title: 'Heads up', body: 'Schedule changed.', link_target: '/events/4/info' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('carries title and link_target into the optimistic row, not just the final REST payload', async () => {
|
|
74
|
+
const api = baseApi(); api.createMessage = vi.fn().mockReturnValue(new Promise(() => {}));
|
|
75
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement linkTarget="/events/4/info" /><CacheState /></MessagingProvider>);
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }));
|
|
77
|
+
fireEvent.change(screen.getByLabelText('MessagingAnnouncement.TITLE'), { target: { value: 'Heads up' } });
|
|
78
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'MessagingAnnouncement.SEND' })[0]);
|
|
79
|
+
await waitFor(() => {
|
|
80
|
+
const [optimistic] = Object.values(JSON.parse(screen.getByTestId('cache').textContent).messages);
|
|
81
|
+
expect(optimistic.status).toBe('pending');
|
|
82
|
+
expect(optimistic.title).toBe('Heads up');
|
|
83
|
+
expect(optimistic.link_target).toBe('/events/4/info');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not offer announcement composing unless the host opts in', () => {
|
|
88
|
+
const api = baseApi();
|
|
89
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} /></MessagingProvider>);
|
|
90
|
+
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('disables the chip while pending, then rolls back and re-enables it on API failure', async () => {
|
|
94
|
+
let rejectAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((_, reject) => { rejectAdd = reject; }));
|
|
95
|
+
render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|
|
96
|
+
const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' });
|
|
97
|
+
fireEvent.click(chip);
|
|
98
|
+
await waitFor(() => expect(chip.className.includes('Mui-disabled')).toBe(true));
|
|
99
|
+
rejectAdd({ response: { data: { detail: 'Reactions disabled' } } });
|
|
100
|
+
await screen.findByRole('alert');
|
|
101
|
+
expect(chip.className.includes('Mui-disabled')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ignores a second click on the same reaction while the first toggle is still in flight', async () => {
|
|
105
|
+
let resolveAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((resolve) => { resolveAdd = resolve; }));
|
|
106
|
+
render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|
|
107
|
+
const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' });
|
|
108
|
+
fireEvent.click(chip); fireEvent.click(chip);
|
|
109
|
+
resolveAdd({ reactions: [{ emoji: '👍', count: 2, reacted: true }] });
|
|
110
|
+
await waitFor(() => expect(api.addReaction).toHaveBeenCalledTimes(1));
|
|
111
|
+
});
|
|
112
|
+
});
|
package/tests/realtime.test.jsx
CHANGED
|
@@ -37,6 +37,13 @@ function StreamConsumer({ envelope, testId }) {
|
|
|
37
37
|
return <output data-testid={testId}>{JSON.stringify(received)}</output>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function ReconnectConsumer() {
|
|
41
|
+
const { onReconnect } = useRealtime();
|
|
42
|
+
const [count, setCount] = React.useState(0);
|
|
43
|
+
React.useEffect(() => onReconnect(() => setCount((current) => current + 1)), [onReconnect]);
|
|
44
|
+
return <output data-testid="reconnect-count">{count}</output>;
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
function NotificationsConsumer() {
|
|
41
48
|
const notifications = useNotifications();
|
|
42
49
|
return (
|
|
@@ -181,6 +188,26 @@ describe('realtime primitive (NOTIF-13)', () => {
|
|
|
181
188
|
}
|
|
182
189
|
});
|
|
183
190
|
|
|
191
|
+
it('additively signals consumers when a closed connection re-establishes', async () => {
|
|
192
|
+
vi.useFakeTimers();
|
|
193
|
+
try {
|
|
194
|
+
render(
|
|
195
|
+
<AuthContext.Provider value={{ user: { id: 4 } }}>
|
|
196
|
+
<NotificationsProvider><ReconnectConsumer /></NotificationsProvider>
|
|
197
|
+
</AuthContext.Provider>,
|
|
198
|
+
);
|
|
199
|
+
await vi.waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
200
|
+
MockWebSocket.instances[0].onopen();
|
|
201
|
+
expect(screen.getByTestId('reconnect-count').textContent).toBe('0');
|
|
202
|
+
MockWebSocket.instances[0].onclose();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
204
|
+
MockWebSocket.instances[1].onopen();
|
|
205
|
+
await vi.waitFor(() => expect(screen.getByTestId('reconnect-count').textContent).toBe('1'));
|
|
206
|
+
} finally {
|
|
207
|
+
vi.useRealTimers();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
184
211
|
it("NotificationsProvider's public context value is unchanged", async () => {
|
|
185
212
|
renderWithStreams();
|
|
186
213
|
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
package/vite.config.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# DX-1 — minimal Vite dev harness for ucm components
|
|
2
|
+
|
|
3
|
+
Status: planned · Tier 1 (dev-only tooling; **adds devDependencies → approval-gated**) · Target repo: `ui-core-micha` (main)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Part A — Envelope (Expertenchat, 2026-07-31)
|
|
8
|
+
|
|
9
|
+
### Goal
|
|
10
|
+
|
|
11
|
+
Give `ui-core-micha` a way to **render** its components during development. Today `package.json`
|
|
12
|
+
exposes only `build` (tsc) and `test` (vitest): there is no Storybook, no demo app, no dev page. Every
|
|
13
|
+
shipped surface — auth, onboarding, notifications, charts — has only ever been verified in jsdom or
|
|
14
|
+
after a consuming app pinned it.
|
|
15
|
+
|
|
16
|
+
Immediate driver: MSG-3 is ~3700 LOC of new UI (jg's parity target: `Thread.jsx` alone is 2470 LOC)
|
|
17
|
+
that no app mounts until MSG-5, and the operator granted a redesign licence, which is only iterable
|
|
18
|
+
if the result can be looked at. But the gap is not messaging-specific and the harness is not
|
|
19
|
+
messaging-specific.
|
|
20
|
+
|
|
21
|
+
### Expected outcome
|
|
22
|
+
|
|
23
|
+
- A `pnpm dev` (or equivalent) script starting a Vite dev server that mounts ucm components in a
|
|
24
|
+
browser, with the repo's MUI theme, `react-i18next` and router context wired the way a host app
|
|
25
|
+
wires them.
|
|
26
|
+
- A small set of harness entries covering the existing surfaces well enough to prove the harness is
|
|
27
|
+
real: at minimum one notifications surface and one charts surface. Adding an entry for a new
|
|
28
|
+
component must be a few lines, not a framework.
|
|
29
|
+
- **A single component must be mountable standalone**, not only a whole assembled surface. MSG-3
|
|
30
|
+
relies on this: it requires one harness entry per named component as its decomposition forcing
|
|
31
|
+
function, so the harness must not assume an entry equals a full page.
|
|
32
|
+
- A **mock transport layer** the entries drive: fixture data plus an injectable API/realtime adapter,
|
|
33
|
+
so a surface can be exercised without a backend. Where a component takes its data through a
|
|
34
|
+
provider, the harness supplies a mock provider rather than patching the component.
|
|
35
|
+
- Light/dark theme and viewport switching, since responsive and dark-mode correctness is exactly what
|
|
36
|
+
jsdom cannot show.
|
|
37
|
+
- Harness code lives outside the published package: not exported from `src/index.js`, excluded from
|
|
38
|
+
`tsconfig.build.json`, and adding **zero** runtime dependencies. `dist/` must be byte-identical in
|
|
39
|
+
content to a build from before this WO.
|
|
40
|
+
- `README` (or `CONTRIBUTING`) gains a short section on running the harness and adding an entry.
|
|
41
|
+
|
|
42
|
+
### Non-goals / do-not-touch
|
|
43
|
+
|
|
44
|
+
Storybook (deliberately not chosen — a dev page is a fraction of the config surface and this repo has
|
|
45
|
+
no story-writing habit to build on; revisit only if the harness proves insufficient); visual
|
|
46
|
+
regression / screenshot testing; publishing the harness; any change to component source, exports, or
|
|
47
|
+
public API; any change to the existing vitest setup; CI wiring (the harness is a local dev tool, not
|
|
48
|
+
a gate).
|
|
49
|
+
|
|
50
|
+
### Required tests to WRITE
|
|
51
|
+
|
|
52
|
+
This is dev tooling, so the meaningful assertions are about **not** affecting the product:
|
|
53
|
+
|
|
54
|
+
- The published barrel (`src/index.js`) is unchanged — the existing exports regression test
|
|
55
|
+
(`tests/notificationsExports.test.js` pattern) must still pass, and no harness module is reachable
|
|
56
|
+
from it.
|
|
57
|
+
- A build (`tsc -p tsconfig.build.json`) succeeds and emits no harness files into `dist/`.
|
|
58
|
+
- `package.json` `dependencies` and `peerDependencies` are unchanged; additions are confined to
|
|
59
|
+
`devDependencies`.
|
|
60
|
+
|
|
61
|
+
No component behaviour is under test here — the existing suite covers that and must stay green.
|
|
62
|
+
|
|
63
|
+
### Risks
|
|
64
|
+
|
|
65
|
+
- **Dependency addition** is the real cost and the reason this is approval-gated: Vite plus a React
|
|
66
|
+
plugin. Keep it to the minimum; do not pull in a UI-catalog framework.
|
|
67
|
+
- **Harness fidelity.** A mock adapter can drift from the real REST/realtime contract and produce
|
|
68
|
+
confidence that does not transfer. Mitigation: fixtures are shaped from the actual dcm contract in
|
|
69
|
+
`django-core-micha/docs/design/messaging-platform.md` §REST/§Realtime, and the harness is a
|
|
70
|
+
development aid — it never replaces the tests or the independent review.
|
|
71
|
+
- Scope creep into "a component gallery". The deliverable is a dev page, not a product.
|
|
72
|
+
|
|
73
|
+
### Preconditions
|
|
74
|
+
|
|
75
|
+
Operator approval for the devDependency additions — granted 2026-07-31 as part of the MSG-3 scoping
|
|
76
|
+
decision. This WO **runs before MSG-3**.
|
|
77
|
+
|
|
78
|
+
### Execution directive
|
|
79
|
+
|
|
80
|
+
Implement through `codex exec` in the background — invoked **directly via Bash** (never the
|
|
81
|
+
`debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check` and
|
|
82
|
+
`--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from a file;
|
|
83
|
+
fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
84
|
+
|
|
85
|
+
### Release
|
|
86
|
+
|
|
87
|
+
**No version bump, no publish.** Dev-only tooling changes nothing in the package. The next ucm
|
|
88
|
+
publish rides whatever WO follows (MSG-3).
|
|
89
|
+
|
|
90
|
+
### Mini-handover (pastable)
|
|
91
|
+
|
|
92
|
+
Orchestrator: implement `work-orders/DX-1.md` in `ui-core-micha` (main). `git pull` first, read the
|
|
93
|
+
WO, then follow `orchestrate-codex` (Codex-first, own independent review, commit on green, **no
|
|
94
|
+
publish**).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Part B — Implementation map (Orchestrator)
|
|
99
|
+
|
|
100
|
+
### Target repo / working directory
|
|
101
|
+
|
|
102
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha` (repo root; package `@micha.bigler/ui-core-micha`,
|
|
103
|
+
current published version 2.15.0 — **unchanged by this WO**, no publish).
|
|
104
|
+
|
|
105
|
+
### Context package
|
|
106
|
+
|
|
107
|
+
**Repo conventions to mirror:**
|
|
108
|
+
- `package.json` — current `scripts`: `build` (`tsc -p tsconfig.build.json`), `test` (`vitest run`).
|
|
109
|
+
No `dev` script exists yet; add one. `peerDependencies`/`dependencies` must not change;
|
|
110
|
+
`devDependencies` gains Vite + `@vitejs/plugin-react` only (or whatever minimal set a Vite+React+JSX
|
|
111
|
+
dev server needs) — no UI-catalog framework, no Storybook.
|
|
112
|
+
- `tsconfig.build.json` — `"include": ["src"]`, `rootDir: "./src"`, `outDir: "dist"`. The harness must
|
|
113
|
+
live **outside** `src/` (e.g. a top-level `dev/` directory) so it is never included by this config
|
|
114
|
+
and never emitted into `dist/`. Confirm after writing: `dist/` from a build with the harness present
|
|
115
|
+
must be byte-identical to a build from before this WO (no new/changed files under `dist/`).
|
|
116
|
+
- `src/index.js` — the published barrel. Do not add anything here for the harness itself; the harness
|
|
117
|
+
imports FROM the barrel (or directly from component source, orchestrator's/Codex's call — importing
|
|
118
|
+
from source is likely simpler and avoids a dist rebuild loop during dev) but nothing is added TO it.
|
|
119
|
+
- `tests/notificationsExports.test.js` — the existing exports-regression pattern to confirm still
|
|
120
|
+
passes untouched (proves the barrel didn't change).
|
|
121
|
+
- `src/notifications/realtime.jsx` — `useRealtimeCore({ active, wsUrl })` returns `{ subscribe }`;
|
|
122
|
+
`RealtimeContext` is the context the mock realtime provider must satisfy so components under it can
|
|
123
|
+
call `useRealtime()` without throwing. The harness's mock realtime adapter should provide a
|
|
124
|
+
`RealtimeContext.Provider` with an injectable `subscribe` (e.g. a manual event-dispatch harness) so
|
|
125
|
+
a harness entry can simulate a WS frame arriving, without opening a real socket.
|
|
126
|
+
- `src/notifications/NotificationsProvider.jsx` (read for the provider-wrapping pattern) — a
|
|
127
|
+
representative provider-consuming surface for the "at minimum one notifications surface" harness
|
|
128
|
+
entry requirement.
|
|
129
|
+
- `src/i18n/notificationsTranslations.ts` / `src/i18n/chartsTranslations.ts` — the flat i18n key
|
|
130
|
+
pattern; the harness needs `react-i18next` wired with at least these (or a minimal fixture set) so
|
|
131
|
+
mounted components render translated text instead of raw keys.
|
|
132
|
+
- Chart components (`ChartFrame`/`BarChart`/`LineChart`, per `tests/ChartFrame.test.jsx` etc.) — the
|
|
133
|
+
"at minimum one charts surface" harness entry.
|
|
134
|
+
|
|
135
|
+
**Invariants:**
|
|
136
|
+
- Zero new runtime dependencies (peer or direct) — Vite/plugin additions are dev-only.
|
|
137
|
+
- Harness is not exported, not built, not tested for behavior (only the three "does not affect the
|
|
138
|
+
product" assertions in Required Tests below).
|
|
139
|
+
- No change to the existing vitest test setup/config or any existing test file.
|
|
140
|
+
- No CI wiring — this is a local dev tool.
|
|
141
|
+
|
|
142
|
+
### Required tests
|
|
143
|
+
|
|
144
|
+
Per envelope: extend/confirm the exports-regression test still passes with nothing new reachable from
|
|
145
|
+
it; a `build` succeeds and diffing `dist/` before/after shows no harness files and no content change;
|
|
146
|
+
inspect `package.json` to confirm `dependencies`/`peerDependencies` are byte-identical and only
|
|
147
|
+
`devDependencies` gained entries.
|
|
148
|
+
|
|
149
|
+
### Progress contract
|
|
150
|
+
|
|
151
|
+
Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
152
|
+
`PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (file opened, file
|
|
153
|
+
edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
|
|
154
|
+
exceeds ~2 min, stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
155
|
+
|
|
156
|
+
### Preamble (must be appended verbatim to the Codex prompt)
|
|
157
|
+
|
|
158
|
+
The text above is the COMPLETE spec — read nearest `AGENTS.md`, `.codex/skills/<role>/SKILL.md` (if
|
|
159
|
+
present), and this repo's `MEMORY.md` only for conventions; stay in scope; do not touch
|
|
160
|
+
`peerDependencies`/`dependencies`, only `devDependencies`; do not touch `src/index.js`'s exports; do
|
|
161
|
+
not update `MEMORY.md`; do NOT `git add`/`commit`/`push` — leave the change uncommitted in the working
|
|
162
|
+
tree for the orchestrator's independent review. WRITE and RUN the three required checks (exports test,
|
|
163
|
+
build + dist diff, package.json dependency diff) yourself to confirm — that is your only verification
|
|
164
|
+
run: do NOT run the full vitest suite as a "review" and do NOT run any review; the orchestrator does
|
|
165
|
+
both after you finish (though re-running `vitest run` in full is harmless and encouraged as a sanity
|
|
166
|
+
check since it's the existing test command, just don't treat it as satisfying the review step).
|