diff --git a/README.md b/README.md index d6f0c18a..3d0db8c2 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,25 @@ return [ 'model' => \Tapp\FilamentHelp\Models\HelpArticle::class, + /* + |-------------------------------------------------------------------------- + | Rich Editor Configuration + |-------------------------------------------------------------------------- + | + | Configure how images are stored when uploaded via the Content rich editor. + | By default, attachments are stored publicly so article HTML can be + | rendered without temporary URL generation. + | + */ + + 'editor' => [ + 'file_attachments' => [ + 'disk' => env('FILAMENT_HELP_FILE_ATTACHMENTS_DISK'), + 'directory' => env('FILAMENT_HELP_FILE_ATTACHMENTS_DIRECTORY', 'help-articles'), + 'visibility' => env('FILAMENT_HELP_FILE_ATTACHMENTS_VISIBILITY', 'public'), + ], + ], + /* |-------------------------------------------------------------------------- | Frontend Configuration diff --git a/config/filament-help.php b/config/filament-help.php index 4f203b2b..c33ae318 100644 --- a/config/filament-help.php +++ b/config/filament-help.php @@ -1,5 +1,7 @@ \Tapp\FilamentHelp\Models\HelpArticle::class, + 'model' => HelpArticle::class, + + /* + |-------------------------------------------------------------------------- + | Rich Editor Configuration + |-------------------------------------------------------------------------- + | + | Configure how images are stored when uploaded via the Content rich editor. + | By default, attachments are stored publicly so article HTML can be + | rendered without temporary URL generation. + | + */ + + 'editor' => [ + 'file_attachments' => [ + 'disk' => env('FILAMENT_HELP_FILE_ATTACHMENTS_DISK'), + 'directory' => env('FILAMENT_HELP_FILE_ATTACHMENTS_DIRECTORY', 'help-articles'), + 'visibility' => env('FILAMENT_HELP_FILE_ATTACHMENTS_VISIBILITY', 'public'), + ], + ], /* |-------------------------------------------------------------------------- @@ -101,4 +122,3 @@ ], ]; - diff --git a/database/migrations/create_help_articles_table.php.stub b/database/migrations/create_help_articles_table.php.stub index a6304057..fb339532 100644 --- a/database/migrations/create_help_articles_table.php.stub +++ b/database/migrations/create_help_articles_table.php.stub @@ -15,13 +15,34 @@ return new class extends Migration // Add tenant column if tenancy is enabled in config if (config('filament-help.tenancy.enabled', false)) { - $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; - $onDelete = config('filament-help.tenancy.foreign_key.on_delete', 'cascade'); - $onUpdate = config('filament-help.tenancy.foreign_key.on_update', 'cascade'); + $tenantModel = config('filament-help.tenancy.model'); - $table->foreignId($tenantColumn) + throw_if(! is_string($tenantModel) || $tenantModel === '', InvalidArgumentException::class, 'Tenant model not configured in filament-help.tenancy.model'); + + $tenantColumnConfig = config('filament-help.tenancy.column'); + $tenantColumn = is_string($tenantColumnConfig) && $tenantColumnConfig !== '' + ? $tenantColumnConfig + : str($tenantModel)->classBasename()->snake()->append('_id')->toString(); + + $onDelete = match (config('filament-help.tenancy.foreign_key.on_delete', 'cascade')) { + 'cascade' => 'cascade', + 'no action' => 'no action', + 'restrict' => 'restrict', + 'set null' => 'set null', + default => 'cascade', + }; + + $onUpdate = match (config('filament-help.tenancy.foreign_key.on_update', 'cascade')) { + 'cascade' => 'cascade', + 'no action' => 'no action', + 'restrict' => 'restrict', + 'set null' => 'set null', + default => 'cascade', + }; + + $table->foreignIdFor($tenantModel, $tenantColumn) ->nullable() - ->constrained('teams') + ->constrained() ->onDelete($onDelete) ->onUpdate($onUpdate); } diff --git a/resources/views/components/help-article-content.blade.php b/resources/views/components/help-article-content.blade.php new file mode 100644 index 00000000..7e48a503 --- /dev/null +++ b/resources/views/components/help-article-content.blade.php @@ -0,0 +1,7 @@ +@props(['content']) + +@if (filled($content)) +
class(['fi-prose max-w-none']) }}> + {!! str($content)->sanitizeHtml() !!} +
+@endif diff --git a/resources/views/filament/resources/help-article-resource/pages/view-help-article.blade.php b/resources/views/filament/resources/help-article-resource/pages/view-help-article.blade.php index d30cec16..f7d81498 100644 --- a/resources/views/filament/resources/help-article-resource/pages/view-help-article.blade.php +++ b/resources/views/filament/resources/help-article-resource/pages/view-help-article.blade.php @@ -8,9 +8,7 @@ @endif @if($this->record->content) -
- {!! $this->record->content !!} -
+ @else
diff --git a/resources/views/resources/frontend/help-article-resource/pages/view-help-article.blade.php b/resources/views/resources/frontend/help-article-resource/pages/view-help-article.blade.php index d30cec16..f7d81498 100644 --- a/resources/views/resources/frontend/help-article-resource/pages/view-help-article.blade.php +++ b/resources/views/resources/frontend/help-article-resource/pages/view-help-article.blade.php @@ -8,9 +8,7 @@ @endif @if($this->record->content) -
- {!! $this->record->content !!} -
+ @else
diff --git a/resources/views/resources/guest/help-article-resource/pages/view-help-article.blade.php b/resources/views/resources/guest/help-article-resource/pages/view-help-article.blade.php index 69268543..97b6b230 100644 --- a/resources/views/resources/guest/help-article-resource/pages/view-help-article.blade.php +++ b/resources/views/resources/guest/help-article-resource/pages/view-help-article.blade.php @@ -8,9 +8,7 @@ @endif @if($this->record->content) -
- {!! $this->record->content !!} -
+ @else
diff --git a/src/Models/HelpArticle.php b/src/Models/HelpArticle.php index f6af5438..44c5b93e 100644 --- a/src/Models/HelpArticle.php +++ b/src/Models/HelpArticle.php @@ -3,8 +3,10 @@ namespace Tapp\FilamentHelp\Models; use Filament\Facades\Filament; +use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\HtmlString; use Tapp\FilamentHelp\Support\Tenancy; class HelpArticle extends Model @@ -107,4 +109,9 @@ public function getRouteKeyName() { return 'slug'; } + + public function renderContent(): Htmlable + { + return new HtmlString((string) str($this->content ?? '')->sanitizeHtml()); + } } diff --git a/src/Resources/HelpArticleResource.php b/src/Resources/HelpArticleResource.php index 85b293d2..a768f09f 100644 --- a/src/Resources/HelpArticleResource.php +++ b/src/Resources/HelpArticleResource.php @@ -24,6 +24,8 @@ class HelpArticleResource extends Resource { protected static ?string $model = null; + protected static ?string $recordTitleAttribute = 'name'; + public static function getModel(): string { return static::$model ?? config('filament-help.model', HelpArticle::class); @@ -102,11 +104,14 @@ public static function form(Schema $schema): Schema 'h3', 'blockquote', 'codeBlock', - ]) - ->columnSpanFull() - ->disableToolbarButtons([ 'attachFiles', ]) + ->fileAttachmentsDisk(config('filament-help.editor.file_attachments.disk')) + ->fileAttachmentsDirectory(config('filament-help.editor.file_attachments.directory', 'help-articles')) + ->fileAttachmentsVisibility(config('filament-help.editor.file_attachments.visibility', 'public')) + ->resizableImages() + ->preventFileAttachmentPathTampering() + ->columnSpanFull() ->extraInputAttributes([ 'style' => 'min-height: 200px;', ]), diff --git a/src/Resources/HelpArticleResource/Pages/ViewHelpArticle.php b/src/Resources/HelpArticleResource/Pages/ViewHelpArticle.php index 657789c2..3ce17c7f 100644 --- a/src/Resources/HelpArticleResource/Pages/ViewHelpArticle.php +++ b/src/Resources/HelpArticleResource/Pages/ViewHelpArticle.php @@ -6,6 +6,7 @@ use Filament\Actions\Action; use Filament\Facades\Filament; use Filament\Resources\Pages\ViewRecord; +use Tapp\FilamentHelp\Models\HelpArticle; use Tapp\FilamentHelp\Resources\Frontend\HelpArticleResource as FrontendHelpArticleResource; use Tapp\FilamentHelp\Resources\Guest\HelpArticleResource as GuestHelpArticleResource; use Tapp\FilamentHelp\Resources\HelpArticleResource; @@ -16,6 +17,16 @@ class ViewHelpArticle extends ViewRecord protected string $view = 'filament-help::filament.resources.help-article-resource.pages.view-help-article'; + public function getTitle(): string + { + return $this->record->name; + } + + public function getHeading(): string + { + return $this->record->name; + } + protected function getHeaderActions(): array { return [ @@ -39,7 +50,7 @@ protected function getHeaderActions(): array protected function getShareUrl(): string { - /** @var \Tapp\FilamentHelp\Models\HelpArticle $record */ + /** @var HelpArticle $record */ $record = $this->record; if ($record->is_public) { diff --git a/tests/Feature/AdminHelpArticleResourceTest.php b/tests/Feature/AdminHelpArticleResourceTest.php index 4b83843a..469f8acd 100644 --- a/tests/Feature/AdminHelpArticleResourceTest.php +++ b/tests/Feature/AdminHelpArticleResourceTest.php @@ -1,6 +1,9 @@ create([ @@ -17,9 +20,9 @@ it('can update help article', function () { $helpArticle = HelpArticle::factory()->create(['name' => 'Original Name']); - + $helpArticle->update(['name' => 'Updated Name', 'is_public' => true]); - + expect($helpArticle->fresh()->name)->toBe('Updated Name'); expect($helpArticle->fresh()->is_public)->toBeTrue(); }); @@ -27,16 +30,56 @@ it('can delete help article', function () { $helpArticle = HelpArticle::factory()->create(); $id = $helpArticle->id; - + $helpArticle->delete(); - + expect(HelpArticle::find($id))->toBeNull(); }); it('can filter help articles by public status', function () { HelpArticle::factory()->public()->count(2)->create(); HelpArticle::factory()->private()->count(3)->create(); - + expect(HelpArticle::public()->count())->toBe(2); expect(HelpArticle::where('is_public', false)->count())->toBe(3); -}); \ No newline at end of file +}); + +it('shows the article name as the view page title', function () { + $helpArticle = HelpArticle::factory()->create([ + 'name' => 'Adding an Evaluation Course', + ]); + + $page = new \Tapp\FilamentHelp\Resources\HelpArticleResource\Pages\ViewHelpArticle; + $page->record = $helpArticle; + + expect($page->getTitle())->toBe('Adding an Evaluation Course'); + expect($page->getHeading())->toBe('Adding an Evaluation Course'); +}); + +it('enables image uploads on the content rich editor', function () { + expect(config('filament-help.editor.file_attachments.directory'))->toBe('help-articles'); + expect(config('filament-help.editor.file_attachments.visibility'))->toBe('public'); + + $schema = HelpArticleResource::form(Schema::make()); + + $components = (new ReflectionProperty($schema, 'components'))->getValue($schema); + + $contentField = collect($components) + ->first(fn ($component) => $component instanceof RichEditor && $component->getName() === 'content'); + + expect($contentField)->not->toBeNull(); + + $toolbarButtons = (new ReflectionProperty($contentField, 'toolbarButtons'))->getValue($contentField); + $fileAttachmentsDirectory = (new ReflectionProperty($contentField, 'fileAttachmentsDirectory'))->getValue($contentField); + $fileAttachmentsVisibility = (new ReflectionProperty($contentField, 'fileAttachmentsVisibility'))->getValue($contentField); + $hasResizableImages = (new ReflectionProperty($contentField, 'hasResizableImages'))->getValue($contentField); + $toolbarModifications = (new ReflectionProperty($contentField, 'toolbarButtonsModifications'))->getValue($contentField); + $shouldPreventTampering = (new ReflectionProperty($contentField, 'shouldPreventFileAttachmentPathTampering'))->getValue($contentField); + + expect($toolbarButtons)->toContain('attachFiles'); + expect($fileAttachmentsDirectory)->toBe('help-articles'); + expect($fileAttachmentsVisibility)->toBe('public'); + expect($hasResizableImages)->toBeTrue(); + expect($shouldPreventTampering)->toBeTrue(); + expect($toolbarModifications)->not->toContain(['type' => 'disable', 'buttons' => ['attachFiles']]); +}); diff --git a/tests/Unit/HelpArticlesMigrationTest.php b/tests/Unit/HelpArticlesMigrationTest.php new file mode 100644 index 00000000..519e386a --- /dev/null +++ b/tests/Unit/HelpArticlesMigrationTest.php @@ -0,0 +1,70 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + config([ + 'filament-help.tenancy.enabled' => true, + 'filament-help.tenancy.model' => HelpArticlesMigrationOrganization::class, + 'filament-help.tenancy.column' => 'organization_id', + ]); + + $migration = require __DIR__.'/../../database/migrations/create_help_articles_table.php.stub'; + $migration->up(); +}); + +it('creates the tenant foreign key from filament-help.tenancy.model instead of a hard-coded teams table', function (): void { + expect(Schema::hasColumn('help_articles', 'organization_id'))->toBeTrue() + ->and(Schema::hasColumn('help_articles', 'team_id'))->toBeFalse() + ->and(Schema::hasTable('teams'))->toBeFalse(); + + $organization = HelpArticlesMigrationOrganization::query()->create(['name' => 'Acme']); + + $articleId = DB::table('help_articles')->insertGetId([ + 'organization_id' => $organization->id, + 'name' => 'Tenant Scoped Article', + 'slug' => 'tenant-scoped-article', + 'is_public' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(DB::table('help_articles')->where('id', $articleId)->value('organization_id')) + ->toBe($organization->id); +}); + +it('requires a tenant model when tenancy is enabled', function (): void { + Schema::dropIfExists('help_articles'); + + config([ + 'filament-help.tenancy.enabled' => true, + 'filament-help.tenancy.model' => null, + 'filament-help.tenancy.column' => 'organization_id', + ]); + + $migration = require __DIR__.'/../../database/migrations/create_help_articles_table.php.stub'; + + expect(fn () => $migration->up()) + ->toThrow(InvalidArgumentException::class, 'Tenant model not configured in filament-help.tenancy.model'); +}); diff --git a/tests/Unit/Models/HelpArticleTest.php b/tests/Unit/Models/HelpArticleTest.php index 0f5e9136..bb957ded 100644 --- a/tests/Unit/Models/HelpArticleTest.php +++ b/tests/Unit/Models/HelpArticleTest.php @@ -30,3 +30,15 @@ expect($helpArticle->is_public)->toBeTrue(); expect($helpArticle->is_public)->toBeBool(); }); + +it('renders rich editor content as html', function () { + $helpArticle = HelpArticle::factory()->create([ + 'content' => '

First paragraph

Demo

', + ]); + + $html = $helpArticle->renderContent()->toHtml(); + + expect($html)->toContain('

First paragraph

'); + expect($html)->toContain('src="https://example.com/image.png"'); + expect($html)->toContain('alt="Demo"'); +});