feat(web): add shared useSpaceJoin hook over exploreStore
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. The
|
||||
// hook imports exploreStore, which transitively pulls in spaceStore ->
|
||||
// AudioManager; the audio worklet is unavailable under jsdom.
|
||||
vi.mock('../audio/AudioManager', () => ({
|
||||
AudioManager: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
setOutputDevice: vi.fn(),
|
||||
setVolume: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useSpaceJoin } from './useSpaceJoin';
|
||||
import { useExploreStore, type TaggedExploreSpace } from '../stores/exploreStore';
|
||||
|
||||
function makeSpace(overrides: Partial<TaggedExploreSpace> = {}): TaggedExploreSpace {
|
||||
return {
|
||||
id: 's1',
|
||||
name: 'Test Space',
|
||||
icon: null,
|
||||
banner: null,
|
||||
avatarColor: null,
|
||||
description: null,
|
||||
visibility: 'public',
|
||||
memberCount: 3,
|
||||
createdAt: 0,
|
||||
joined: false,
|
||||
_instanceOrigin: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useExploreStore.setState({
|
||||
myRequests: [],
|
||||
publicJoin: vi.fn().mockResolvedValue({ id: 's1', name: 'Test Space' }),
|
||||
requestJoin: vi.fn().mockResolvedValue({ id: 'req1', spaceId: 's1', status: 'pending' }),
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSpaceJoin', () => {
|
||||
it('reports public/joined/pending flags from the space and store', () => {
|
||||
const { result } = renderHook(() => useSpaceJoin(makeSpace()));
|
||||
expect(result.current.isPublic).toBe(true);
|
||||
expect(result.current.isJoined).toBe(false);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it('derives isPending from a matching pending request in the store', () => {
|
||||
useExploreStore.setState({
|
||||
myRequests: [{ id: 'r', spaceId: 's1', status: 'pending' } as never],
|
||||
});
|
||||
const { result } = renderHook(() => useSpaceJoin(makeSpace({ visibility: 'request' })));
|
||||
expect(result.current.isPending).toBe(true);
|
||||
});
|
||||
|
||||
it('join() calls publicJoin and returns the full space', async () => {
|
||||
const { result } = renderHook(() => useSpaceJoin(makeSpace()));
|
||||
let full: unknown;
|
||||
await act(async () => { full = await result.current.join(); });
|
||||
expect(useExploreStore.getState().publicJoin).toHaveBeenCalled();
|
||||
expect((full as { id: string }).id).toBe('s1');
|
||||
});
|
||||
|
||||
it('join() surfaces an error and returns null on failure', async () => {
|
||||
useExploreStore.setState({ publicJoin: vi.fn().mockRejectedValue(new Error('nope')) });
|
||||
const { result } = renderHook(() => useSpaceJoin(makeSpace()));
|
||||
let full: unknown = 'unset';
|
||||
await act(async () => { full = await result.current.join(); });
|
||||
expect(full).toBeNull();
|
||||
expect(result.current.joinError).toBe('nope');
|
||||
expect(result.current.joining).toBe(false);
|
||||
});
|
||||
|
||||
it('sendRequest() calls requestJoin and flips to pending', async () => {
|
||||
const { result } = renderHook(() => useSpaceJoin(makeSpace({ visibility: 'request' })));
|
||||
act(() => result.current.setRequestMessage(' please '));
|
||||
await act(async () => { await result.current.sendRequest(); });
|
||||
expect(useExploreStore.getState().requestJoin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 's1' }), 'please',
|
||||
);
|
||||
await waitFor(() => expect(result.current.isPending).toBe(true));
|
||||
expect(result.current.showRequestForm).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useExploreStore, type TaggedExploreSpace } from '../stores/exploreStore';
|
||||
import type { SpaceWithChannelsAndMembers } from '@backspace/shared';
|
||||
|
||||
export interface SpaceJoinControls {
|
||||
isJoined: boolean;
|
||||
isPublic: boolean;
|
||||
isPending: boolean;
|
||||
joining: boolean;
|
||||
joinError: string;
|
||||
showRequestForm: boolean;
|
||||
requestMessage: string;
|
||||
setRequestMessage: (v: string) => void;
|
||||
openRequestForm: () => void;
|
||||
cancelRequestForm: () => void;
|
||||
join: () => Promise<SpaceWithChannelsAndMembers | null>;
|
||||
sendRequest: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared join/request state machine over exploreStore, used by both the full
|
||||
* Explore SpaceCard and the compact JoinSpace modal preview card. Single source
|
||||
* of truth so the two surfaces cannot drift.
|
||||
*/
|
||||
export function useSpaceJoin(space: TaggedExploreSpace): SpaceJoinControls {
|
||||
const publicJoin = useExploreStore((s) => s.publicJoin);
|
||||
const requestJoin = useExploreStore((s) => s.requestJoin);
|
||||
const myRequests = useExploreStore((s) => s.myRequests);
|
||||
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [joinError, setJoinError] = useState('');
|
||||
const [showRequestForm, setShowRequestForm] = useState(false);
|
||||
const [requestMessage, setRequestMessage] = useState('');
|
||||
const [localRequestSent, setLocalRequestSent] = useState(false);
|
||||
|
||||
const isJoined = space.joined === true;
|
||||
const isPublic = space.visibility === 'public';
|
||||
const isPending =
|
||||
localRequestSent ||
|
||||
myRequests.some((r) => r.spaceId === space.id && r.status === 'pending');
|
||||
|
||||
// On success the caller navigates away and this component unmounts, so we do
|
||||
// not reset `joining` — matches the pre-refactor SpaceCard behavior and
|
||||
// avoids a flash of the enabled button before navigation.
|
||||
const join = async (): Promise<SpaceWithChannelsAndMembers | null> => {
|
||||
setJoining(true);
|
||||
setJoinError('');
|
||||
try {
|
||||
return await publicJoin(space);
|
||||
} catch (err) {
|
||||
setJoinError(err instanceof Error ? err.message : 'Failed to join');
|
||||
setJoining(false);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sendRequest = async (): Promise<void> => {
|
||||
setJoining(true);
|
||||
setJoinError('');
|
||||
try {
|
||||
await requestJoin(space, requestMessage.trim() || undefined);
|
||||
setLocalRequestSent(true);
|
||||
setShowRequestForm(false);
|
||||
} catch (err) {
|
||||
setJoinError(err instanceof Error ? err.message : 'Failed to send request');
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isJoined,
|
||||
isPublic,
|
||||
isPending,
|
||||
joining,
|
||||
joinError,
|
||||
showRequestForm,
|
||||
requestMessage,
|
||||
setRequestMessage,
|
||||
openRequestForm: () => setShowRequestForm(true),
|
||||
cancelRequestForm: () => setShowRequestForm(false),
|
||||
join,
|
||||
sendRequest,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user