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