3d08fa42f8
Phase C of channel-roster import. Adds the optional addMembers(threadId, userIds) adapter seam (+ BulkAddResult) and, in ConversationSettings, a '#' mode on the existing Add-people box that lists the channels you belong to — public AND private, since the source list is your own membership-scoped conversation list. Picking a channel STAGES the import (reads its roster, diffs against who is already here) and shows a confirm — 'Add 9 people from #design? (3 already here)' — so an administrative action never fires on a stray click. The result line reports added / already-a-member / failed. Absent addMembers => the affordance is hidden entirely and '#' is just a search string. Bumped to 0.1.8. SDK suite: 15 files / 78 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { MessagingProvider } from '../provider';
|
|
import { MockAdapter } from '../adapters/mock';
|
|
import { ConversationSettings } from './conversation-settings';
|
|
|
|
function mount(adapter = new MockAdapter()) {
|
|
render(
|
|
<MessagingProvider adapter={adapter}>
|
|
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
|
|
</MessagingProvider>,
|
|
);
|
|
return adapter;
|
|
}
|
|
|
|
describe('MockAdapter member management', () => {
|
|
it('adds and removes a member, and renames', async () => {
|
|
const a = new MockAdapter();
|
|
await a.addMember('th_mock_2', 'pp_sofia');
|
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
|
await a.removeMember('th_mock_2', 'pp_sofia');
|
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(false);
|
|
await a.renameConversation('th_mock_2', 'Renamed');
|
|
expect((await a.listConversations()).find((c) => c.threadId === 'th_mock_2')?.title).toBe('Renamed');
|
|
});
|
|
});
|
|
|
|
describe('<ConversationSettings />', () => {
|
|
it('lists members and adds one from the directory', async () => {
|
|
const a = mount();
|
|
// A current member is shown.
|
|
await screen.findByText('Dan Whitaker');
|
|
// Add an addable person from the directory.
|
|
const sofia = await screen.findByText('Sofia Ramirez');
|
|
fireEvent.click(sofia);
|
|
await waitFor(async () => {
|
|
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('“#” switches the picker to channels you belong to, excluding this one', async () => {
|
|
mount();
|
|
await screen.findByText('Dan Whitaker');
|
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
|
// Channels the mock user is in show up as import sources…
|
|
await screen.findByText('#general');
|
|
// …and the conversation being edited is never offered as its own source.
|
|
expect(screen.queryByText('#Storm crew')).toBeNull();
|
|
});
|
|
|
|
it('imports a channel roster only after confirmation, and reports what landed', async () => {
|
|
const a = mount();
|
|
await screen.findByText('Dan Whitaker');
|
|
const before = (await a.listMembers('th_mock_2')).length;
|
|
|
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
|
fireEvent.click(await screen.findByText('#general'));
|
|
|
|
// Staged, NOT applied — picking a channel must not mutate membership on its own.
|
|
const confirm = await screen.findByRole('button', { name: /^Add \d+$/ });
|
|
expect((await a.listMembers('th_mock_2')).length).toBe(before);
|
|
|
|
fireEvent.click(confirm);
|
|
await waitFor(async () => {
|
|
expect((await a.listMembers('th_mock_2')).length).toBeGreaterThan(before);
|
|
});
|
|
await screen.findByText(/Added \d+/);
|
|
});
|
|
|
|
it('cancelling a staged import leaves membership untouched', async () => {
|
|
const a = mount();
|
|
await screen.findByText('Dan Whitaker');
|
|
const before = (await a.listMembers('th_mock_2')).length;
|
|
|
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
|
fireEvent.click(await screen.findByText('#general'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
|
|
await screen.findByLabelText('Search people'); // back to the picker
|
|
expect((await a.listMembers('th_mock_2')).length).toBe(before);
|
|
});
|
|
|
|
it('hides the channel-import affordance when the adapter cannot bulk-add', async () => {
|
|
const a = new MockAdapter();
|
|
// A host that implements single add but not addMembers => no import path offered.
|
|
(a as { addMembers?: unknown }).addMembers = undefined;
|
|
render(
|
|
<MessagingProvider adapter={a}>
|
|
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
|
|
</MessagingProvider>,
|
|
);
|
|
await screen.findByText('Dan Whitaker');
|
|
expect(screen.getByLabelText('Search people').getAttribute('placeholder')).toBe('Search people…');
|
|
fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } });
|
|
expect(screen.queryByText('#general')).toBeNull(); // '#' is just a search string here
|
|
});
|
|
});
|