Skip to content

Commit

Permalink
[9.x] Discover anonymous Blade components in other folders (#41637)
Browse files Browse the repository at this point in the history
* Register anonymous component folders

* Look up components in their namespaces

* Update comment

* Add tests

* Apply style

* Clarify little comment

* Update facade docblock

* formatting

Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
ralphjsmit and taylorotwell authored Apr 7, 2022
1 parent c195fd9 commit 5686d44
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 11 deletions.
3 changes: 2 additions & 1 deletion src/Illuminate/Support/Facades/Blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* @method static void compile(string|null $path = null)
* @method static void component(string $class, string|null $alias = null, string $prefix = '')
* @method static void components(array $components, string $prefix = '')
* @method static void componentNamespace(string $namespace, string $prefix)
* @method static void anonymousComponentNamespace(string $directory, string $prefix)
* @method static void componentNamespace(string $prefix, string $directory = null)
* @method static void directive(string $name, callable $handler)
* @method static void extend(callable $compiler)
* @method static void if(string $name, callable $callback)
Expand Down
34 changes: 34 additions & 0 deletions src/Illuminate/View/Compilers/BladeCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ class BladeCompiler extends Compiler implements CompilerInterface
*/
protected $rawBlocks = [];

/**
* The array of anonymous component namespaces to autoload from.
*
* @var array
*/
protected $anonymousComponentNamespaces = [];

/**
* The array of class component aliases and their class names.
*
Expand Down Expand Up @@ -672,6 +679,23 @@ public function getClassComponentAliases()
return $this->classComponentAliases;
}

/**
* Register an anonymous component namespace.
*
* @param string $directory
* @param string|null $prefix
* @return void
*/
public function anonymousComponentNamespace(string $directory, string $prefix = null)
{
$prefix ??= $directory;

$this->anonymousComponentNamespaces[$prefix] = Str::of($directory)
->replace('/', '.')
->trim('. ')
->toString();
}

/**
* Register a class-based component namespace.
*
Expand All @@ -684,6 +708,16 @@ public function componentNamespace($namespace, $prefix)
$this->classComponentNamespaces[$prefix] = $namespace;
}

/**
* Get the registered anonymous component namespaces.
*
* @return array
*/
public function getAnonymousComponentNamespaces()
{
return $this->anonymousComponentNamespaces;
}

/**
* Get the registered class component namespaces.
*
Expand Down
36 changes: 28 additions & 8 deletions src/Illuminate/View/Compilers/ComponentTagCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,29 @@ public function componentClass(string $component)
return $class;
}

if ($viewFactory->exists($view = $this->guessViewName($component))) {
return $view;
}

if ($viewFactory->exists($view = $this->guessViewName($component).'.index')) {
return $view;
$guess = collect($this->blade->getAnonymousComponentNamespaces())
->filter(function ($directory, $prefix) use ($component) {
return Str::startsWith($component, $prefix.'::');
})
->prepend('components', $component)
->reduce(function ($carry, $directory, $prefix) use ($component, $viewFactory) {
if (! is_null($carry)) {
return $carry;
}

$componentName = Str::after($component, $prefix.'::');

if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory))) {
return $view;
}

if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory).'.index')) {
return $view;
}
});

if (! is_null($guess)) {
return $guess;
}

throw new InvalidArgumentException(
Expand Down Expand Up @@ -341,11 +358,14 @@ public function formatClassName(string $component)
* Guess the view name for the given component.
*
* @param string $name
* @param string $prefix
* @return string
*/
public function guessViewName($name)
public function guessViewName($name, $prefix = 'components.')
{
$prefix = 'components.';
if (! Str::endsWith($prefix, '.')) {
$prefix .= '.';
}

$delimiter = ViewFinderInterface::HINT_PATH_DELIMITER;

Expand Down
72 changes: 70 additions & 2 deletions tests/View/Blade/BladeComponentTagCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,74 @@ public function testPackagesClasslessComponents()
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
}

public function testClasslessComponentsWithAnonymousComponentNamespace()
{
$container = new Container;

$container->instance(Application::class, $app = m::mock(Application::class));
$container->instance(Factory::class, $factory = m::mock(Factory::class));

$app->shouldReceive('getNamespace')->andReturn('App\\');
$factory->shouldReceive('exists')->andReturnUsing(function ($arg) {
// In our test, we'll do as if the 'public.frontend.anonymous-component'
// view exists and not the others.
return $arg === 'public.frontend.anonymous-component';
});

Container::setInstance($container);

$blade = m::mock(BladeCompiler::class)->makePartial();

$blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([
'frontend' => 'public.frontend',
]);

$compiler = $this->compiler([], [], $blade);

$result = $compiler->compileTags('<x-frontend::anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />');

$this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'frontend::anonymous-component', ['view' => 'public.frontend.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']])
<?php if (isset(\$attributes) && \$constructor = (new ReflectionClass(Illuminate\View\AnonymousComponent::class))->getConstructor()): ?>
<?php \$attributes = \$attributes->except(collect(\$constructor->getParameters())->map->getName()->all()); ?>
<?php endif; ?>
<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n".
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
}

public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexView()
{
$container = new Container;

$container->instance(Application::class, $app = m::mock(Application::class));
$container->instance(Factory::class, $factory = m::mock(Factory::class));

$app->shouldReceive('getNamespace')->andReturn('App\\');
$factory->shouldReceive('exists')->andReturnUsing(function (string $viewNameBeingCheckedForExistence) {
// In our test, we'll do as if the 'public.frontend.anonymous-component'
// view exists and not the others.
return $viewNameBeingCheckedForExistence === 'admin.auth.components.anonymous-component.index';
});

Container::setInstance($container);

$blade = m::mock(BladeCompiler::class)->makePartial();

$blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([
'admin.auth' => 'admin.auth.components',
]);

$compiler = $this->compiler([], [], $blade);

$result = $compiler->compileTags('<x-admin.auth::anonymous-component :name="\'Taylor\'" :age="31" wire:model="foo" />');

$this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\View\AnonymousComponent', 'admin.auth::anonymous-component', ['view' => 'admin.auth.components.anonymous-component.index','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']])
<?php if (isset(\$attributes) && \$constructor = (new ReflectionClass(Illuminate\View\AnonymousComponent::class))->getConstructor()): ?>
<?php \$attributes = \$attributes->except(collect(\$constructor->getParameters())->map->getName()->all()); ?>
<?php endif; ?>
<?php \$component->withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n".
'@endComponentClass##END-COMPONENT-CLASS##', trim($result));
}

public function testAttributeSanitization()
{
$class = new class
Expand Down Expand Up @@ -451,10 +519,10 @@ protected function mockViewFactory($existsSucceeds = true)
Container::setInstance($container);
}

protected function compiler($aliases = [])
protected function compiler(array $aliases = [], array $namespaces = [], ?BladeCompiler $blade = null)
{
return new ComponentTagCompiler(
$aliases
$aliases, $namespaces, $blade
);
}
}
Expand Down
30 changes: 30 additions & 0 deletions tests/View/ViewBladeCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,36 @@ public function testComponentAliasesCanBeConventionallyDetermined()
$this->assertEquals(['prefix-forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases());
}

public function testAnonymousComponentNamespacesCanBeStored()
{
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);

$compiler->anonymousComponentNamespace(' public/frontend ', 'frontend');
$this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces());

$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);

$compiler->anonymousComponentNamespace('public/frontend/', 'frontend');
$this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces());

$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);

$compiler->anonymousComponentNamespace('/admin/components', 'admin');
$this->assertEquals(['admin' => 'admin.components'], $compiler->getAnonymousComponentNamespaces());

// Test directory is automatically inferred from the prefix if not given.
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);

$compiler->anonymousComponentNamespace('frontend');
$this->assertEquals(['frontend' => 'frontend'], $compiler->getAnonymousComponentNamespaces());

// Test that the prefix can also contain dots.
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);

$compiler->anonymousComponentNamespace('frontend/auth', 'frontend.auth');
$this->assertEquals(['frontend.auth' => 'frontend.auth'], $compiler->getAnonymousComponentNamespaces());
}

protected function getFiles()
{
return m::mock(Filesystem::class);
Expand Down

0 comments on commit 5686d44

Please sign in to comment.