Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions config/filament-help.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Tapp\FilamentHelp\Models\HelpArticle;

return [

/*
Expand All @@ -15,7 +17,26 @@
|
*/

'model' => \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'),
],
],

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -101,4 +122,3 @@
],

];

31 changes: 26 additions & 5 deletions database/migrations/create_help_articles_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions resources/views/components/help-article-content.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@props(['content'])

@if (filled($content))
<div {{ $attributes->class(['fi-prose max-w-none']) }}>
{!! str($content)->sanitizeHtml() !!}
</div>
@endif
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
@endif

@if($this->record->content)
<div class="prose max-w-none dark:prose-invert prose-headings:text-gray-900 dark:prose-headings:text-white prose-video:aspect-video">
{!! $this->record->content !!}
</div>
<x-filament-help::help-article-content :content="$this->record->content" />
@else
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
@endif

@if($this->record->content)
<div class="prose max-w-none dark:prose-invert prose-headings:text-gray-900 dark:prose-headings:text-white prose-video:aspect-video">
{!! $this->record->content !!}
</div>
<x-filament-help::help-article-content :content="$this->record->content" />
@else
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
@endif

@if($this->record->content)
<div class="prose max-w-none dark:prose-invert prose-headings:text-gray-900 dark:prose-headings:text-white prose-video:aspect-video">
{!! $this->record->content !!}
</div>
<x-filament-help::help-article-content :content="$this->record->content" />
@else
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down
7 changes: 7 additions & 0 deletions src/Models/HelpArticle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,4 +109,9 @@ public function getRouteKeyName()
{
return 'slug';
}

public function renderContent(): Htmlable
{
return new HtmlString((string) str($this->content ?? '')->sanitizeHtml());
}
}
11 changes: 8 additions & 3 deletions src/Resources/HelpArticleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;',
]),
Expand Down
13 changes: 12 additions & 1 deletion src/Resources/HelpArticleResource/Pages/ViewHelpArticle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 [
Expand All @@ -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) {
Expand Down
55 changes: 49 additions & 6 deletions tests/Feature/AdminHelpArticleResourceTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<?php

use Filament\Forms\Components\RichEditor;
use Filament\Schemas\Schema;
use Tapp\FilamentHelp\Models\HelpArticle;
use Tapp\FilamentHelp\Resources\HelpArticleResource;

it('can create help article through factory', function () {
$helpArticle = HelpArticle::factory()->create([
Expand All @@ -17,26 +20,66 @@

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();
});

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);
});
});

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']]);
});
70 changes: 70 additions & 0 deletions tests/Unit/HelpArticlesMigrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

final class HelpArticlesMigrationOrganization extends Model
{
protected $table = 'organizations';

protected $guarded = [];
}

beforeEach(function (): void {
Schema::dropIfExists('help_articles');
Schema::dropIfExists('organizations');

Schema::create('organizations', function (Blueprint $table): void {
$table->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');
});
Loading