mirror of
https://github.com/archtechx/tenancy.git
synced 2026-02-05 10:54:04 +00:00
wip
This commit is contained in:
commit
8c74cb4d76
21 changed files with 850 additions and 82 deletions
|
|
@ -76,7 +76,7 @@ class CommandsTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function database_connection_is_switched_to_default_after_migrating_or_seeding_or_rolling_back()
|
||||
public function database_connection_is_switched_to_default()
|
||||
{
|
||||
$originalDBName = DB::connection()->getDatabaseName();
|
||||
|
||||
|
|
@ -88,13 +88,209 @@ class CommandsTest extends TestCase
|
|||
|
||||
Artisan::call('tenants:rollback');
|
||||
$this->assertSame($originalDBName, DB::connection()->getDatabaseName());
|
||||
|
||||
$this->run_commands_works();
|
||||
$this->assertSame($originalDBName, DB::connection()->getDatabaseName());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function database_connection_is_switched_to_default_after_migrating_or_seeding_or_rolling_back_when_tenancy_has_been_initialized()
|
||||
public function database_connection_is_switched_to_default_when_tenancy_has_been_initialized()
|
||||
{
|
||||
tenancy()->init('localhost');
|
||||
|
||||
$this->database_connection_is_switched_to_default_after_migrating_or_seeding_or_rolling_back();
|
||||
$this->database_connection_is_switched_to_default();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function run_commands_works()
|
||||
{
|
||||
$uuid = tenant()->create('run.localhost')['uuid'];
|
||||
|
||||
Artisan::call('tenants:migrate', ['--tenants' => $uuid]);
|
||||
|
||||
$this->artisan("tenants:run foo --tenants=$uuid --argument='a=foo' --option='b=bar' --option='c=xyz'")
|
||||
->expectsOutput("User's name is Test command")
|
||||
->expectsOutput('foo')
|
||||
->expectsOutput('xyz');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function install_command_works()
|
||||
{
|
||||
if (! is_dir($dir = app_path('Http'))) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
if (! is_dir($dir = base_path('routes'))) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents(app_path('Http/Kernel.php'), "<?php
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*
|
||||
* These middleware are run during every request to your application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middleware = [
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\App\Http\Middleware\CheckForMaintenanceMode::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middlewareGroups = [
|
||||
'web' => [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
// \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'throttle:60,1',
|
||||
'bindings',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware.
|
||||
*
|
||||
* These middleware may be assigned to groups or used individually.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$routeMiddleware = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The priority-sorted list of middleware.
|
||||
*
|
||||
* This forces non-global middleware to always be in the given order.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middlewarePriority = [
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\Authenticate::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\Illuminate\Auth\Middleware\Authorize::class,
|
||||
];
|
||||
}
|
||||
");
|
||||
|
||||
$this->artisan('tenancy:install')
|
||||
->expectsQuestion('Do you want to publish the default database migration?', 'yes');
|
||||
$this->assertFileExists(base_path('routes/tenant.php'));
|
||||
$this->assertFileExists(base_path('config/tenancy.php'));
|
||||
$this->assertSame("<?php
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*
|
||||
* These middleware are run during every request to your application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middleware = [
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\App\Http\Middleware\CheckForMaintenanceMode::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middlewareGroups = [
|
||||
'web' => [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
// \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'throttle:60,1',
|
||||
'bindings',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware.
|
||||
*
|
||||
* These middleware may be assigned to groups or used individually.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$routeMiddleware = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The priority-sorted list of middleware.
|
||||
*
|
||||
* This forces non-global middleware to always be in the given order.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected \$middlewarePriority = [
|
||||
\Stancl\Tenancy\Middleware\InitializeTenancy::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\Authenticate::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\Illuminate\Auth\Middleware\Authorize::class,
|
||||
];
|
||||
}
|
||||
", \file_get_contents(app_path('Http/Kernel.php')));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
tests/Etc/ConsoleKernel.php
Normal file
17
tests/Etc/ConsoleKernel.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
|
||||
use Orchestra\Testbench\Console\Kernel;
|
||||
|
||||
class ConsoleKernel extends Kernel
|
||||
{
|
||||
/**
|
||||
* The Artisan commands provided by your application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $commands = [
|
||||
ExampleCommand::class,
|
||||
];
|
||||
}
|
||||
39
tests/Etc/ExampleCommand.php
Normal file
39
tests/Etc/ExampleCommand.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace Stancl\Tenancy\Tests\Etc;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ExampleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'foo {a} {--b=} {--c=}';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
User::create([
|
||||
'id' => 999,
|
||||
'name' => 'Test command',
|
||||
'email' => 'test@command.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$this->line("User's name is " . User::find(999)->name);
|
||||
$this->line($this->argument('a'));
|
||||
$this->line($this->option('c'));
|
||||
}
|
||||
}
|
||||
|
||||
class User extends \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Tenant;
|
||||
use Tenancy;
|
||||
|
||||
class FacadeTest extends TestCase
|
||||
|
|
@ -15,4 +16,14 @@ class FacadeTest extends TestCase
|
|||
$this->assertSame('bar', Tenancy::get('foo'));
|
||||
$this->assertSame('xyz', Tenancy::get('abc'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tenant_manager_can_be_accessed_using_the_Tenant_facade()
|
||||
{
|
||||
tenancy()->put('foo', 'bar');
|
||||
Tenant::put('abc', 'xyz');
|
||||
|
||||
$this->assertSame('bar', Tenant::get('foo'));
|
||||
$this->assertSame('xyz', Tenant::get('abc'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
137
tests/TenantManagerEventsTest.php
Normal file
137
tests/TenantManagerEventsTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Tenant;
|
||||
use Tenancy;
|
||||
|
||||
class TenantManagerEventsTest extends TestCase
|
||||
{
|
||||
/** @test */
|
||||
public function bootstrapping_event_works()
|
||||
{
|
||||
$uuid = tenant()->create('foo.localhost')['uuid'];
|
||||
|
||||
Tenancy::bootstrapping(function ($tenantManager) use ($uuid) {
|
||||
if ($tenantManager->tenant['uuid'] === $uuid) {
|
||||
config(['tenancy.foo' => 'bar']);
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->init('foo.localhost');
|
||||
$this->assertSame('bar', config('tenancy.foo'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bootstrapped_event_works()
|
||||
{
|
||||
$uuid = tenant()->create('foo.localhost')['uuid'];
|
||||
|
||||
Tenancy::bootstrapped(function ($tenantManager) use ($uuid) {
|
||||
if ($tenantManager->tenant['uuid'] === $uuid) {
|
||||
config(['tenancy.foo' => 'bar']);
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->init('foo.localhost');
|
||||
$this->assertSame('bar', config('tenancy.foo'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function ending_event_works()
|
||||
{
|
||||
$uuid = tenant()->create('foo.localhost')['uuid'];
|
||||
|
||||
Tenancy::ending(function ($tenantManager) use ($uuid) {
|
||||
if ($tenantManager->tenant['uuid'] === $uuid) {
|
||||
config(['tenancy.foo' => 'bar']);
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->init('foo.localhost');
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->end();
|
||||
$this->assertSame('bar', config('tenancy.foo'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function ended_event_works()
|
||||
{
|
||||
$uuid = tenant()->create('foo.localhost')['uuid'];
|
||||
|
||||
Tenancy::ended(function ($tenantManager) use ($uuid) {
|
||||
if ($tenantManager->tenant['uuid'] === $uuid) {
|
||||
config(['tenancy.foo' => 'bar']);
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->init('foo.localhost');
|
||||
$this->assertSame(null, config('tenancy.foo'));
|
||||
tenancy()->end();
|
||||
$this->assertSame('bar', config('tenancy.foo'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function event_returns_a_collection()
|
||||
{
|
||||
// Note: The event() method should not be called by your code.
|
||||
tenancy()->bootstrapping(function ($tenancy) {
|
||||
return ['database'];
|
||||
});
|
||||
tenancy()->bootstrapping(function ($tenancy) {
|
||||
return ['redis', 'cache'];
|
||||
});
|
||||
|
||||
$prevents = tenancy()->event('bootstrapping');
|
||||
$this->assertEquals(collect(['database', 'redis', 'cache']), $prevents);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function database_can_be_reconnected_using_event_hooks()
|
||||
{
|
||||
config(['database.connections.tenantabc' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => database_path('some_special_database.sqlite'),
|
||||
]]);
|
||||
|
||||
$uuid = Tenant::create('abc.localhost')['uuid'];
|
||||
|
||||
Tenancy::bootstrapping(function ($tenancy) use ($uuid) {
|
||||
if ($tenancy->tenant['uuid'] === $uuid) {
|
||||
$tenancy->database->useConnection('tenantabc');
|
||||
|
||||
return ['database'];
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertNotSame('tenantabc', \DB::connection()->getConfig()['name']);
|
||||
tenancy()->init('abc.localhost');
|
||||
$this->assertSame('tenantabc', \DB::connection()->getConfig()['name']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function database_cannot_be_reconnected_without_using_prevents()
|
||||
{
|
||||
config(['database.connections.tenantabc' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => database_path('some_special_database.sqlite'),
|
||||
]]);
|
||||
|
||||
$uuid = Tenant::create('abc.localhost')['uuid'];
|
||||
|
||||
Tenancy::bootstrapping(function ($tenancy) use ($uuid) {
|
||||
if ($tenancy->tenant['uuid'] === $uuid) {
|
||||
$tenancy->database->useConnection('tenantabc');
|
||||
// return ['database'];
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertNotSame('tenantabc', \DB::connection()->getConfig()['name']);
|
||||
tenancy()->init('abc.localhost');
|
||||
$this->assertSame('tenant', \DB::connection()->getConfig()['name']);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace Stancl\Tenancy\Tests;
|
|||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Stancl\Tenancy\Exceptions\CannotChangeUuidOrDomainException;
|
||||
|
||||
class TenantManagerTest extends TestCase
|
||||
{
|
||||
|
|
@ -194,4 +195,50 @@ class TenantManagerTest extends TestCase
|
|||
$tenant2 = tenant()->create('bar.localhost');
|
||||
$this->assertEqualsCanonicalizing([$tenant1, $tenant2], tenant()->all()->toArray());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properites_can_be_passed_in_the_create_method()
|
||||
{
|
||||
$data = ['plan' => 'free', 'subscribed_until' => '2020-01-01'];
|
||||
$tenant = tenant()->create('foo.localhost', $data);
|
||||
|
||||
$tenant_data = $tenant;
|
||||
unset($tenant_data['uuid']);
|
||||
unset($tenant_data['domain']);
|
||||
|
||||
$this->assertSame($data, $tenant_data);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function database_name_can_be_passed_in_the_create_method()
|
||||
{
|
||||
$database = 'abc';
|
||||
config(['tenancy.database_name_key' => '_stancl_tenancy_database_name']);
|
||||
|
||||
$tenant = tenant()->create('foo.localhost', [
|
||||
'_stancl_tenancy_database_name' => $database,
|
||||
]);
|
||||
|
||||
$this->assertSame($database, tenant()->getDatabaseName($tenant));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function uuid_and_domain_cannot_be_changed()
|
||||
{
|
||||
$tenant = tenant()->create('foo.localhost');
|
||||
|
||||
$this->expectException(CannotChangeUuidOrDomainException::class);
|
||||
tenant()->put('uuid', 'foo', $tenant['uuid']);
|
||||
|
||||
$this->expectException(CannotChangeUuidOrDomainException::class);
|
||||
tenant()->put(['uuid' => 'foo'], null, $tenant['uuid']);
|
||||
|
||||
tenancy()->init('foo.localhost');
|
||||
|
||||
$this->expectException(CannotChangeUuidOrDomainException::class);
|
||||
tenant()->put('uuid', 'foo');
|
||||
|
||||
$this->expectException(CannotChangeUuidOrDomainException::class);
|
||||
tenant()->put(['uuid' => 'foo']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
}
|
||||
|
||||
$app['config']->set([
|
||||
'database.redis.client' => 'phpredis',
|
||||
'database.redis.cache.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'),
|
||||
'database.redis.default.host' => env('TENANCY_TEST_REDIS_HOST', '127.0.0.1'),
|
||||
'database.redis.options.prefix' => 'foo',
|
||||
|
|
@ -86,24 +85,11 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
'public',
|
||||
's3',
|
||||
],
|
||||
'tenancy.redis.tenancy' => true,
|
||||
'tenancy.redis.tenancy' => env('TENANCY_TEST_REDIS_TENANCY', true),
|
||||
'database.redis.client' => env('TENANCY_TEST_REDIS_CLIENT', 'phpredis'),
|
||||
'tenancy.redis.prefixed_connections' => ['default'],
|
||||
'tenancy.migrations_directory' => database_path('../migrations'),
|
||||
]);
|
||||
|
||||
switch ((string) env('STANCL_TENANCY_TEST_VARIANT', '1')) {
|
||||
case '2':
|
||||
$app['config']->set([
|
||||
'tenancy.redis.tenancy' => false,
|
||||
'database.redis.client' => 'predis',
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
$app['config']->set([
|
||||
'tenancy.redis.tenancy' => true,
|
||||
'database.redis.client' => 'phpredis',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app)
|
||||
|
|
@ -115,6 +101,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
{
|
||||
return [
|
||||
'Tenancy' => \Stancl\Tenancy\TenancyFacade::class,
|
||||
'Tenant' => \Stancl\Tenancy\TenancyFacade::class,
|
||||
'GlobalCache' => \Stancl\Tenancy\GlobalCacheFacade::class,
|
||||
];
|
||||
}
|
||||
|
|
@ -130,6 +117,17 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
$app->singleton('Illuminate\Contracts\Http\Kernel', Etc\HttpKernel::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve application Console Kernel implementation.
|
||||
*
|
||||
* @param \Illuminate\Foundation\Application $app
|
||||
* @return void
|
||||
*/
|
||||
protected function resolveApplicationConsoleKernel($app)
|
||||
{
|
||||
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\ConsoleKernel::class);
|
||||
}
|
||||
|
||||
public function randomString(int $length = 10)
|
||||
{
|
||||
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue