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

[9.x] Discover anonymous Blade components in other folders #41637

Merged
merged 8 commits into from
Apr 7, 2022
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
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 @@ -218,6 +218,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