Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2.x] Interactive component publisher command #2062

Draft
wants to merge 78 commits into
base: 2.x-dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
396bfe5
Update signature to include interactive flag
caendesilva Dec 10, 2024
bdf1d4b
Fix shortcut syntax
caendesilva Dec 10, 2024
07d46fc
Add helper method
caendesilva Dec 10, 2024
b22a7e8
Draft outline for interactive publish
caendesilva Dec 10, 2024
0ff87fd
First pass at collecting and formatting publish paths
caendesilva Dec 10, 2024
73bcf9f
Cleanup code
caendesilva Dec 10, 2024
ff5757f
Prompt for files
caendesilva Dec 10, 2024
6025e57
Use Laravel Prompts multiselect
caendesilva Dec 10, 2024
b6eba4a
Cleanup and move up output
caendesilva Dec 10, 2024
98e4b16
Filter the files to only include the selected ones
caendesilva Dec 10, 2024
6607d3d
Publish the files
caendesilva Dec 10, 2024
cea2f6c
Display how to select/unselect all at once
caendesilva Dec 10, 2024
e737d1f
Create InteractivePublishCommandHelper.php
caendesilva Dec 10, 2024
1b93233
Create InteractivePublishCommandHelperTest.php
caendesilva Dec 10, 2024
d27fae8
Add test crosslink
caendesilva Dec 10, 2024
e04400a
Support all groups
caendesilva Dec 10, 2024
f9bb56a
Add todo
caendesilva Dec 10, 2024
3f406ad
Tweak setup
caendesilva Dec 10, 2024
04813da
Create helper instance
caendesilva Dec 10, 2024
15b67aa
Introduce local variable
caendesilva Dec 10, 2024
5589e36
Mark helper as internal
caendesilva Dec 10, 2024
6d7810a
Move method to helper
caendesilva Dec 10, 2024
32105b2
Revert "Move method to helper"
caendesilva Dec 10, 2024
72b4b11
Move logic to helper
caendesilva Dec 10, 2024
2faf686
Refactor the extracted class
caendesilva Dec 10, 2024
26eff2f
Refactor so output is handled in command
caendesilva Dec 11, 2024
b6010c2
Refactor to initialise all data in constructor
caendesilva Dec 11, 2024
acaf3f3
Make it clearer where data is initialized
caendesilva Dec 11, 2024
093c96e
Cleanup code
caendesilva Dec 11, 2024
2f000ba
Refactor data retrieval to clean up constructor
caendesilva Dec 11, 2024
cd6dbb0
Rename properties to indicate they are directories
caendesilva Dec 11, 2024
ecc8725
Use array facade instead of unnecessary collections
caendesilva Dec 11, 2024
9c9b113
Extract helper method
caendesilva Dec 11, 2024
50f8362
Formatting
caendesilva Dec 11, 2024
3330f3a
Extract helper method and add more array types
caendesilva Dec 11, 2024
7c68954
Change variable used to match signature
caendesilva Dec 11, 2024
179a6d3
Fix formatting
caendesilva Dec 11, 2024
c60a240
Split out output handler to void handle method
caendesilva Dec 11, 2024
8df8040
Add base test setups
caendesilva Dec 11, 2024
c28a233
Disable interactive publishing on Windows
caendesilva Dec 22, 2024
27d3cc2
Create ConsoleHelper.php
caendesilva Dec 22, 2024
4e59a21
Mark helper as internal
caendesilva Dec 22, 2024
2c221b1
Code coverage ignore helper
caendesilva Dec 22, 2024
b9a4ed8
Add mocking scaffolding
caendesilva Dec 22, 2024
443a1a8
Add and use mockable Windows detection helper
caendesilva Dec 22, 2024
03bcc03
Create mockable helper for multiselect
caendesilva Dec 22, 2024
4f90765
Create first tests
caendesilva Dec 22, 2024
366c42d
Mock test state
caendesilva Dec 22, 2024
f64240b
Refactor to add check earlier so we can fail command
caendesilva Dec 22, 2024
86ac4a6
Clean up after test
caendesilva Dec 22, 2024
0200cc6
Cleanup test
caendesilva Dec 22, 2024
6824637
Also bail if the stream is not interactive
caendesilva Dec 22, 2024
e72985a
Revert "Also bail if the stream is not interactive"
caendesilva Dec 22, 2024
802a9ab
Mock the application
caendesilva Dec 22, 2024
b3faee7
Annotate literal parameters
caendesilva Dec 22, 2024
7b4003d
Annotate array types
caendesilva Dec 22, 2024
6fa6b35
Implement the unit test
caendesilva Dec 22, 2024
31a1400
Cleanup code and use real app
caendesilva Dec 22, 2024
540c0c4
Add final unit test
caendesilva Dec 22, 2024
2438471
Refactor to mock by default
caendesilva Dec 22, 2024
8e45658
Simplify setup
caendesilva Dec 22, 2024
4394f66
Add testing helper
caendesilva Dec 23, 2024
9f37161
Use proper single file pluralization
caendesilva Dec 23, 2024
f71272d
More Laravely formatting
caendesilva Dec 23, 2024
7895391
Extract helper method
caendesilva Dec 23, 2024
dbb49aa
Clearer helper method name
caendesilva Dec 23, 2024
945d5f1
Inline local variable
caendesilva Dec 23, 2024
988a1dc
Helper method
caendesilva Dec 23, 2024
a799498
Inline variable
caendesilva Dec 23, 2024
63c8b61
Implement dynamic formatting
caendesilva Dec 23, 2024
95a305c
Remove inlined helper method
caendesilva Dec 23, 2024
84b5459
Go really crazy with the formatting
caendesilva Dec 23, 2024
1cabc8b
Expect smarter formatting
caendesilva Dec 23, 2024
e9a1dda
Cleanup formatting
caendesilva Dec 24, 2024
38d8a21
Create ConsoleHelperTest.php
caendesilva Dec 24, 2024
65ebd7e
Add regression test
caendesilva Dec 24, 2024
e9bee64
Cleanup code
caendesilva Dec 24, 2024
5143c0b
Clean up formatting
caendesilva Dec 24, 2024
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
34 changes: 33 additions & 1 deletion packages/framework/src/Console/Commands/PublishViewsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Hyde\Console\Commands;

use Hyde\Console\Concerns\Command;
use Hyde\Console\Helpers\ConsoleHelper;
use Hyde\Console\Helpers\InteractivePublishCommandHelper;
use Illuminate\Support\Facades\Artisan;

use function str_replace;
Expand All @@ -17,7 +19,7 @@
class PublishViewsCommand extends Command
{
/** @var string */
protected $signature = 'publish:views {category? : The category to publish}';
protected $signature = 'publish:views {category? : The category to publish} {--i|interactive : Interactively select the views to publish}';

/** @var string */
protected $description = 'Publish the hyde components for customization. Note that existing files will be overwritten';
Expand All @@ -43,6 +45,12 @@ class PublishViewsCommand extends Command

public function handle(): int
{
if ($this->isInteractive() && ConsoleHelper::usesWindowsOs()) {
$this->error('Due to limitations in the Windows version of PHP, it is not currently possible to use interactive mode on Windows outside of WSL.');

return Command::FAILURE;
}

$selected = (string) ($this->argument('category') ?? $this->promptForCategory());

if ($selected === 'all' || $selected === '') {
Expand All @@ -56,8 +64,20 @@ public function handle(): int
return Command::SUCCESS;
}

protected function isInteractive(): bool
{
return $this->option('interactive');
}

protected function publishOption(string $selected): void
{
// Todo: Don't trigger interactive if "all" is selected
if ($this->isInteractive()) {
$this->handleInteractivePublish($selected);

return;
}

Artisan::call('vendor:publish', [
'--tag' => $this->options[$selected]['group'] ?? $selected,
'--force' => true,
Expand Down Expand Up @@ -94,4 +114,16 @@ protected function parseChoiceIntoKey(string $choice): string
{
return strstr(str_replace(['<comment>', '</comment>'], '', $choice), ':', true) ?: '';
}

protected function handleInteractivePublish(string $selected): void
{
$publisher = new InteractivePublishCommandHelper($this->options[$selected]['group']);

$choices = $publisher->getFileChoices();

$selectedFiles = ConsoleHelper::multiselect('Select the files you want to publish (CTRL+A to toggle all)', $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.');
$publisher->handle($selectedFiles);

$this->infoComment($publisher->formatOutput($selectedFiles));
}
}
64 changes: 64 additions & 0 deletions packages/framework/src/Console/Helpers/ConsoleHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Hyde\Console\Helpers;

use Closure;
use Illuminate\Support\Collection;

use function Laravel\Prompts\multiselect;

/**
* @internal This class contains internal helpers for interacting with the console, and for easier testing.
*
* @codeCoverageIgnore This class provides internal testing helpers and does not need to be tested.
*/
class ConsoleHelper
{
protected static array $mocks = [];

public static function clearMocks(): void
{
static::$mocks = [];
}

public static function usesWindowsOs()
{
if (isset(static::$mocks['usesWindowsOs'])) {
return static::$mocks['usesWindowsOs'];
}

return windows_os();
}

public static function mockWindowsOs(bool $isWindows = true): void
{
static::$mocks['usesWindowsOs'] = $isWindows;
}

// Todo: Add test to ensure the signature is the same if Laravel updates it
public static function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?Closure $transform = null): array
{
if (isset(static::$mocks['multiselect'])) {
$returns = static::$mocks['multiselect'];
$assertionCallback = static::$mocks['multiselectAssertion'] ?? null;

if ($assertionCallback !== null) {
$assertionCallback($label, $options, $default, $scroll, $required, $validate, $hint, $transform);
}

return $returns;
}

return multiselect($label, $options, $default, $scroll, $required, $validate, $hint, $transform);
}

public static function mockMultiselect(array $returns, ?Closure $assertionCallback = null): void
{
assert(! isset(static::$mocks['multiselect']), 'Cannot mock multiselect twice.');

static::$mocks['multiselect'] = $returns;
static::$mocks['multiselectAssertion'] = $assertionCallback;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace Hyde\Console\Helpers;

use Hyde\Facades\Filesystem;
use Hyde\Foundation\Providers\ViewServiceProvider;
use Hyde\Hyde;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Symfony\Component\Finder\SplFileInfo;

use function Hyde\path_join;

/**
* @internal This class offloads logic from the PublishViewsCommand class and should not be used elsewhere.
*/
class InteractivePublishCommandHelper
{
protected readonly string $group;
protected readonly string $sourceDirectory;
protected readonly string $targetDirectory;

/** @var array<string, string> Map of source files to target files */
protected readonly array $publishableFilesMap;

/** @param "hyde-layouts"|"hyde-components"|"hyde-page-404" $group */
public function __construct(string $group)
{
$this->group = $group;

[$this->sourceDirectory, $this->targetDirectory] = $this->getPublishPaths();

$this->publishableFilesMap = $this->mapPublishableFiles($this->findAllFilesForTag());
}

/** @return array<string, string> */
public function getFileChoices(): array
{
return Arr::mapWithKeys($this->publishableFilesMap, /** @return array<string, string> */ function (string $source): array {
return [$source => $this->pathRelativeToDirectory($source, $this->targetDirectory)];
});
}

/** @param array<string> $selectedFiles */
public function handle(array $selectedFiles): void
{
$filesToPublish = $this->filterPublishableFiles($selectedFiles);

$this->publishFiles($filesToPublish);
}

/** @return array{string, string} */
protected function getPublishPaths(): array
{
$viewPaths = ServiceProvider::pathsToPublish(ViewServiceProvider::class, $this->group);

$source = array_key_first($viewPaths);
$target = $viewPaths[$source];

return [$source, $target];
}

/** @return \Symfony\Component\Finder\SplFileInfo[] */
protected function findAllFilesForTag(): array
{
return File::allFiles($this->sourceDirectory);
}

/**
* @param \Symfony\Component\Finder\SplFileInfo[] $search
* @return array<string, string>
*/
protected function mapPublishableFiles(array $search): array
{
return Arr::mapWithKeys($search, /** @return array<string, string> */ function (SplFileInfo $file): array {
$targetPath = path_join($this->targetDirectory, $file->getRelativePathname());

return [Hyde::pathToRelative(realpath($file->getPathname())) => Hyde::pathToRelative($targetPath)];
});
}

/** @param array<string, string> $selectedFiles */
protected function publishFiles(array $selectedFiles): void
{
foreach ($selectedFiles as $source => $target) {
Filesystem::ensureDirectoryExists(dirname($target));
Filesystem::copy($source, $target); // Todo: See how we should handle existing files
}
}

/**
* @experimental This method may be toned down in the future.
*
* @param array<string> $selectedFiles
*/
public function formatOutput(array $selectedFiles): string
caendesilva marked this conversation as resolved.
Show resolved Hide resolved
{
$fileCount = count($selectedFiles);
$displayLimit = 3;

$fileNames = collect($selectedFiles)->map(fn (string $file): string => $this->pathRelativeToDirectory($file, $this->sourceDirectory));

$displayFiles = $fileNames->take($displayLimit)->implode(', ');

return Str::of('Published')
->when($fileCount === $this->publishableFilesMapCount(),
fn (Stringable $str): Stringable => $str->append(' all files, including'),
fn (Stringable $str): Stringable => $str->append(' ', Str::plural('file', $fileCount))
)
->append(' [', $displayFiles, ']')
->when($fileCount > $displayLimit,
fn (Stringable $str): Stringable => $str->append(' and ', $fileCount - $displayLimit, ' more')
)
->toString();
}

protected function pathRelativeToDirectory(string $source, string $directory): string
{
return Str::after($source, basename($directory).'/');
}

/**
* @param array<string> $selectedFiles
* @return array<string, string>
*/
protected function filterPublishableFiles(array $selectedFiles): array
{
return array_filter($this->publishableFilesMap, fn (string $file): bool => in_array($file, $selectedFiles));
}

protected function publishableFilesMapCount(): int
{
return count($this->publishableFilesMap);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

namespace Hyde\Framework\Testing\Feature\Commands;

use Hyde\Console\Helpers\ConsoleHelper;
use Hyde\Hyde;
use Hyde\Testing\TestCase;
use Illuminate\Support\Facades\File;

/**
* @covers \Hyde\Console\Commands\PublishViewsCommand
*
* @see \Hyde\Framework\Testing\Unit\InteractivePublishCommandHelperTest
*/
class PublishViewsCommandTest extends TestCase
{
Expand Down Expand Up @@ -48,4 +51,43 @@ public function testWithInvalidSuppliedTag()
->expectsOutputToContain('No publishable resources for tag [invalid].')
->assertExitCode(0);
}

public function testInteractiveSelectionOnUnixSystems()
{
ConsoleHelper::mockWindowsOs(false);

ConsoleHelper::mockMultiselect(['resources/views/vendor/hyde/components/article-excerpt.blade.php'], function (string $label, array $options) {
$this->assertSame('Select the files you want to publish (CTRL+A to toggle all)', $label);
$this->assertContainsOnly('string', array_keys($options));
$this->assertContainsOnly('string', array_values($options));
$this->assertContains('resources/views/vendor/hyde/components/article-excerpt.blade.php', array_keys($options));
$this->assertContains('article-excerpt.blade.php', array_values($options));
});

$this->artisan('publish:views components --interactive')
->expectsOutput('Published file [article-excerpt.blade.php]')
->assertExitCode(0);

$this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php'));

File::deleteDirectory(Hyde::path('resources/views/vendor/hyde'));
}

public function testInteractiveSelectionOnWindowsSystems()
{
ConsoleHelper::mockWindowsOs();

$this->artisan('publish:views components --interactive')
->expectsOutput('Due to limitations in the Windows version of PHP, it is not currently possible to use interactive mode on Windows outside of WSL.')
->assertExitCode(1);

File::deleteDirectory(Hyde::path('resources/views/vendor/hyde'));
}

protected function tearDown(): void
{
ConsoleHelper::clearMocks();

parent::tearDown();
}
}
Loading
Loading