diff --git a/database/migrations/change_name_to_text_on_lms_tests_table.php.stub b/database/migrations/change_name_to_text_on_lms_tests_table.php.stub new file mode 100644 index 0000000..6ab639a --- /dev/null +++ b/database/migrations/change_name_to_text_on_lms_tests_table.php.stub @@ -0,0 +1,28 @@ +text('name')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('lms_tests', function (Blueprint $table): void { + $table->string('name')->change(); + }); + } +}; diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index e1d50ae..7d4370d 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -50,6 +50,7 @@ public function configurePackage(Package $package): void 'add_completed_at_to_lms_course_user_table', 'make_material_nullable_in_lms_steps_table', 'backfill_lms_course_user_completed_at_from_step_dates', + 'change_name_to_text_on_lms_tests_table', ]) ->hasCommand(BackfillCourseCompletedAt::class) ->hasInstallCommand(function (InstallCommand $command) { diff --git a/src/Imports/CourseStepsImport.php b/src/Imports/CourseStepsImport.php new file mode 100644 index 0000000..d321752 --- /dev/null +++ b/src/Imports/CourseStepsImport.php @@ -0,0 +1,200 @@ +isEmpty()) { + return; + } + + $first = $rows->first(); + $headers = array_keys($first->toArray()); + $hasStepNameColumn = in_array('step_name', $headers, true); + $hasScriptColumn = in_array('script', $headers, true); + + if (! $hasStepNameColumn && ! in_array('lesson_name', $headers, true)) { + throw new \InvalidArgumentException('Unrecognized CSV format. Expected at least "Step Name" or "Lesson Name" column.'); + } + + DB::transaction(function () use ($rows, $hasStepNameColumn, $hasScriptColumn): void { + $this->importRows($rows, $hasStepNameColumn, $hasScriptColumn); + }); + } + + protected function importRows(Collection $rows, bool $hasStepNameColumn, bool $hasScriptColumn): void + { + $course = Course::create([ + 'name' => $this->courseName, + 'slug' => Str::slug($this->courseName), + 'external_id' => Str::slug($this->courseName, '_'), + ]); + + $lessonOrder = 0; + $lessons = []; + $defaultLesson = null; + + foreach ($rows as $row) { + $stepName = $this->value($row, 'step_name') ?? $this->value($row, 'lesson_name'); + if ($stepName === null || trim((string) $stepName) === '') { + continue; + } + + $stepName = trim((string) $stepName); + + if ($hasStepNameColumn) { + $lessonName = $this->value($row, 'lesson_name'); + $lessonNameTrimmed = $lessonName !== null ? trim((string) $lessonName) : ''; + $lessonName = $lessonNameTrimmed !== '' ? $lessonNameTrimmed : $this->courseName; + if (! isset($lessons[$lessonName])) { + $lessonOrder++; + $lessons[$lessonName] = Lesson::create([ + 'course_id' => $course->id, + 'name' => $lessonName, + 'slug' => Str::slug($lessonName), + 'order' => $lessonOrder, + ]); + } + $lesson = $lessons[$lessonName]; + $videoUrl = $this->value($row, 'url'); + $videoUrl = $videoUrl !== null && trim((string) $videoUrl) !== '' ? trim((string) $videoUrl) : null; + $text = null; + $imageUrl = null; + $linkUrl = null; + } else { + if ($defaultLesson === null) { + $defaultLesson = Lesson::create([ + 'course_id' => $course->id, + 'name' => $this->courseName, + 'slug' => Str::slug($this->courseName), + 'order' => 1, + ]); + } + $lesson = $defaultLesson; + $text = $hasScriptColumn ? $this->value($row, 'script') : null; + $text = $text !== null ? trim((string) $text) : null; + $videoUrl = self::extractUrl($this->value($row, 'video_audio_image')); + $imageUrl = self::extractUrl($this->value($row, 'slides_image')); + $linkUrlRaw = $this->value($row, 'url'); + $linkUrl = $linkUrlRaw !== null && trim((string) $linkUrlRaw) !== '' + ? self::extractUrl($linkUrlRaw) ?? trim((string) $linkUrlRaw) + : null; + } + + [$materialId, $materialType] = $this->createMaterialForStep( + $stepName, + $videoUrl, + $imageUrl, + $linkUrl + ); + + $stepOrder = $lesson->steps()->count() + 1; + $stepSlug = $lesson->slug.'-'.Str::slug($stepName); + + Step::create([ + 'lesson_id' => $lesson->id, + 'order' => $stepOrder, + 'name' => $stepName, + 'slug' => $stepSlug, + 'text' => $text, + 'material_id' => $materialId, + 'material_type' => $materialType, + ]); + } + } + + /** + * Create video, image, or link material (priority: video > image > link). Returns [material_id, material_type]. + * + * @return array{0: int|null, 1: string|null} + */ + protected function createMaterialForStep( + string $stepName, + ?string $videoUrl, + ?string $imageUrl, + ?string $linkUrl + ): array { + if ($videoUrl !== null && $videoUrl !== '') { + $videoUrl = VideoUrlService::convertToEmbedUrl($videoUrl); + $video = Video::create([ + 'name' => $stepName, + 'url' => $videoUrl, + ]); + + return [$video->id, 'video']; + } + + if ($imageUrl !== null && $imageUrl !== '') { + $image = Image::create(['name' => $stepName]); + try { + $image->addMediaFromUrl($imageUrl)->toMediaCollection('image'); + } catch (\Throwable) { + // If URL media add fails, step still has image record + } + + return [$image->id, 'image']; + } + + if ($linkUrl !== null && $linkUrl !== '') { + $link = Link::create([ + 'name' => $stepName, + 'url' => $linkUrl, + ]); + + return [$link->id, 'link']; + } + + return [null, null]; + } + + protected function value(Collection $row, string $key): mixed + { + return $row->get($key); + } +} diff --git a/src/Jobs/ImportCourseFromCsv.php b/src/Jobs/ImportCourseFromCsv.php new file mode 100644 index 0000000..ec8749a --- /dev/null +++ b/src/Jobs/ImportCourseFromCsv.php @@ -0,0 +1,35 @@ +filePath)) { + return; + } + + Excel::import(new CourseStepsImport($this->courseName), $this->filePath); + + File::delete($this->filePath); + } +} diff --git a/src/Models/Video.php b/src/Models/Video.php index 0efaf41..6c305a5 100644 --- a/src/Models/Video.php +++ b/src/Models/Video.php @@ -27,12 +27,18 @@ public function step(): MorphTo return $this->morphTo(Step::class); } - public function getProviderAttribute() + public function getProviderAttribute(): string { - if (str_contains($this->url, 'youtube')) { + $url = $this->url ?? ''; + + if (str_contains($url, 'youtube.com') || str_contains($url, 'youtube-nocookie.com') || str_contains($url, 'youtu.be')) { return 'youtube'; } - return 'vimeo'; + if (str_contains($url, 'vimeo.com') || str_contains($url, 'player.vimeo.com')) { + return 'vimeo'; + } + + return 'external'; } } diff --git a/src/Resources/CourseResource/Pages/ListCourses.php b/src/Resources/CourseResource/Pages/ListCourses.php index 3f8f956..a1d809d 100644 --- a/src/Resources/CourseResource/Pages/ListCourses.php +++ b/src/Resources/CourseResource/Pages/ListCourses.php @@ -2,8 +2,16 @@ namespace Tapp\FilamentLms\Resources\CourseResource\Pages; +use Filament\Actions\Action; use Filament\Actions\CreateAction; +use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Tapp\FilamentLms\Jobs\ImportCourseFromCsv; use Tapp\FilamentLms\Resources\CourseResource; class ListCourses extends ListRecords @@ -13,6 +21,65 @@ class ListCourses extends ListRecords protected function getHeaderActions(): array { return [ + Action::make('import_course') + ->label('Import Course') + ->icon('heroicon-o-arrow-up-tray') + ->schema([ + FileUpload::make('file') + ->label('CSV file') + ->required() + ->storeFiles(false) + ->acceptedFileTypes([ + 'text/csv', + 'application/csv', + 'text/plain', + 'application/vnd.ms-excel', + ]) + ->maxSize(10240) + ->helperText('Upload a CSV with columns: "Step Name", "Lesson Name", "Url", "Script", "Slides (Image)", "Video (Audio + Image)"'), + TextInput::make('course_name') + ->label('Course name') + ->required() + ->maxLength(255) + ->helperText('Name of the new course.'), + ]) + ->action(function (array $data): void { + $file = $data['file']; + $courseName = trim($data['course_name']); + + if (! $file instanceof UploadedFile) { + Notification::make() + ->title('Import failed') + ->body('Could not read the uploaded file.') + ->danger() + ->send(); + + return; + } + + $storedPath = $file->storeAs( + 'filament-lms/course-imports', + Str::uuid().'.csv' + ); + + if ($storedPath === false) { + Notification::make() + ->title('Import failed') + ->body('Could not store the uploaded file.') + ->danger() + ->send(); + + return; + } + + ImportCourseFromCsv::dispatch($courseName, Storage::path($storedPath)); + + Notification::make() + ->title('Import queued') + ->body("Course \"{$courseName}\" will be imported in the background. You can continue working while the import runs.") + ->success() + ->send(); + }), CreateAction::make(), ]; } diff --git a/tests/Feature/CourseImportTest.php b/tests/Feature/CourseImportTest.php new file mode 100644 index 0000000..3f3bcf7 --- /dev/null +++ b/tests/Feature/CourseImportTest.php @@ -0,0 +1,67 @@ + \Tapp\FilamentLms\Tests\TestUser::class]); +}); + +test('course steps import format a creates course lessons steps and videos', function () { + $path = __DIR__.'/../fixtures/course-import-format-a.csv'; + + Excel::import(new CourseStepsImport('Imported Course'), $path); + + $course = Course::where('name', 'Imported Course')->first(); + expect($course)->not->toBeNull(); + expect($course->slug)->toBe('imported-course'); + + $lessons = $course->lessons()->orderBy('order')->get(); + expect($lessons)->toHaveCount(2); + expect($lessons->pluck('name')->toArray())->toBe(['Background', 'Other Lesson']); + + $steps = $course->steps()->orderBy('lms_lessons.order')->orderBy('lms_steps.order')->get(); + expect($steps)->toHaveCount(3); + + $videos = Video::all(); + expect($videos)->toHaveCount(3); + + $firstStep = $steps->first(); + expect($firstStep->material_type)->toBe('video'); + expect($firstStep->material_id)->toBe($videos->first()->id); +}); + +test('course steps import format a falls back to course name when lesson name is empty', function () { + $path = __DIR__.'/../fixtures/course-import-format-a-empty-lesson-name.csv'; + + Excel::import(new CourseStepsImport('My Course'), $path); + + $course = Course::where('name', 'My Course')->first(); + expect($course)->not->toBeNull(); + + $lessons = $course->lessons()->orderBy('order')->get(); + expect($lessons)->toHaveCount(1); + expect($lessons->first()->name)->toBe('My Course'); + expect($lessons->first()->slug)->toBe('my-course'); +}); + +test('course steps import format b creates course with steps and text', function () { + $path = __DIR__.'/../fixtures/course-import-format-b.csv'; + + Excel::import(new CourseStepsImport('Format B Course'), $path); + + $course = Course::where('name', 'Format B Course')->first(); + expect($course)->not->toBeNull(); + + $steps = $course->steps()->orderBy('order')->get(); + expect($steps)->toHaveCount(2); + expect($steps->first()->name)->toBe('Step One'); + expect($steps->first()->text)->toBe('Some script text here'); + expect($steps->get(1)->text)->toBe('More text'); +}); diff --git a/tests/Feature/VideoProviderTest.php b/tests/Feature/VideoProviderTest.php new file mode 100644 index 0000000..eb476f3 --- /dev/null +++ b/tests/Feature/VideoProviderTest.php @@ -0,0 +1,47 @@ + 'Test', + 'url' => 'https://www.youtube.com/embed/abc123', + ]); + expect($video->provider)->toBe('youtube'); +}); + +test('video provider returns youtube for youtube-nocookie.com embed URL', function () { + $video = Video::create([ + 'name' => 'Test', + 'url' => 'https://www.youtube-nocookie.com/embed/abc123', + ]); + expect($video->provider)->toBe('youtube'); +}); + +test('video provider returns youtube for youtu.be URL', function () { + $video = Video::create([ + 'name' => 'Test', + 'url' => 'https://youtu.be/abc123', + ]); + expect($video->provider)->toBe('youtube'); +}); + +test('video provider returns vimeo for vimeo.com URL', function () { + $video = Video::create([ + 'name' => 'Test', + 'url' => 'https://vimeo.com/123456', + ]); + expect($video->provider)->toBe('vimeo'); +}); + +test('video provider returns external for unknown URL', function () { + $video = Video::create([ + 'name' => 'Test', + 'url' => 'https://example.com/video', + ]); + expect($video->provider)->toBe('external'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index aefac17..674834d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Filament\Support\SupportServiceProvider; use Illuminate\Database\Schema\Blueprint; use Livewire\LivewireServiceProvider; +use Maatwebsite\Excel\ExcelServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; use Spatie\MediaLibrary\MediaLibraryServiceProvider; use Tapp\FilamentFormBuilder\FilamentFormBuilderServiceProvider; @@ -113,7 +114,7 @@ protected function setUpDatabase($app) $table->softDeletes(); }); - // Create lms_steps table + // Create lms_steps table (material nullable to match make_material_nullable migration) $app['db']->connection()->getSchemaBuilder()->create('lms_steps', function (Blueprint $table) { $table->id(); $table->foreignId('lesson_id')->references('id')->on('lms_lessons')->onDelete('cascade'); @@ -121,7 +122,8 @@ protected function setUpDatabase($app) $table->boolean('is_optional')->default(false); $table->string('name'); $table->string('slug'); - $table->morphs('material'); + $table->unsignedBigInteger('material_id')->nullable(); + $table->string('material_type')->nullable(); $table->text('text')->nullable(); $table->foreignId('retry_step_id')->nullable()->constrained('lms_steps')->onDelete('set null'); $table->boolean('require_perfect_score')->default(false); @@ -256,6 +258,7 @@ protected function getPackageProviders($app) $providers = [ LivewireServiceProvider::class, FilamentServiceProvider::class, + ExcelServiceProvider::class, SupportServiceProvider::class, MediaLibraryServiceProvider::class, FilamentLmsServiceProvider::class, diff --git a/tests/fixtures/course-import-format-a-empty-lesson-name.csv b/tests/fixtures/course-import-format-a-empty-lesson-name.csv new file mode 100644 index 0000000..9a028ca --- /dev/null +++ b/tests/fixtures/course-import-format-a-empty-lesson-name.csv @@ -0,0 +1,2 @@ +Step Name,Slide,Lesson Name,url +Only Step,1,,https://vimeo.com/123 diff --git a/tests/fixtures/course-import-format-a.csv b/tests/fixtures/course-import-format-a.csv new file mode 100644 index 0000000..e6141cc --- /dev/null +++ b/tests/fixtures/course-import-format-a.csv @@ -0,0 +1,4 @@ +Step Name,Slide,Lesson Name,url +Intro,1,Background,https://vimeo.com/123 +Part 2,2,Background,https://vimeo.com/456 +Other Lesson Step,1,Other Lesson,https://vimeo.com/789 diff --git a/tests/fixtures/course-import-format-b.csv b/tests/fixtures/course-import-format-b.csv new file mode 100644 index 0000000..f3c9764 --- /dev/null +++ b/tests/fixtures/course-import-format-b.csv @@ -0,0 +1,3 @@ +Lesson Name,Script,Lesson,Slides (Image),Video (Audio + Image),Audio,url +Step One,"Some script text here",,,,, +Step Two,"More text",,,,,