Update Kirby and Composer dependencies
This commit is contained in:
parent
f5d3ea5e84
commit
ec74d78ba9
382 changed files with 25077 additions and 4955 deletions
|
@ -11,7 +11,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Dialog extends Json
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
|
@ -19,7 +20,7 @@ use Throwable;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Document
|
||||
|
@ -65,22 +66,9 @@ class Document
|
|||
'css' => [
|
||||
'index' => $url . '/css/style.css',
|
||||
'plugins' => $plugins->url('css'),
|
||||
'custom' => static::customCss(),
|
||||
'custom' => static::customAsset('panel.css'),
|
||||
],
|
||||
'icons' => $kirby->option('panel.favicon', [
|
||||
'apple-touch-icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/apple-touch-icon.png',
|
||||
],
|
||||
'shortcut icon' => [
|
||||
'type' => 'image/svg+xml',
|
||||
'url' => $url . '/favicon.svg',
|
||||
],
|
||||
'alternate icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/favicon.png',
|
||||
]
|
||||
]),
|
||||
'icons' => static::favicon($url),
|
||||
'js' => [
|
||||
'vendor' => [
|
||||
'nonce' => $nonce,
|
||||
|
@ -99,7 +87,7 @@ class Document
|
|||
],
|
||||
'custom' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => static::customJs(),
|
||||
'src' => static::customAsset('panel.js'),
|
||||
'type' => 'module'
|
||||
],
|
||||
'index' => [
|
||||
|
@ -130,23 +118,26 @@ class Document
|
|||
|
||||
// remove missing files
|
||||
$assets['css'] = array_filter($assets['css']);
|
||||
$assets['js'] = array_filter($assets['js'], function ($js) {
|
||||
return empty($js['src']) === false;
|
||||
});
|
||||
$assets['js'] = array_filter(
|
||||
$assets['js'],
|
||||
fn ($js) => empty($js['src']) === false
|
||||
);
|
||||
|
||||
return $assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a custom css file from the
|
||||
* config (panel.css)
|
||||
* Check for a custom asset file from the
|
||||
* config (e.g. panel.css or panel.js)
|
||||
* @since 3.6.2
|
||||
*
|
||||
* @param string $option asset option name
|
||||
* @return string|null
|
||||
*/
|
||||
public static function customCss(): ?string
|
||||
public static function customAsset(string $option): ?string
|
||||
{
|
||||
if ($css = kirby()->option('panel.css')) {
|
||||
$asset = asset($css);
|
||||
if ($path = kirby()->option($option)) {
|
||||
$asset = asset($path);
|
||||
|
||||
if ($asset->exists() === true) {
|
||||
return $asset->url() . '?' . $asset->modified();
|
||||
|
@ -157,22 +148,64 @@ class Document
|
|||
}
|
||||
|
||||
/**
|
||||
* Check for a custom js file from the
|
||||
* config (panel.js)
|
||||
*
|
||||
* @return string|null
|
||||
* @deprecated 3.7.0 Use `Document::customAsset('panel.css)` instead
|
||||
* @todo add deprecation warning in 3.7.0, remove in 3.8.0
|
||||
*/
|
||||
public static function customCss(): ?string
|
||||
{
|
||||
return static::customAsset('panel.css');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 3.7.0 Use `Document::customAsset('panel.js)` instead
|
||||
* @todo add deprecation warning in 3.7.0, remove in 3.8.0
|
||||
*/
|
||||
public static function customJs(): ?string
|
||||
{
|
||||
if ($js = kirby()->option('panel.js')) {
|
||||
$asset = asset($js);
|
||||
return static::customAsset('panel.js');
|
||||
}
|
||||
|
||||
if ($asset->exists() === true) {
|
||||
return $asset->url() . '?' . $asset->modified();
|
||||
}
|
||||
/**
|
||||
* Returns array of favion icons
|
||||
* based on config option
|
||||
* @since 3.6.2
|
||||
*
|
||||
* @param string $url URL prefix for default icons
|
||||
* @return array
|
||||
*/
|
||||
public static function favicon(string $url = ''): array
|
||||
{
|
||||
$kirby = kirby();
|
||||
$icons = $kirby->option('panel.favicon', [
|
||||
'apple-touch-icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/apple-touch-icon.png',
|
||||
],
|
||||
'shortcut icon' => [
|
||||
'type' => 'image/svg+xml',
|
||||
'url' => $url . '/favicon.svg',
|
||||
],
|
||||
'alternate icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/favicon.png',
|
||||
]
|
||||
]);
|
||||
|
||||
if (is_array($icons) === true) {
|
||||
return $icons;
|
||||
}
|
||||
|
||||
return null;
|
||||
// make sure to convert favicon string to array
|
||||
if (is_string($icons) === true) {
|
||||
return [
|
||||
'shortcut icon' => [
|
||||
'type' => F::mime($icons),
|
||||
'url' => $icons,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid panel.favicon option');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,7 +17,7 @@ use Throwable;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Dropdown extends Json
|
||||
|
|
|
@ -13,7 +13,7 @@ use Kirby\Cms\Page;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Field
|
||||
|
|
|
@ -11,7 +11,7 @@ use Throwable;
|
|||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class File extends Model
|
||||
|
@ -38,12 +38,10 @@ class File extends Model
|
|||
}
|
||||
break;
|
||||
case 'page':
|
||||
$breadcrumb = $this->model->parents()->flip()->values(function ($parent) {
|
||||
return [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
];
|
||||
});
|
||||
$breadcrumb = $this->model->parents()->flip()->values(fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]);
|
||||
}
|
||||
|
||||
// add the file
|
||||
|
@ -316,7 +314,7 @@ class File extends Model
|
|||
$absolute = $parent !== $params['model'];
|
||||
}
|
||||
|
||||
$params['text'] = $params['text'] ?? '{{ file.filename }}';
|
||||
$params['text'] ??= '{{ file.filename }}';
|
||||
|
||||
return array_merge(parent::pickerData($params), [
|
||||
'filename' => $name,
|
||||
|
@ -459,13 +457,11 @@ class File extends Model
|
|||
$file = $this->model;
|
||||
|
||||
return [
|
||||
'breadcrumb' => function () use ($file): array {
|
||||
return $file->panel()->breadcrumb();
|
||||
},
|
||||
'component' => 'k-file-view',
|
||||
'props' => $this->props(),
|
||||
'search' => 'files',
|
||||
'title' => $file->filename(),
|
||||
'breadcrumb' => fn (): array => $file->panel()->breadcrumb(),
|
||||
'component' => 'k-file-view',
|
||||
'props' => $this->props(),
|
||||
'search' => 'files',
|
||||
'title' => $file->filename(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ use Throwable;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Home
|
||||
|
|
|
@ -12,7 +12,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Json
|
||||
|
@ -68,7 +68,7 @@ abstract class Json
|
|||
}
|
||||
|
||||
// always inject the response code
|
||||
$data['code'] = $data['code'] ?? 200;
|
||||
$data['code'] ??= 200;
|
||||
$data['path'] = $options['path'] ?? null;
|
||||
$data['referrer'] = Panel::referrer();
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ use Kirby\Toolkit\A;
|
|||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Model
|
||||
|
@ -77,7 +77,7 @@ abstract class Model
|
|||
*/
|
||||
public function dragTextType(string $type = null): string
|
||||
{
|
||||
$type = $type ?? 'auto';
|
||||
$type ??= 'auto';
|
||||
|
||||
if ($type === 'auto') {
|
||||
$type = option('panel.kirbytext', true) ? 'kirbytext' : 'markdown';
|
||||
|
@ -141,8 +141,8 @@ abstract class Model
|
|||
// main url
|
||||
$settings['url'] = $image->url();
|
||||
|
||||
// only create srcsets for actual File objects
|
||||
if (is_a($image, 'Kirby\Cms\File') === true) {
|
||||
// only create srcsets for resizable files
|
||||
if ($image->isResizable() === true) {
|
||||
$settings['src'] = static::imagePlaceholder();
|
||||
|
||||
switch ($layout) {
|
||||
|
@ -174,6 +174,8 @@ abstract class Model
|
|||
]
|
||||
]);
|
||||
}
|
||||
} elseif ($image->isViewable() === true) {
|
||||
$settings['src'] = $image->url();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Page extends Model
|
||||
|
@ -22,12 +22,10 @@ class Page extends Model
|
|||
public function breadcrumb(): array
|
||||
{
|
||||
$parents = $this->model->parents()->flip()->merge($this->model);
|
||||
return $parents->values(function ($parent) {
|
||||
return [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
];
|
||||
});
|
||||
return $parents->values(fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -231,7 +229,7 @@ class Page extends Model
|
|||
*/
|
||||
public function pickerData(array $params = []): array
|
||||
{
|
||||
$params['text'] = $params['text'] ?? '{{ page.title }}';
|
||||
$params['text'] ??= '{{ page.title }}';
|
||||
|
||||
return array_merge(parent::pickerData($params), [
|
||||
'dragText' => $this->dragText(),
|
||||
|
|
|
@ -22,7 +22,7 @@ use Throwable;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Panel
|
||||
|
@ -36,14 +36,14 @@ class Panel
|
|||
*/
|
||||
public static function area(string $id, $area): array
|
||||
{
|
||||
$area['id'] = $id;
|
||||
$area['label'] = $area['label'] ?? $id;
|
||||
$area['breadcrumb'] = $area['breadcrumb'] ?? [];
|
||||
$area['breadcrumbLabel'] = $area['breadcrumbLabel'] ?? $area['label'];
|
||||
$area['title'] = $area['label'];
|
||||
$area['menu'] = $area['menu'] ?? false;
|
||||
$area['link'] = $area['link'] ?? $id;
|
||||
$area['search'] = $area['search'] ?? null;
|
||||
$area['id'] = $id;
|
||||
$area['label'] ??= $id;
|
||||
$area['breadcrumb'] ??= [];
|
||||
$area['breadcrumbLabel'] ??= $area['label'];
|
||||
$area['title'] = $area['label'];
|
||||
$area['menu'] ??= false;
|
||||
$area['link'] ??= $id;
|
||||
$area['search'] ??= null;
|
||||
|
||||
return $area;
|
||||
}
|
||||
|
@ -229,11 +229,14 @@ class Panel
|
|||
/**
|
||||
* Returns the referrer path if present
|
||||
*
|
||||
* @return string|null
|
||||
* @return string
|
||||
*/
|
||||
public static function referrer(): ?string
|
||||
public static function referrer(): string
|
||||
{
|
||||
$referrer = kirby()->request()->header('X-Fiber-Referrer') ?? get('_referrer');
|
||||
$referrer = kirby()->request()->header('X-Fiber-Referrer')
|
||||
?? get('_referrer')
|
||||
?? '';
|
||||
|
||||
return '/' . trim($referrer, '/');
|
||||
}
|
||||
|
||||
|
@ -302,9 +305,6 @@ class Panel
|
|||
// create a micro-router for the Panel
|
||||
return router($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) {
|
||||
|
||||
// trigger hook
|
||||
$route = $kirby->apply('panel.route:before', compact('route', 'path', 'method'), 'route');
|
||||
|
||||
// route needs authentication?
|
||||
$auth = $route->attributes()['auth'] ?? true;
|
||||
$areaId = $route->attributes()['area'] ?? null;
|
||||
|
@ -313,6 +313,9 @@ class Panel
|
|||
|
||||
// call the route action to check the result
|
||||
try {
|
||||
// trigger hook
|
||||
$route = $kirby->apply('panel.route:before', compact('route', 'path', 'method'), 'route');
|
||||
|
||||
// check for access before executing area routes
|
||||
if ($auth !== false) {
|
||||
static::firewall($kirby->user(), $areaId);
|
||||
|
@ -350,11 +353,9 @@ class Panel
|
|||
[
|
||||
'pattern' => 'browser',
|
||||
'auth' => false,
|
||||
'action' => function () use ($kirby) {
|
||||
return new Response(
|
||||
Tpl::load($kirby->root('kirby') . '/views/browser.php')
|
||||
);
|
||||
},
|
||||
'action' => fn () => new Response(
|
||||
Tpl::load($kirby->root('kirby') . '/views/browser.php')
|
||||
),
|
||||
]
|
||||
];
|
||||
|
||||
|
@ -379,17 +380,14 @@ class Panel
|
|||
'installation',
|
||||
'login',
|
||||
],
|
||||
'action' => function () {
|
||||
Panel::go(Home::url());
|
||||
}
|
||||
'action' => fn () => Panel::go(Home::url()),
|
||||
'auth' => false
|
||||
];
|
||||
|
||||
// catch all route
|
||||
$routes[] = [
|
||||
'pattern' => '(:all)',
|
||||
'action' => function () {
|
||||
return 'The view could not be found';
|
||||
}
|
||||
'action' => fn () => 'The view could not be found'
|
||||
];
|
||||
|
||||
return $routes;
|
||||
|
@ -417,9 +415,7 @@ class Panel
|
|||
'pattern' => $pattern,
|
||||
'type' => 'dialog',
|
||||
'area' => $areaId,
|
||||
'action' => $dialog['load'] ?? function () {
|
||||
return 'The load handler for your dialog is missing';
|
||||
},
|
||||
'action' => $dialog['load'] ?? fn () => 'The load handler for your dialog is missing'
|
||||
];
|
||||
|
||||
// submit event
|
||||
|
@ -428,9 +424,7 @@ class Panel
|
|||
'type' => 'dialog',
|
||||
'area' => $areaId,
|
||||
'method' => 'POST',
|
||||
'action' => $dialog['submit'] ?? function () {
|
||||
return 'Your dialog does not define a submit handler';
|
||||
}
|
||||
'action' => $dialog['submit'] ?? fn () => 'Your dialog does not define a submit handler'
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -450,6 +444,15 @@ class Panel
|
|||
$routes = [];
|
||||
|
||||
foreach ($dropdowns as $name => $dropdown) {
|
||||
// Handle shortcuts for dropdowns. The name is the pattern
|
||||
// and options are defined in a Closure
|
||||
if (is_a($dropdown, 'Closure') === true) {
|
||||
$dropdown = [
|
||||
'pattern' => $name,
|
||||
'action' => $dropdown
|
||||
];
|
||||
}
|
||||
|
||||
// create the full pattern with dropdowns prefix
|
||||
$pattern = 'dropdowns/' . trim(($dropdown['pattern'] ?? $name), '/');
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ use Kirby\Toolkit\Str;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Plugins
|
||||
|
|
|
@ -13,7 +13,7 @@ use Exception;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Redirect extends Exception
|
||||
|
|
|
@ -11,7 +11,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Search extends Json
|
||||
|
|
|
@ -9,7 +9,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Site extends Model
|
||||
|
|
|
@ -9,7 +9,7 @@ namespace Kirby\Panel;
|
|||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class User extends Model
|
||||
|
@ -172,7 +172,7 @@ class User extends Model
|
|||
*/
|
||||
public function pickerData(array $params = null): array
|
||||
{
|
||||
$params['text'] = $params['text'] ?? '{{ user.username }}';
|
||||
$params['text'] ??= '{{ user.username }}';
|
||||
|
||||
return array_merge(parent::pickerData($params), [
|
||||
'email' => $this->model->email(),
|
||||
|
|
|
@ -16,7 +16,7 @@ use Kirby\Toolkit\Str;
|
|||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class View
|
||||
|
@ -178,15 +178,13 @@ class View
|
|||
},
|
||||
'$languages' => function () use ($kirby, $multilang): array {
|
||||
if ($multilang === true) {
|
||||
return $kirby->languages()->values(function ($language) {
|
||||
return [
|
||||
'code' => $language->code(),
|
||||
'default' => $language->isDefault(),
|
||||
'direction' => $language->direction(),
|
||||
'name' => $language->name(),
|
||||
'rules' => $language->rules(),
|
||||
];
|
||||
});
|
||||
return $kirby->languages()->values(fn ($language) => [
|
||||
'code' => $language->code(),
|
||||
'default' => $language->isDefault(),
|
||||
'direction' => $language->direction(),
|
||||
'name' => $language->name(),
|
||||
'rules' => $language->rules(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
@ -315,12 +313,10 @@ class View
|
|||
'name' => $translation->name(),
|
||||
];
|
||||
},
|
||||
'$urls' => function () use ($kirby) {
|
||||
return [
|
||||
'api' => $kirby->url('api'),
|
||||
'site' => $kirby->url('index')
|
||||
];
|
||||
}
|
||||
'$urls' => fn () => [
|
||||
'api' => $kirby->url('api'),
|
||||
'site' => $kirby->url('index')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -401,10 +397,6 @@ class View
|
|||
*/
|
||||
public static function response($data, array $options = [])
|
||||
{
|
||||
$kirby = kirby();
|
||||
$area = $options['area'] ?? [];
|
||||
$areas = $options['areas'] ?? [];
|
||||
|
||||
// handle redirects
|
||||
if (is_a($data, 'Kirby\Panel\Redirect') === true) {
|
||||
return Response::redirect($data->location(), $data->code());
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue