Fix feed form so user can only choose from dropdown

This commit is contained in:
myrmidex 2025-08-09 17:05:11 +02:00
parent 696e2b5235
commit e495f49481
8 changed files with 195 additions and 76 deletions

View file

@ -47,6 +47,19 @@ public function store(StoreFeedRequest $request): JsonResponse
$validated = $request->validated(); $validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true; $validated['is_active'] = $validated['is_active'] ?? true;
// Map provider to URL and set type
$providers = [
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
];
$adapter = $providers[$validated['provider']];
$validated['url'] = $adapter->getHomepageUrl();
$validated['type'] = 'website';
// Remove provider from validated data as it's not a database column
unset($validated['provider']);
$feed = Feed::create($validated); $feed = Feed::create($validated);
return $this->sendResponse( return $this->sendResponse(

View file

@ -90,11 +90,18 @@ public function options(): JsonResponse
->orderBy('name') ->orderBy('name')
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Expose supported feed providers so frontend can offer only VRT and Belga
$feedProviders = [
['code' => 'vrt', 'name' => (new \App\Services\Parsers\VrtHomepageParserAdapter())->getSourceName()],
['code' => 'belga', 'name' => (new \App\Services\Parsers\BelgaHomepageParserAdapter())->getSourceName()],
];
return $this->sendResponse([ return $this->sendResponse([
'languages' => $languages, 'languages' => $languages,
'platform_instances' => $platformInstances, 'platform_instances' => $platformInstances,
'feeds' => $feeds, 'feeds' => $feeds,
'platform_channels' => $platformChannels, 'platform_channels' => $platformChannels,
'feed_providers' => $feedProviders,
], 'Onboarding options retrieved successfully.'); ], 'Onboarding options retrieved successfully.');
} }
@ -190,8 +197,7 @@ public function createFeed(Request $request): JsonResponse
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'url' => 'required|url|max:500', 'provider' => 'required|string|in:vrt,belga',
'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000', 'description' => 'nullable|string|max:1000',
]); ]);
@ -202,10 +208,25 @@ public function createFeed(Request $request): JsonResponse
$validated = $validator->validated(); $validated = $validator->validated();
// Supported providers and their canonical definitions
$providers = [
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
];
$adapter = $providers[$validated['provider']];
$finalUrl = $adapter->getHomepageUrl();
$finalName = $validated['name'];
// Keep user provided name, but default to provider name if empty/blank
if (trim($finalName) === '') {
$finalName = $adapter->getSourceName();
}
$feed = Feed::create([ $feed = Feed::create([
'name' => $validated['name'], 'name' => $finalName,
'url' => $validated['url'], 'url' => $finalUrl,
'type' => $validated['type'], 'type' => 'website',
'language_id' => $validated['language_id'], 'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'is_active' => true, 'is_active' => true,

View file

@ -18,8 +18,7 @@ public function rules(): array
{ {
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url', 'provider' => 'required|in:vrt,belga',
'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean'

View file

@ -10,7 +10,13 @@ public function run(): void
{ {
$this->call([ $this->call([
SettingsSeeder::class, SettingsSeeder::class,
]);
// Seed languages in local/dev environment only to avoid conflicts in tests
if (app()->environment('local')) {
$this->call([
LanguageSeeder::class, LanguageSeeder::class,
]); ]);
} }
} }
}

View file

@ -47,14 +47,13 @@ public function test_index_returns_feeds_ordered_by_active_status_then_name(): v
$this->assertFalse($feeds[2]['is_active']); $this->assertFalse($feeds[2]['is_active']);
} }
public function test_store_creates_feed_successfully(): void public function test_store_creates_vrt_feed_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$feedData = [ $feedData = [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/feed.xml', 'provider' => 'vrt',
'type' => 'rss',
'language_id' => $language->id, 'language_id' => $language->id,
'is_active' => true, 'is_active' => true,
]; ];
@ -66,17 +65,49 @@ public function test_store_creates_feed_successfully(): void
'success' => true, 'success' => true,
'message' => 'Feed created successfully!', 'message' => 'Feed created successfully!',
'data' => [ 'data' => [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/feed.xml', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'rss', 'type' => 'website',
'is_active' => true, 'is_active' => true,
] ]
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/feed.xml', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'rss', 'type' => 'website',
]);
}
public function test_store_creates_belga_feed_successfully(): void
{
$language = Language::factory()->create();
$feedData = [
'name' => 'Belga Test Feed',
'provider' => 'belga',
'language_id' => $language->id,
'is_active' => true,
];
$response = $this->postJson('/api/v1/feeds', $feedData);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Feed created successfully!',
'data' => [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'is_active' => true,
]
]);
$this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
]); ]);
} }
@ -86,8 +117,7 @@ public function test_store_sets_default_active_status(): void
$feedData = [ $feedData = [
'name' => 'Test Feed', 'name' => 'Test Feed',
'url' => 'https://example.com/feed.xml', 'provider' => 'vrt',
'type' => 'rss',
'language_id' => $language->id, 'language_id' => $language->id,
// Not setting is_active // Not setting is_active
]; ];
@ -107,7 +137,23 @@ public function test_store_validates_required_fields(): void
$response = $this->postJson('/api/v1/feeds', []); $response = $this->postJson('/api/v1/feeds', []);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['name', 'url', 'type']); ->assertJsonValidationErrors(['name', 'provider', 'language_id']);
}
public function test_store_rejects_invalid_provider(): void
{
$language = Language::factory()->create();
$feedData = [
'name' => 'Invalid Feed',
'provider' => 'invalid',
'language_id' => $language->id,
];
$response = $this->postJson('/api/v1/feeds', $feedData);
$response->assertStatus(422)
->assertJsonValidationErrors(['provider']);
} }
public function test_show_returns_feed_successfully(): void public function test_show_returns_feed_successfully(): void

View file

@ -191,15 +191,14 @@ public function test_create_feed_validates_required_fields()
$response = $this->postJson('/api/v1/onboarding/feed', []); $response = $this->postJson('/api/v1/onboarding/feed', []);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['name', 'url', 'type', 'language_id']); ->assertJsonValidationErrors(['name', 'provider', 'language_id']);
} }
public function test_create_feed_creates_feed_successfully() public function test_create_feed_creates_vrt_feed_successfully()
{ {
$feedData = [ $feedData = [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/rss', 'provider' => 'vrt',
'type' => 'rss',
'language_id' => 1, 'language_id' => 1,
'description' => 'Test description', 'description' => 'Test description',
]; ];
@ -210,22 +209,68 @@ public function test_create_feed_creates_feed_successfully()
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/rss', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'rss', 'type' => 'website',
'is_active' => true, 'is_active' => true,
] ]
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
'name' => 'Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://example.com/rss', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'rss', 'type' => 'website',
'language_id' => 1, 'language_id' => 1,
'is_active' => true, 'is_active' => true,
]); ]);
} }
public function test_create_feed_creates_belga_feed_successfully()
{
$feedData = [
'name' => 'Belga Test Feed',
'provider' => 'belga',
'language_id' => 1,
'description' => 'Test description',
];
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'is_active' => true,
]
]);
$this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'language_id' => 1,
'is_active' => true,
]);
}
public function test_create_feed_rejects_invalid_provider()
{
$feedData = [
'name' => 'Invalid Feed',
'provider' => 'invalid',
'language_id' => 1,
'description' => 'Test description',
];
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
$response->assertStatus(422)
->assertJsonValidationErrors(['provider']);
}
public function test_create_channel_validates_required_fields() public function test_create_channel_validates_required_fields()
{ {
$response = $this->postJson('/api/v1/onboarding/channel', []); $response = $this->postJson('/api/v1/onboarding/channel', []);

View file

@ -155,11 +155,17 @@ export interface OnboardingStatus {
onboarding_skipped: boolean; onboarding_skipped: boolean;
} }
export interface FeedProvider {
code: string;
name: string;
}
export interface OnboardingOptions { export interface OnboardingOptions {
languages: Language[]; languages: Language[];
platform_instances: PlatformInstance[]; platform_instances: PlatformInstance[];
feeds: Feed[]; feeds: Feed[];
platform_channels: PlatformChannel[]; platform_channels: PlatformChannel[];
feed_providers: FeedProvider[];
} }
export interface PlatformAccountRequest { export interface PlatformAccountRequest {
@ -171,8 +177,7 @@ export interface PlatformAccountRequest {
export interface FeedRequest { export interface FeedRequest {
name: string; name: string;
url: string; provider: 'vrt' | 'belga';
type: 'rss' | 'website';
language_id: number; language_id: number;
description?: string; description?: string;
} }

View file

@ -1,14 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient, type FeedRequest, type Language } from '../../../lib/api'; import { apiClient, type FeedRequest, type Language, type FeedProvider } from '../../../lib/api';
const FeedStep: React.FC = () => { const FeedStep: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [formData, setFormData] = useState<FeedRequest>({ const [formData, setFormData] = useState<FeedRequest>({
name: '', name: '',
url: '', provider: 'vrt',
type: 'rss',
language_id: 0, language_id: 0,
description: '' description: ''
}); });
@ -56,7 +55,7 @@ const FeedStep: React.FC = () => {
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p className="text-gray-600"> <p className="text-gray-600">
Add a RSS feed or website to monitor for new articles Choose from our supported news providers to monitor for new articles
</p> </p>
{/* Progress indicator */} {/* Progress indicator */}
@ -93,40 +92,25 @@ const FeedStep: React.FC = () => {
</div> </div>
<div> <div>
<label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="provider" className="block text-sm font-medium text-gray-700 mb-2">
Feed URL News Provider
</label>
<input
type="url"
id="url"
value={formData.url}
onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://example.com/rss.xml"
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
/>
{errors.url && (
<p className="text-red-600 text-sm mt-1">{errors.url[0]}</p>
)}
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-2">
Feed Type
</label> </label>
<select <select
id="type" id="provider"
value={formData.type} value={formData.provider}
onChange={(e) => handleChange('type', e.target.value)} onChange={(e) => handleChange('provider', e.target.value)}
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" 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 required
> >
<option value="">Select feed type</option> <option value="">Select news provider</option>
<option value="rss">RSS Feed</option> {options?.feed_providers?.map((provider: FeedProvider) => (
<option value="website">Website</option> <option key={provider.code} value={provider.code}>
{provider.name}
</option>
))}
</select> </select>
{errors.type && ( {errors.provider && (
<p className="text-red-600 text-sm mt-1">{errors.type[0]}</p> <p className="text-red-600 text-sm mt-1">{errors.provider[0]}</p>
)} )}
</div> </div>