Instance communities retrieval

This commit is contained in:
myrmidex 2025-08-16 11:34:52 +02:00
parent 58848c934e
commit 25cae3c0e9
7 changed files with 1047 additions and 19 deletions

View file

@ -5,11 +5,14 @@
use Domains\Platform\Resources\PlatformChannelResource;
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Models\PlatformAccount;
use Domains\Platform\Models\PlatformInstance;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use Domains\Platform\Exceptions\ChannelException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cache;
class PlatformChannelsController extends BaseController
{
@ -271,4 +274,108 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
}
}
/**
* Get available communities for a platform instance
*/
public function getCommunities(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'type' => 'sometimes|string|in:Local,All,Subscribed',
'sort' => 'sometimes|string|in:Hot,Active,New,TopDay,TopWeek,TopMonth,TopYear,TopAll',
'limit' => 'sometimes|integer|min:1|max:100',
'page' => 'sometimes|integer|min:1',
'show_nsfw' => 'sometimes|boolean',
]);
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance to get auth token
$activeAccount = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->first();
if (!$activeAccount) {
return $this->sendError(
'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.',
[],
422
);
}
// Create cache key based on instance and parameters
$cacheKey = sprintf(
'communities:%s:%s:%s:%d:%d:%s',
$platformInstance->id,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false ? '1' : '0'
);
// Try to get communities from cache first (cache for 10 minutes)
$communities = Cache::remember($cacheKey, 600, function () use ($platformInstance, $activeAccount, $validated) {
$apiService = app(LemmyApiService::class, ['instance' => $platformInstance->url]);
return $apiService->listCommunities(
$activeAccount->settings['api_token'] ?? null,
$validated['type'] ?? 'Local',
$validated['sort'] ?? 'Active',
$validated['limit'] ?? 50,
$validated['page'] ?? 1,
$validated['show_nsfw'] ?? false
);
});
// Transform the response to include only relevant data and add helpful fields
$transformedCommunities = collect($communities['communities'] ?? [])->map(function ($item) {
$community = $item['community'] ?? [];
return [
'id' => $community['id'] ?? null,
'name' => $community['name'] ?? null,
'title' => $community['title'] ?? null,
'description' => $community['description'] ?? null,
'nsfw' => $community['nsfw'] ?? false,
'local' => $community['local'] ?? false,
'subscribers' => $item['counts']['subscribers'] ?? 0,
'posts' => $item['counts']['posts'] ?? 0,
'display_text' => sprintf(
'%s (%s subscribers)',
$community['title'] ?? $community['name'] ?? 'Unknown',
number_format($item['counts']['subscribers'] ?? 0)
),
];
});
return $this->sendResponse([
'communities' => $transformedCommunities,
'total' => $transformedCommunities->count(),
'platform_instance' => [
'id' => $platformInstance->id,
'name' => $platformInstance->name,
'url' => $platformInstance->url,
],
'parameters' => [
'type' => $validated['type'] ?? 'Local',
'sort' => $validated['sort'] ?? 'Active',
'limit' => $validated['limit'] ?? 50,
'page' => $validated['page'] ?? 1,
'show_nsfw' => $validated['show_nsfw'] ?? false,
]
], 'Communities retrieved successfully.');
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
// Clear cache on error to prevent serving stale data
if (isset($cacheKey)) {
Cache::forget($cacheKey);
}
return $this->sendError('Failed to fetch communities: ' . $e->getMessage(), [], 500);
}
}
}

View file

@ -57,6 +57,10 @@
->name('api.platform-accounts.set-active');
// Platform Channels
// NOTE: Specific routes must come before resource routes to avoid parameter conflicts
Route::get('/platform-channels/communities', [PlatformChannelsController::class, 'getCommunities'])
->name('api.platform-channels.get-communities');
Route::apiResource('platform-channels', PlatformChannelsController::class)->names([
'index' => 'api.platform-channels.index',
'store' => 'api.platform-channels.store',

View file

@ -246,4 +246,70 @@ public function getLanguages(): array
return [];
}
}
/**
* List communities on the instance with optional filtering
*
* @param string|null $token
* @param string $type Local, All, or Subscribed
* @param string $sort Hot, Active, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
* @param int $limit Maximum number of communities to return (default: 50)
* @param int $page Page number for pagination (default: 1)
* @param bool $showNsfw Whether to include NSFW communities (default: false)
* @return array<string, mixed>
* @throws Exception
*/
public function listCommunities(
?string $token = null,
string $type = 'Local',
string $sort = 'Active',
int $limit = 50,
int $page = 1,
bool $showNsfw = false
): array {
try {
$request = new LemmyRequest($this->instance, $token);
$params = [
'type_' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page,
'show_nsfw' => $showNsfw,
];
$response = $request->get('community/list', $params);
if (!$response->successful()) {
$statusCode = $response->status();
$responseBody = $response->body();
logger()->warning('Failed to fetch communities list', [
'status' => $statusCode,
'response' => $responseBody,
'params' => $params
]);
throw new Exception("Failed to fetch communities list: {$statusCode} - {$responseBody}");
}
$data = $response->json();
if (!isset($data['communities'])) {
logger()->warning('Invalid communities list response format', ['response' => $data]);
return ['communities' => []];
}
return $data;
} catch (Exception $e) {
logger()->error('Exception while fetching communities list', [
'error' => $e->getMessage(),
'type' => $type,
'sort' => $sort,
'limit' => $limit,
'page' => $page
]);
throw $e;
}
}
}

View file

@ -6,8 +6,10 @@
use Domains\Platform\Models\PlatformChannel;
use Domains\Platform\Models\PlatformInstance;
use Domains\Platform\Services\ChannelLanguageDetectionService;
use Domains\Platform\Api\Lemmy\LemmyApiService;
use Domains\Settings\Models\Language;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
use Mockery;
@ -270,4 +272,390 @@ public function test_toggle_deactivates_active_channel(): void
'is_active' => false
]);
}
public function test_get_communities_returns_successful_response(): void
{
// Arrange
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
'settings' => ['api_token' => 'test-token']
]);
// Mock LemmyApiService
$mockApiService = Mockery::mock(LemmyApiService::class);
$mockApiService->shouldReceive('listCommunities')
->with('test-token', 'Local', 'Active', 50, 1, false)
->once()
->andReturn([
'communities' => [
[
'community' => [
'id' => 1,
'name' => 'technology',
'title' => 'Technology',
'description' => 'Tech discussions',
'nsfw' => false,
'local' => true
],
'counts' => [
'subscribers' => 1500,
'posts' => 250
]
],
[
'community' => [
'id' => 2,
'name' => 'news',
'title' => 'News',
'description' => 'Latest news',
'nsfw' => false,
'local' => true
],
'counts' => [
'subscribers' => 2300,
'posts' => 450
]
]
]
]);
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
return $mockApiService;
});
// Act
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
// Assert
$response->assertStatus(200)
->assertJsonStructure([
'success',
'message',
'data' => [
'communities' => [
'*' => [
'id',
'name',
'title',
'description',
'nsfw',
'local',
'subscribers',
'posts',
'display_text'
]
],
'total',
'platform_instance' => [
'id',
'name',
'url'
],
'parameters' => [
'type',
'sort',
'limit',
'page',
'show_nsfw'
]
]
])
->assertJson([
'success' => true,
'message' => 'Communities retrieved successfully.',
'data' => [
'communities' => [
[
'id' => 1,
'name' => 'technology',
'title' => 'Technology',
'subscribers' => 1500,
'display_text' => 'Technology (1,500 subscribers)'
],
[
'id' => 2,
'name' => 'news',
'title' => 'News',
'subscribers' => 2300,
'display_text' => 'News (2,300 subscribers)'
]
],
'total' => 2,
'parameters' => [
'type' => 'Local',
'sort' => 'Active',
'limit' => 50,
'page' => 1,
'show_nsfw' => false
]
]
]);
}
public function test_get_communities_with_custom_parameters(): void
{
// Arrange
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
'settings' => ['api_token' => 'test-token']
]);
// Mock LemmyApiService with custom parameters
$mockApiService = Mockery::mock(LemmyApiService::class);
$mockApiService->shouldReceive('listCommunities')
->with('test-token', 'All', 'TopMonth', 25, 2, true)
->once()
->andReturn(['communities' => []]);
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
return $mockApiService;
});
// Act
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'type' => 'All',
'sort' => 'TopMonth',
'limit' => 25,
'page' => 2,
'show_nsfw' => true
]));
// Assert
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'parameters' => [
'type' => 'All',
'sort' => 'TopMonth',
'limit' => 25,
'page' => 2,
'show_nsfw' => true
]
]
]);
}
public function test_get_communities_validates_required_platform_instance_id(): void
{
$response = $this->getJson('/api/v1/platform-channels/communities');
$response->assertStatus(422)
->assertJsonValidationErrors(['platform_instance_id']);
}
public function test_get_communities_validates_platform_instance_exists(): void
{
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=999');
$response->assertStatus(422)
->assertJsonValidationErrors(['platform_instance_id']);
}
public function test_get_communities_validates_type_parameter(): void
{
$instance = PlatformInstance::factory()->create();
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'type' => 'InvalidType'
]));
$response->assertStatus(422)
->assertJsonValidationErrors(['type']);
}
public function test_get_communities_validates_sort_parameter(): void
{
$instance = PlatformInstance::factory()->create();
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'sort' => 'InvalidSort'
]));
$response->assertStatus(422)
->assertJsonValidationErrors(['sort']);
}
public function test_get_communities_validates_limit_parameter(): void
{
$instance = PlatformInstance::factory()->create();
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'limit' => 0 // Invalid: below minimum
]));
$response->assertStatus(422)
->assertJsonValidationErrors(['limit']);
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'limit' => 101 // Invalid: above maximum
]));
$response->assertStatus(422)
->assertJsonValidationErrors(['limit']);
}
public function test_get_communities_validates_page_parameter(): void
{
$instance = PlatformInstance::factory()->create();
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
'platform_instance_id' => $instance->id,
'page' => 0 // Invalid: below minimum
]));
$response->assertStatus(422)
->assertJsonValidationErrors(['page']);
}
public function test_get_communities_fails_when_no_active_account_exists(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
// Create an inactive account
PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => false
]);
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
$response->assertStatus(422)
->assertJson([
'success' => false,
'message' => 'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.'
]);
}
public function test_get_communities_uses_cache(): void
{
// Arrange
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
'settings' => ['api_token' => 'test-token']
]);
$mockData = ['communities' => [['community' => ['id' => 1, 'name' => 'test']]]];
$cacheKey = "communities:{$instance->id}:Local:Active:50:1:0";
// Set cache data
Cache::put($cacheKey, $mockData, 600);
// Mock should not be called since cache hit
$mockApiService = Mockery::mock(LemmyApiService::class);
$mockApiService->shouldNotReceive('listCommunities');
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
return $mockApiService;
});
// Act
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
// Assert
$response->assertStatus(200)
->assertJson(['success' => true]);
}
public function test_get_communities_clears_cache_on_error(): void
{
// Arrange
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
'settings' => ['api_token' => 'test-token']
]);
$cacheKey = "communities:{$instance->id}:Local:Active:50:1:0";
// Mock LemmyApiService to throw exception
$mockApiService = Mockery::mock(LemmyApiService::class);
$mockApiService->shouldReceive('listCommunities')
->once()
->andThrow(new \Exception('API Error'));
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
return $mockApiService;
});
// Ensure cache is empty initially so API service gets called
Cache::forget($cacheKey);
$this->assertFalse(Cache::has($cacheKey));
// Act
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
// Assert
$response->assertStatus(500);
// Cache should still not exist since the error prevented caching
$this->assertFalse(Cache::has($cacheKey));
}
public function test_get_communities_handles_missing_community_data_gracefully(): void
{
// Arrange
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
'settings' => ['api_token' => 'test-token']
]);
// Mock LemmyApiService with incomplete community data
$mockApiService = Mockery::mock(LemmyApiService::class);
$mockApiService->shouldReceive('listCommunities')
->once()
->andReturn([
'communities' => [
[
'community' => [
'id' => 1,
'name' => 'minimal',
// Missing title, description, etc.
],
// Missing counts
]
]
]);
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
return $mockApiService;
});
// Act
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
// Assert
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'communities' => [
[
'id' => 1,
'name' => 'minimal',
'title' => null,
'description' => null,
'nsfw' => false,
'local' => false,
'subscribers' => 0,
'posts' => 0,
'display_text' => 'minimal (0 subscribers)'
]
]
]
]);
}
}

View file

@ -426,4 +426,260 @@ public function test_get_languages_returns_empty_when_all_languages_missing(): v
$this->assertEquals([], $languages);
}
public function test_list_communities_success_with_default_parameters(): void
{
Http::fake([
'*' => Http::response([
'communities' => [
[
'community' => [
'id' => 1,
'name' => 'technology',
'title' => 'Technology Community',
'description' => 'All about tech',
'nsfw' => false
]
],
[
'community' => [
'id' => 2,
'name' => 'news',
'title' => 'News Community',
'description' => 'Latest news',
'nsfw' => false
]
]
]
], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities();
$this->assertArrayHasKey('communities', $result);
$this->assertCount(2, $result['communities']);
$this->assertEquals('technology', $result['communities'][0]['community']['name']);
$this->assertEquals('news', $result['communities'][1]['community']['name']);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/api/v3/community/list')
&& str_contains($request->url(), 'type_=Local')
&& str_contains($request->url(), 'sort=Active')
&& str_contains($request->url(), 'limit=50')
&& str_contains($request->url(), 'page=1')
&& str_contains($request->url(), 'show_nsfw=');
});
}
public function test_list_communities_success_with_custom_parameters(): void
{
Http::fake([
'*' => Http::response([
'communities' => [
[
'community' => [
'id' => 3,
'name' => 'gaming',
'title' => 'Gaming Community',
'description' => 'Gaming discussions',
'nsfw' => false
]
]
]
], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities(
'test-token',
'All',
'TopMonth',
25,
2,
true
);
$this->assertArrayHasKey('communities', $result);
$this->assertCount(1, $result['communities']);
$this->assertEquals('gaming', $result['communities'][0]['community']['name']);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/api/v3/community/list')
&& str_contains($request->url(), 'type_=All')
&& str_contains($request->url(), 'sort=TopMonth')
&& str_contains($request->url(), 'limit=25')
&& str_contains($request->url(), 'page=2')
&& str_contains($request->url(), 'show_nsfw=1')
&& $request->header('Authorization')[0] === 'Bearer test-token';
});
}
public function test_list_communities_success_without_token(): void
{
Http::fake([
'*' => Http::response([
'communities' => []
], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities();
$this->assertArrayHasKey('communities', $result);
$this->assertEmpty($result['communities']);
Http::assertSent(function ($request) {
return !$request->hasHeader('Authorization');
});
}
public function test_list_communities_throws_on_unsuccessful_response(): void
{
Http::fake([
'*' => Http::response('Server Error', 500)
]);
Log::shouldReceive('warning')->once()->with('Failed to fetch communities list', Mockery::any());
Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any());
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
$this->expectExceptionMessage('Failed to fetch communities list: 500 - Server Error');
$service->listCommunities('token');
}
public function test_list_communities_throws_on_404_response(): void
{
Http::fake([
'*' => Http::response('Not Found', 404)
]);
Log::shouldReceive('warning')->once();
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
$this->expectExceptionMessage('Failed to fetch communities list: 404');
$service->listCommunities('token');
}
public function test_list_communities_returns_empty_communities_on_invalid_response_format(): void
{
Http::fake([
'*' => Http::response([
'invalid_format' => 'data'
], 200)
]);
Log::shouldReceive('warning')->once()->with('Invalid communities list response format', Mockery::any());
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities();
$this->assertArrayHasKey('communities', $result);
$this->assertEmpty($result['communities']);
}
public function test_list_communities_handles_exception(): void
{
Http::fake(function () {
throw new Exception('Network error');
});
Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any());
$service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class);
$this->expectExceptionMessage('Network error');
$service->listCommunities('token');
}
public function test_list_communities_with_all_sort_options(): void
{
$sortOptions = ['Hot', 'Active', 'New', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll'];
foreach ($sortOptions as $sort) {
Http::fake([
'*' => Http::response(['communities' => []], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities(null, 'Local', $sort);
$this->assertArrayHasKey('communities', $result);
Http::assertSent(function ($request) use ($sort) {
return str_contains($request->url(), "sort={$sort}");
});
}
}
public function test_list_communities_with_all_type_options(): void
{
$typeOptions = ['Local', 'All', 'Subscribed'];
foreach ($typeOptions as $type) {
Http::fake([
'*' => Http::response(['communities' => []], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities(null, $type);
$this->assertArrayHasKey('communities', $result);
Http::assertSent(function ($request) use ($type) {
return str_contains($request->url(), "type_={$type}");
});
}
}
public function test_list_communities_pagination_parameters(): void
{
Http::fake([
'*' => Http::response(['communities' => []], 200)
]);
$service = new LemmyApiService('lemmy.world');
$result = $service->listCommunities(null, 'Local', 'Active', 100, 3);
$this->assertArrayHasKey('communities', $result);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'limit=100')
&& str_contains($request->url(), 'page=3');
});
}
public function test_list_communities_nsfw_parameter(): void
{
Http::fake([
'*' => Http::response(['communities' => []], 200)
]);
$service = new LemmyApiService('lemmy.world');
// Test with NSFW enabled
$result = $service->listCommunities(null, 'Local', 'Active', 50, 1, true);
$this->assertArrayHasKey('communities', $result);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'show_nsfw=1');
});
// Test with NSFW disabled
$result = $service->listCommunities(null, 'Local', 'Active', 50, 1, false);
$this->assertArrayHasKey('communities', $result);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'show_nsfw=');
});
}
}

View file

@ -248,6 +248,44 @@ export interface KeywordRequest {
is_active?: boolean;
}
export interface Community {
id: number;
name: string;
title: string | null;
description: string | null;
nsfw: boolean;
local: boolean;
subscribers: number;
posts: number;
display_text: string;
}
export interface CommunitiesResponse {
communities: Community[];
total: number;
platform_instance: {
id: number;
name: string;
url: string;
};
parameters: {
type: string;
sort: string;
limit: number;
page: number;
show_nsfw: boolean;
};
}
export interface CommunitiesRequest {
platform_instance_id: number;
type?: 'Local' | 'All' | 'Subscribed';
sort?: 'Hot' | 'Active' | 'New' | 'TopDay' | 'TopWeek' | 'TopMonth' | 'TopYear' | 'TopAll';
limit?: number;
page?: number;
show_nsfw?: boolean;
}
// API Client class
class ApiClient {
constructor() {
@ -475,6 +513,14 @@ class ApiClient {
async deletePlatformAccount(id: number): Promise<void> {
await axios.delete(`/platform-accounts/${id}`);
}
// Platform Communities endpoints
async getPlatformCommunities(params: CommunitiesRequest): Promise<CommunitiesResponse> {
const response = await axios.get<ApiResponse<CommunitiesResponse>>('/platform-channels/communities', {
params
});
return response.data.data;
}
}
export const apiClient = new ApiClient();

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
import { apiClient, type ChannelRequest, type Language, type PlatformInstance, type Community } from '../../../lib/api';
const ChannelStep: React.FC = () => {
const navigate = useNavigate();
@ -13,6 +13,10 @@ const ChannelStep: React.FC = () => {
description: ''
});
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [searchTerm, setSearchTerm] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const [, setSelectedCommunity] = useState<Community | null>(null);
const [manualInput, setManualInput] = useState(false);
// Get onboarding options (languages, platform instances)
const { data: options, isLoading: optionsLoading } = useQuery({
@ -27,6 +31,31 @@ const ChannelStep: React.FC = () => {
retry: false,
});
// Fetch communities when platform instance is selected
const { data: communitiesData, isLoading: communitiesLoading } = useQuery({
queryKey: ['communities', formData.platform_instance_id],
queryFn: () => apiClient.getPlatformCommunities({
platform_instance_id: formData.platform_instance_id,
type: 'Local',
sort: 'Active',
limit: 100
}),
enabled: formData.platform_instance_id > 0 && !manualInput,
retry: false,
});
// Filter communities based on search term
const filteredCommunities = useMemo(() => {
if (!communitiesData?.communities) return [];
if (!searchTerm) return communitiesData.communities;
return communitiesData.communities.filter(community =>
community.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
community.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
community.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [communitiesData?.communities, searchTerm]);
// Pre-fill form with existing data
useEffect(() => {
if (channels && channels.length > 0) {
@ -37,9 +66,34 @@ const ChannelStep: React.FC = () => {
language_id: firstChannel.language_id || 0,
description: firstChannel.description || ''
});
setSearchTerm(firstChannel.name || '');
}
}, [channels]);
// Reset community selection when platform instance changes
useEffect(() => {
setSelectedCommunity(null);
setSearchTerm('');
if (formData.platform_instance_id === 0) {
setManualInput(false);
}
}, [formData.platform_instance_id]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('[data-dropdown]')) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [showDropdown]);
const createChannelMutation = useMutation({
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
onSuccess: () => {
@ -47,11 +101,12 @@ const ChannelStep: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
navigate('/onboarding/route');
},
onError: (error: any) => {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
onError: (error: unknown) => {
const errorData = error as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } };
if (errorData.response?.data?.errors) {
setErrors(errorData.response.data.errors);
} else {
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
setErrors({ general: [errorData.response?.data?.message || 'An error occurred'] });
}
}
});
@ -70,6 +125,34 @@ const ChannelStep: React.FC = () => {
}
};
const handleCommunitySelect = (community: Community) => {
setSelectedCommunity(community);
setFormData(prev => ({ ...prev, name: community.name }));
setSearchTerm(community.name);
setShowDropdown(false);
if (errors.name) {
setErrors(prev => ({ ...prev, name: [] }));
}
};
const handleSearchChange = (value: string) => {
setSearchTerm(value);
setFormData(prev => ({ ...prev, name: value }));
setShowDropdown(true);
setSelectedCommunity(null);
if (errors.name) {
setErrors(prev => ({ ...prev, name: [] }));
}
};
const toggleManualInput = () => {
setManualInput(prev => !prev);
setSelectedCommunity(null);
setSearchTerm('');
setFormData(prev => ({ ...prev, name: '' }));
setShowDropdown(false);
};
if (optionsLoading) {
return <div className="text-center">Loading...</div>;
}
@ -97,19 +180,97 @@ const ChannelStep: React.FC = () => {
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Community Name
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="technology"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
<p className="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
<div className="flex items-center justify-between mb-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Community Name
</label>
{formData.platform_instance_id > 0 && (
<button
type="button"
onClick={toggleManualInput}
className="text-sm text-blue-600 hover:text-blue-800"
>
{manualInput ? 'Use dropdown' : 'Enter manually'}
</button>
)}
</div>
{manualInput || formData.platform_instance_id === 0 ? (
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="technology"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
) : (
<div className="relative" data-dropdown>
<input
type="text"
id="name"
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
onFocus={() => setShowDropdown(true)}
placeholder="Search communities..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
{communitiesLoading && (
<div className="absolute right-3 top-3">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
</div>
)}
{showDropdown && !communitiesLoading && filteredCommunities.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{filteredCommunities.slice(0, 10).map((community) => (
<button
key={community.id}
type="button"
onClick={() => handleCommunitySelect(community)}
className="w-full text-left px-3 py-2 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none border-b border-gray-100 last:border-b-0"
>
<div className="font-medium text-gray-900">{community.title || community.name}</div>
<div className="text-sm text-gray-500">
!{community.name} {community.subscribers.toLocaleString()} subscribers
</div>
{community.description && (
<div className="text-xs text-gray-400 mt-1 truncate">{community.description}</div>
)}
</button>
))}
{filteredCommunities.length > 10 && (
<div className="px-3 py-2 text-sm text-gray-500 bg-gray-50">
And {filteredCommunities.length - 10} more communities...
</div>
)}
</div>
)}
{showDropdown && !communitiesLoading && searchTerm && filteredCommunities.length === 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg p-3">
<div className="text-sm text-gray-500">No communities found matching "{searchTerm}"</div>
<button
type="button"
onClick={toggleManualInput}
className="text-sm text-blue-600 hover:text-blue-800 mt-1"
>
Enter community name manually
</button>
</div>
)}
</div>
)}
<p className="text-sm text-gray-500 mt-1">
{manualInput || formData.platform_instance_id === 0
? 'Enter the community name (without the @ or instance)'
: 'Search and select from available communities, or enter manually'
}
</p>
{errors.name && (
<p className="text-red-600 text-sm mt-1">{errors.name[0]}</p>
)}