feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,998 @@
<?php namespace Backend\Behaviors;
use Db;
use Str;
use Lang;
use Flash;
use Event;
use Redirect;
use Backend;
use Backend\Classes\ControllerBehavior;
use Winter\Storm\Router\Helper as RouterHelper;
use ApplicationException;
use Exception;
/**
* Adds features for working with backend forms. This behavior
* will inject CRUD actions to the controller -- including create,
* update and preview -- along with some relevant AJAX handlers.
*
* Each action supports a custom context code, allowing fields
* to be displayed or hidden on a contextual basis, as specified
* by the form field definitions or some other custom logic.
*
* This behavior is implemented in the controller like so:
*
* public $implement = [
* \Backend\Behaviors\FormController::class,
* ];
*
* public $formConfig = 'config_form.yaml';
*
* The `$formConfig` property makes reference to the form configuration
* values as either a YAML file, located in the controller view directory,
* or directly as a PHP array.
*
* @see https://wintercms.com/docs/backend/forms Back-end form documentation
* @package winter\wn-backend-module
* @author Alexey Bobkov, Samuel Georges
*/
class FormController extends ControllerBehavior
{
use \Backend\Traits\FormModelSaver;
/**
* @var string Default context for "create" pages.
*/
const CONTEXT_CREATE = 'create';
/**
* @var string Default context for "update" pages.
*/
const CONTEXT_UPDATE = 'update';
/**
* @var string Default context for "preview" pages.
*/
const CONTEXT_PREVIEW = 'preview';
/**
* @var \Backend\Classes\Controller|FormController Reference to the back end controller.
*/
protected $controller;
/**
* @var \Backend\Widgets\Form Reference to the widget object.
*/
protected $formWidget;
/**
* @var array Configuration values that must exist when applying the primary config file.
* - modelClass: Class name for the model
* - form: Form field definitions
*/
protected $requiredConfig = ['modelClass', 'form'];
/**
* @var array Visible actions in context of the controller
*/
protected $actions = ['create', 'update', 'preview'];
/**
* @var string The context to pass to the form widget.
*/
protected $context;
/**
* @var \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model The initialized model used by the form.
*/
protected $model;
/**
* @var mixed Configuration for this behaviour
*/
public $formConfig = 'config_form.yaml';
/**
* Behavior constructor
* @param \Backend\Classes\Controller $controller
*/
public function __construct($controller)
{
parent::__construct($controller);
/*
* Build configuration
*/
$this->config = $this->makeConfig($controller->formConfig ?: $this->formConfig, $this->requiredConfig);
$this->config->modelClass = Str::normalizeClassName($this->config->modelClass);
}
/**
* Initialize the form configuration against a model and context value.
* This will process the configuration found in the `$formConfig` property
* and prepare the Form widget, which is the underlying tool used for
* actually rendering the form. The model used by this form is passed
* to this behavior via this method as the first argument.
*
* @see \Backend\Widgets\Form
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model $model
* @param string $context Form context
* @return void
*/
public function initForm($model, $context = null)
{
$context = $this->context = $context ?? $this->formGetContext();
/*
* Each page can supply a unique form definition, if desired
*/
$formFields = $this->getConfig("{$context}[form]", $this->config->form);
$config = $this->makeConfig($formFields);
$config->model = $model;
$config->arrayName = class_basename($model);
$config->context = $context;
/*
* Form Widget with extensibility
*/
$this->formWidget = $this->makeWidget('Backend\Widgets\Form', $config);
// Setup the default preview mode on form initialization if the context is preview
if ($config->context === 'preview') {
$this->formWidget->previewMode = true;
}
$this->formWidget->bindEvent('form.extendFieldsBefore', function () {
$this->controller->formExtendFieldsBefore($this->formWidget);
});
$this->formWidget->bindEvent('form.extendFields', function ($fields) {
$this->controller->formExtendFields($this->formWidget, $fields);
});
$this->formWidget->bindEvent('form.beforeRefresh', function ($holder) {
$result = $this->controller->formExtendRefreshData($this->formWidget, $holder->data);
if (is_array($result)) {
$holder->data = $result;
}
});
$this->formWidget->bindEvent('form.refreshFields', function ($fields) {
return $this->controller->formExtendRefreshFields($this->formWidget, $fields);
});
$this->formWidget->bindEvent('form.refresh', function ($result) {
return $this->controller->formExtendRefreshResults($this->formWidget, $result);
});
$this->formWidget->bindToController();
/*
* Detected Relation controller behavior
*/
if ($this->controller->isClassExtendedWith(\Backend\Behaviors\RelationController::class)) {
$this->controller->initRelation(clone $model);
}
$this->prepareVars($model);
$this->model = $model;
}
/**
* Prepares commonly used view data.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model $model
*/
protected function prepareVars($model)
{
$this->controller->vars['formModel'] = $model;
$this->controller->vars['formConfig'] = $this->getConfig();
$this->controller->vars['formContext'] = $this->formGetContext();
$this->controller->vars['formController'] = $this;
$this->controller->vars['formRecordName'] = Lang::get($this->getConfig('name', 'backend::lang.model.name'));
}
//
// Create
//
/**
* Controller "create" action used for creating new model records.
*
* @param string $context Form context
* @return void
*/
public function create($context = null)
{
try {
$this->context = strlen($context) ? $context : $this->getConfig('create[context]', self::CONTEXT_CREATE);
$this->controller->pageTitle = $this->controller->pageTitle ?: $this->getLang(
"{$this->context}[title]",
'backend::lang.form.create_title'
);
$model = $this->controller->formCreateModelObject();
$model = $this->controller->formExtendModel($model) ?: $model;
$this->initForm($model);
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
}
/**
* AJAX handler "onSave" called from the create action and
* primarily used for creating new records.
*
* This handler will invoke the unique controller overrides
* `formBeforeCreate` and `formAfterCreate`.
*
* @param string $context Form context
* @return \Illuminate\Http\RedirectResponse|void
*/
public function create_onSave($context = null)
{
$this->context = strlen($context) ? $context : $this->getConfig('create[context]', self::CONTEXT_CREATE);
$model = $this->controller->formCreateModelObject();
$model = $this->controller->formExtendModel($model) ?: $model;
$this->initForm($model);
$this->controller->formBeforeSave($model);
$this->controller->formBeforeCreate($model);
$modelsToSave = $this->prepareModelsToSave($model, $this->formWidget->getSaveData());
Db::transaction(function () use ($modelsToSave) {
foreach ($modelsToSave as $modelToSave) {
$modelToSave->save(null, $this->formWidget->getSessionKey());
}
});
$this->controller->formAfterSave($model);
$this->controller->formAfterCreate($model);
Flash::success($this->getLang("{$this->context}[flashSave]", 'backend::lang.form.create_success'));
if ($redirect = $this->makeRedirect($this->context, $model)) {
return $redirect;
}
}
//
// Update
//
/**
* Controller "update" action used for updating existing model records.
* This action takes a record identifier (primary key of the model)
* to locate the record used for sourcing the existing form values.
*
* @param int $recordId Record identifier
* @param string $context Form context
* @return void
*/
public function update($recordId = null, $context = null)
{
try {
$this->context = strlen($context) ? $context : $this->getConfig('update[context]', self::CONTEXT_UPDATE);
$this->controller->pageTitle = $this->controller->pageTitle ?: $this->getLang(
"{$this->context}[title]",
'backend::lang.form.update_title'
);
$model = $this->controller->formFindModelObject($recordId);
$this->initForm($model);
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
}
/**
* AJAX handler "onSave" called from the update action and
* primarily used for updating existing records.
*
* This handler will invoke the unique controller overrides
* `formBeforeUpdate` and `formAfterUpdate`.
*
* @param int $recordId Record identifier
* @param string $context Form context
* @return \Illuminate\Http\RedirectResponse|void
* @throws \Winter\Storm\Exception\ApplicationException if the provided recordId is not found
*/
public function update_onSave($recordId = null, $context = null)
{
$this->context = strlen($context) ? $context : $this->getConfig('update[context]', self::CONTEXT_UPDATE);
$model = $this->controller->formFindModelObject($recordId);
$this->initForm($model);
$this->controller->formBeforeSave($model);
$this->controller->formBeforeUpdate($model);
$modelsToSave = $this->prepareModelsToSave($model, $this->formWidget->getSaveData());
Db::transaction(function () use ($modelsToSave) {
foreach ($modelsToSave as $modelToSave) {
$modelToSave->save(null, $this->formWidget->getSessionKey());
}
});
$this->controller->formAfterSave($model);
$this->controller->formAfterUpdate($model);
Flash::success($this->getLang("{$this->context}[flashSave]", 'backend::lang.form.update_success'));
if ($redirect = $this->makeRedirect($this->context, $model)) {
return $redirect;
}
}
/**
* AJAX handler "onDelete" called from the update action and
* used for deleting existing records.
*
* This handler will invoke the unique controller override
* `formAfterDelete`.
*
* @param int $recordId Record identifier
* @return \Illuminate\Http\RedirectResponse|void
* @throws \Winter\Storm\Exception\ApplicationException if the provided recordId is not found
* @throws Exception if there is no primary key on the model
*/
public function update_onDelete($recordId = null)
{
$this->context = $this->getConfig('update[context]', self::CONTEXT_UPDATE);
$model = $this->controller->formFindModelObject($recordId);
$this->initForm($model);
$model->delete();
$this->controller->formAfterDelete($model);
Flash::success($this->getLang("{$this->context}[flashDelete]", 'backend::lang.form.delete_success'));
if ($redirect = $this->makeRedirect('delete', $model)) {
return $redirect;
}
}
//
// Preview
//
/**
* Controller "preview" action used for viewing existing model records.
* This action takes a record identifier (primary key of the model)
* to locate the record used for sourcing the existing preview data.
*
* @param int $recordId Record identifier
* @param string $context Form context
* @return void
*/
public function preview($recordId = null, $context = null)
{
try {
$this->context = strlen($context) ? $context : $this->getConfig('preview[context]', self::CONTEXT_PREVIEW);
$this->controller->pageTitle = $this->controller->pageTitle ?: $this->getLang(
"{$this->context}[title]",
'backend::lang.form.preview_title'
);
$model = $this->controller->formFindModelObject($recordId);
$this->initForm($model);
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
}
//
// Utils
//
/**
* Method to render the prepared form markup. This method is usually
* called from a view file.
*
* <?= $this->formRender() ?>
*
* The first argument supports an array of render options. The supported
* options can be found via the `render` method of the Form widget class.
*
* <?= $this->formRender(['preview' => true, section' => 'primary']) ?>
*
* @see \Backend\Widgets\Form
* @param array $options Render options
* @return string Rendered HTML for the form.
* @throws \Winter\Storm\Exception\ApplicationException if the Form Widget isn't set
*/
public function formRender($options = [])
{
if (!$this->formWidget) {
throw new ApplicationException(Lang::get('backend::lang.form.behavior_not_ready'));
}
return $this->formWidget->render($options);
}
/**
* Returns the model initialized by this form behavior.
* The model will be provided by one of the page actions or AJAX
* handlers via the `initForm` method.
*
* @return \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formGetModel()
{
return $this->model;
}
/**
* Returns the active form context, either obtained from the postback
* variable called `form_context` or detected from the configuration,
* or routing parameters.
*
* @return string
*/
public function formGetContext()
{
return $this->context;
}
/**
* Internal method used to prepare the form model object.
*
* @return \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
protected function createModel()
{
$class = $this->config->modelClass;
return new $class;
}
/**
* Returns a Redirect object based on supplied context and parses
* the model primary key.
*
* @param string $context Redirect context, eg: create, update, delete
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model $model The active model to parse in it's ID and attributes.
* @return \Illuminate\Http\RedirectResponse
*/
public function makeRedirect($context = null, $model = null)
{
$redirectUrl = null;
if (post('close') && !ends_with($context, '-close')) {
$context .= '-close';
}
if (post('refresh', false)) {
return Redirect::refresh();
}
if (post('new', false)) {
return Redirect::to($this->controller->actionUrl('create'));
}
if (post('redirect', true)) {
$redirectUrl = $this->controller->formGetRedirectUrl($context, $model);
}
if ($model && $redirectUrl) {
$redirectUrl = RouterHelper::replaceParameters($model, $redirectUrl);
}
if (starts_with($redirectUrl, 'http://') || starts_with($redirectUrl, 'https://')) {
// Process absolute redirects
$redirect = Redirect::to($redirectUrl);
} else {
// Process relative redirects
$redirect = $redirectUrl ? Backend::redirect($redirectUrl) : null;
}
return $redirect;
}
/**
* Returns a redirect URL from the config based on supplied context.
* Otherwise the default redirect is used. Relative URLs are treated as
* backend URLs.
*
* @param string $context Redirect context, eg: create, update, delete.
* @param Model $model The active model.
* @return string
*/
public function formGetRedirectUrl($context = null, $model = null)
{
$redirectContext = explode('-', $context, 2)[0];
$redirectSource = ends_with($context, '-close') ? 'redirectClose' : 'redirect';
// Get the redirect for the provided context
$redirects = [$context => $this->getConfig("{$redirectContext}[{$redirectSource}]", '')];
// Assign the default redirect afterwards to prevent the
// source for the default redirect being default[redirect]
$redirects['default'] = $this->getConfig('defaultRedirect', '');
if (empty($redirects[$context])) {
return $redirects['default'];
}
return $redirects[$context];
}
/**
* Parses in some default variables to a language string defined in config.
*
* @param string $name Configuration property containing the language string
* @param string $default A default language string to use if the config is not found
* @param array $extras Any extra params to include in the language string variables
* @return string The translated string.
*/
protected function getLang($name, $default = null, $extras = [])
{
$name = $this->getConfig($name, $default);
$vars = [
'name' => Lang::get($this->getConfig('name', 'backend::lang.model.name'))
];
$vars = array_merge($vars, $extras);
return Lang::get($name, $vars);
}
//
// Pass-through Helpers
//
/**
* View helper to render a single form field.
*
* <?= $this->formRenderField('field_name') ?>
*
* @param string $name Field name
* @param array $options (e.g. ['useContainer'=>false])
* @return string HTML markup
*/
public function formRenderField($name, $options = [])
{
return $this->formWidget->renderField($name, $options);
}
/**
* View helper to render the form in preview mode.
*
* <?= $this->formRenderPreview() ?>
*
* @return string The form HTML markup.
* @throws \Winter\Storm\Exception\ApplicationException if the Form Widget isn't set
*/
public function formRenderPreview()
{
return $this->formRender(['preview' => true]);
}
/**
* View helper to check if a form tab has fields in the
* non-tabbed section (outside fields).
*
* <?php if ($this->formHasOutsideFields()): ?>
* <!-- Do something -->
* <?php endif ?>
*
* @return bool
*/
public function formHasOutsideFields()
{
return $this->formWidget->getTab('outside')->hasFields();
}
/**
* View helper to render the form fields belonging to the
* non-tabbed section (outside form fields).
*
* <?= $this->formRenderOutsideFields() ?>
*
* @return string HTML markup
* @throws \Winter\Storm\Exception\ApplicationException if the Form Widget isn't set
*/
public function formRenderOutsideFields()
{
return $this->formRender(['section' => 'outside']);
}
/**
* View helper to check if a form tab has fields in the
* primary tab section.
*
* <?php if ($this->formHasPrimaryTabs()): ?>
* <!-- Do something -->
* <?php endif ?>
*
* @return bool
*/
public function formHasPrimaryTabs()
{
return $this->formWidget->getTab('primary')->hasFields();
}
/**
* View helper to render the form fields belonging to the
* primary tabs section.
*
* <?= $this->formRenderPrimaryTabs() ?>
*
* @return string HTML markup
* @throws \Winter\Storm\Exception\ApplicationException if the Form Widget isn't set
*/
public function formRenderPrimaryTabs()
{
return $this->formRender(['section' => 'primary']);
}
/**
* View helper to check if a form tab has fields in the
* secondary tab section.
*
* <?php if ($this->formHasSecondaryTabs()): ?>
* <!-- Do something -->
* <?php endif ?>
*
* @return bool
*/
public function formHasSecondaryTabs()
{
return $this->formWidget->getTab('secondary')->hasFields();
}
/**
* View helper to render the form fields belonging to the
* secondary tabs section.
*
* <?= $this->formRenderSecondaryTabs() ?>
*
* @return string HTML markup
* @throws \Winter\Storm\Exception\ApplicationException if the Form Widget isn't set
*/
public function formRenderSecondaryTabs()
{
return $this->formRender(['section' => 'secondary']);
}
/**
* View helper to render the previous/next record navigation for the record
* being edited, relative to its sibling records in the controller's list.
*
* <?= $this->formRenderRecordNavigation() ?>
*
* Renders nothing unless the `recordNavigation` config is enabled (the
* default), the controller also implements the ListController behavior, and
* an existing record is being viewed.
*
* @return string HTML markup (empty string when navigation is unavailable)
*/
public function formRenderRecordNavigation(): string
{
$navigation = $this->formGetRecordNavigation();
if ($navigation === null || $navigation['current'] === null) {
return '';
}
return $this->formMakePartial('record_navigation', [
'navigation' => $navigation,
'navigationContext' => $this->context,
]);
}
/**
* Resolves the position of the current record within the controller's list
* and the neighboring record keys used for previous/next navigation.
*
* The sibling set comes from the ListController's prepared query, so it
* reflects the active filters, search and sorting exactly as the user left
* the list. Ordered keys are read with a single portable `pluck` and the
* position is resolved in PHP no driver-specific SQL so it behaves
* identically across every database Winter supports.
*
* @param \Winter\Storm\Database\Model|null $model
* @return array{previous: mixed, next: mixed, current: int|null, total: int}|null
*/
public function formGetRecordNavigation($model = null): ?array
{
if (!$this->getConfig('recordNavigation', true)) {
return null;
}
$model = $model ?: $this->model;
if (!$model || !$model->exists) {
return null;
}
if (!$this->controller->isClassExtendedWith(\Backend\Behaviors\ListController::class)) {
return null;
}
$this->controller->makeLists();
$listWidget = $this->controller->listGetWidget();
if (!$listWidget) {
return null;
}
$keys = $listWidget->prepareQuery()->pluck($model->getQualifiedKeyName())->all();
return static::resolveRecordPosition($keys, $model->getKey());
}
/**
* Pure position math for record navigation: given an ordered list of record
* keys and the current key, returns the navigation descriptor. Performs no
* database access, so it is trivially unit-testable and identical on every
* driver.
*
* @param array<int, mixed> $keys Ordered list of record keys.
* @param mixed $currentKey The key of the record being viewed.
* @return array{previous: mixed, next: mixed, current: int|null, total: int}
*/
public static function resolveRecordPosition(array $keys, $currentKey): array
{
$keys = array_values($keys);
$total = count($keys);
$position = null;
foreach ($keys as $index => $key) {
if ((string) $key === (string) $currentKey) {
$position = $index;
break;
}
}
return [
'previous' => ($position !== null && $position > 0) ? $keys[$position - 1] : null,
'next' => ($position !== null && $position < $total - 1) ? $keys[$position + 1] : null,
'current' => $position === null ? null : $position + 1,
'total' => $total,
];
}
/**
* Returns the form widget used by this behavior.
*
* @return \Backend\Widgets\Form
*/
public function formGetWidget()
{
return $this->formWidget;
}
/**
* Returns a unique ID for the form widget used by this behavior.
* This is useful for dealing with identifiers in the markup.
*
* <div id="<?= $this->formGetId()">...</div>
*
* A suffix may be used passed as the first argument to reuse
* the identifier in other areas.
*
* <button id="<?= $this->formGetId('button')">...</button>
*
* @param string $suffix
* @return string
*/
public function formGetId($suffix = null)
{
return $this->formWidget->getId($suffix);
}
/**
* Helper to get the form session key.
*
* @return string
*/
public function formGetSessionKey()
{
return $this->formWidget->getSessionKey();
}
//
// Overrides
//
/**
* Called before the creation or updating form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formBeforeSave($model)
{
}
/**
* Called after the creation or updating form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formAfterSave($model)
{
}
/**
* Called before the creation form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formBeforeCreate($model)
{
}
/**
* Called after the creation form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formAfterCreate($model)
{
}
/**
* Called before the updating form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formBeforeUpdate($model)
{
}
/**
* Called after the updating form is saved.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formAfterUpdate($model)
{
}
/**
* Called after the form model is deleted.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formAfterDelete($model)
{
}
/**
* Finds a Model record by its primary identifier, used by update actions. This logic
* can be changed by overriding it in the controller.
* @param string $recordId
* @return \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
* @throws \Winter\Storm\Exception\ApplicationException if the provided recordId is not found
*/
public function formFindModelObject($recordId)
{
if (!strlen($recordId)) {
throw new ApplicationException($this->getLang('not-found-message', 'backend::lang.form.missing_id'));
}
$model = $this->controller->formCreateModelObject();
/*
* Prepare query and find model record
*/
$query = $model->newQuery();
$this->controller->formExtendQuery($query);
$result = $query->find($recordId);
if (!$result) {
throw new ApplicationException($this->getLang('not-found-message', 'backend::lang.form.not_found', [
'class' => get_class($model), 'id' => $recordId
]));
}
$result = $this->controller->formExtendModel($result) ?: $result;
return $result;
}
/**
* Creates a new instance of a form model. This logic can be changed
* by overriding it in the controller.
* @return \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model
*/
public function formCreateModelObject()
{
return $this->createModel();
}
/**
* Called before the form fields are defined.
* @param \Backend\Widgets\Form $host The hosting form widget
* @return void
*/
public function formExtendFieldsBefore($host)
{
}
/**
* Called after the form fields are defined.
* @param \Backend\Widgets\Form $host The hosting form widget
* @param array $fields Array of all defined form field objects (\Backend\Classes\FormField)
* @return void
*/
public function formExtendFields($host, $fields)
{
}
/**
* Called before the form is refreshed, should return an array of additional save data.
* @param \Backend\Widgets\Form $host The hosting form widget
* @param array $saveData Current save data
* @return array|void
*/
public function formExtendRefreshData($host, $saveData)
{
}
/**
* Called when the form is refreshed, giving the opportunity to modify the form fields.
* @param \Backend\Widgets\Form $host The hosting form widget
* @param array $fields Current form fields
* @return array|void
*/
public function formExtendRefreshFields($host, $fields)
{
}
/**
* Called after the form is refreshed, should return an array of additional result parameters.
* @param \Backend\Widgets\Form $host The hosting form widget
* @param array $result Current result parameters.
* @return array|void
*/
public function formExtendRefreshResults($host, $result)
{
}
/**
* Extend supplied model used by create and update actions, the model can
* be altered by overriding it in the controller.
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model $model
* @return \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model|void
*/
public function formExtendModel($model)
{
}
/**
* Extend the query used for finding the form model. Extra conditions
* can be applied to the query, for example, $query->withTrashed();
* @param \Winter\Storm\Database\Builder|\Winter\Storm\Halcyon\Builder $query
* @return void
*/
public function formExtendQuery($query)
{
}
/**
* Static helper for extending form fields.
* @param callable $callback
* @return void
*/
public static function extendFormFields($callback)
{
$calledClass = self::getCalledExtensionClass();
Event::listen('backend.form.extendFields', function ($widget) use ($calledClass, $callback) {
if (!is_a($widget->getController(), $calledClass)) {
return;
}
call_user_func_array($callback, [$widget, $widget->model, $widget->getContext()]);
});
}
/**
* Controller accessor for making partials within this behavior.
*/
public function formMakePartial(string $partial, array $params = []): string
{
$contents = $this->controller->makePartial('form_' . $this->context . '_' . $partial, $params + $this->vars, false);
if (!$contents) {
$contents = $this->controller->makePartial('form_' . $partial, $params + $this->vars, false);
}
if (!$contents) {
$contents = $this->makePartial($partial, $params);
}
return $contents;
}
}

View File

@@ -0,0 +1,873 @@
<?php
namespace Backend\Behaviors;
use Backend\Behaviors\ImportExportController\TranscodeFilter;
use Backend\Classes\Controller;
use Backend\Classes\ControllerBehavior;
use Backend\Classes\WidgetBase;
use Backend\Facades\Backend;
use Backend\Facades\BackendAuth;
use Exception;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Response;
use League\Csv\EscapeFormula as CsvEscapeFormula;
use League\Csv\Reader as CsvReader;
use League\Csv\Statement as CsvStatement;
use League\Csv\Writer as CsvWriter;
use SplTempFileObject;
use Winter\Storm\Database\Model;
use Winter\Storm\Exception\ApplicationException;
use Winter\Storm\Support\Str;
/**
* Adds features for importing and exporting data.
*
* This behavior is implemented in the controller like so:
*
* public $implement = [
* \Backend\Behaviors\ImportExportController::class,
* ];
*
* public $importExportConfig = 'config_import_export.yaml';
*
* The `$importExportConfig` property makes reference to the configuration
* values as either a YAML file, located in the controller view directory,
* or directly as a PHP array.
*
* @package winter\wn-backend-module
* @author Alexey Bobkov, Samuel Georges
*/
class ImportExportController extends ControllerBehavior
{
/**
* @var array Configuration values that must exist when applying the primary config file.
*/
protected $requiredConfig = [];
/**
* @var array Visible actions in context of the controller
*/
protected $actions = ['import', 'export', 'download'];
/**
* @var Model Import model
*/
public $importModel;
/**
* @var array Import column configuration.
*/
public $importColumns;
/**
* @var WidgetBase Reference to the widget used for uploading import file.
*/
protected $importUploadFormWidget;
/**
* @var WidgetBase Reference to the widget used for specifying import options.
*/
protected $importOptionsFormWidget;
/**
* @var Model Export model
*/
public $exportModel;
/**
* @var array Export column configuration.
*/
public $exportColumns;
/**
* @var string File name used for export output.
*/
protected $exportFileName = 'export.csv';
/**
* @var WidgetBase Reference to the widget used for standard export options.
*/
protected $exportFormatFormWidget;
/**
* @var WidgetBase Reference to the widget used for custom export options.
*/
protected $exportOptionsFormWidget;
/**
* @var mixed Configuration for this behaviour
*/
public $importExportConfig = 'config_import_export.yaml';
/**
* Behavior constructor
* @param Controller $controller
*/
public function __construct($controller)
{
parent::__construct($controller);
/*
* Build configuration
*/
$this->config = $this->makeConfig($controller->importExportConfig ?: $this->importExportConfig, $this->requiredConfig);
/*
* Process config
*/
if ($exportFileName = $this->getConfig('export[fileName]')) {
$this->exportFileName = $exportFileName;
}
/*
* Import form widgets
*/
if ($this->importUploadFormWidget = $this->makeImportUploadFormWidget()) {
$this->importUploadFormWidget->bindToController();
}
if ($this->importOptionsFormWidget = $this->makeImportOptionsFormWidget()) {
$this->importOptionsFormWidget->bindToController();
}
/*
* Export form widgets
*/
if ($this->exportFormatFormWidget = $this->makeExportFormatFormWidget()) {
$this->exportFormatFormWidget->bindToController();
}
if ($this->exportOptionsFormWidget = $this->makeExportOptionsFormWidget()) {
$this->exportOptionsFormWidget->bindToController();
}
}
//
// Controller actions
//
public function import()
{
if (!$this->userHasAccess('import')) {
abort(403);
}
$this->addJs('js/winter.import.js', 'core');
$this->addCss('css/import.css', 'core');
$this->controller->pageTitle = $this->controller->pageTitle
?: Lang::get($this->getConfig('import[title]', 'Import records'));
$this->prepareImportVars();
}
public function export()
{
if (!$this->userHasAccess('export')) {
abort(403);
}
if ($response = $this->checkUseListExportMode()) {
return $response;
}
$this->addJs('js/winter.export.js', 'core');
$this->addCss('css/export.css', 'core');
$this->controller->pageTitle = $this->controller->pageTitle
?: Lang::get($this->getConfig('export[title]', 'Export records'));
$this->prepareExportVars();
}
public function download($name, $outputName = null)
{
if (!$this->userHasAccess('export')) {
abort(403);
}
$this->controller->pageTitle = $this->controller->pageTitle
?: Lang::get($this->getConfig('export[title]', 'Export records'));
return $this->exportGetModel()->download($name, $outputName);
}
//
// Importing AJAX
//
public function onImport()
{
if (!$this->userHasAccess('import')) {
abort(403);
}
try {
$model = $this->importGetModel();
$matches = post('column_match', []);
if ($optionData = post('ImportOptions')) {
$model->fill($optionData);
}
$importOptions = $this->getFormatOptionsFromPost();
$importOptions['sessionKey'] = $this->importUploadFormWidget->getSessionKey();
$importOptions['firstRowTitles'] = post('first_row_titles', false);
$model->import($matches, $importOptions);
$this->vars['importResults'] = $model->getResultStats();
$this->vars['returnUrl'] = $this->getRedirectUrlForType('import');
}
catch (MassAssignmentException $ex) {
$this->controller->handleError(new ApplicationException(Lang::get(
'backend::lang.model.mass_assignment_failed',
['attribute' => $ex->getMessage()]
)));
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
$this->vars['sourceIndexOffset'] = $this->getImportSourceIndexOffset($importOptions['firstRowTitles']);
return $this->importExportMakePartial('import_result_form');
}
public function onImportLoadForm()
{
if (!$this->userHasAccess('import')) {
abort(403);
}
try {
$this->checkRequiredImportColumns();
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
return $this->importExportMakePartial('import_form');
}
public function onImportLoadColumnSampleForm()
{
if (!$this->userHasAccess('import')) {
abort(403);
}
if (($columnId = post('file_column_id', false)) === false) {
throw new ApplicationException(Lang::get('backend::lang.import_export.missing_column_id_error'));
}
$columns = $this->getImportFileColumns();
if (!array_key_exists($columnId, $columns)) {
throw new ApplicationException(Lang::get('backend::lang.import_export.unknown_column_error'));
}
$path = $this->getImportFilePath();
$reader = $this->createCsvReader($path);
if (post('first_row_titles')) {
$reader->setHeaderOffset(1);
}
$result = (new CsvStatement())->limit(50)->process($reader)->fetchColumn((int) $columnId);
$data = iterator_to_array($result, false);
/*
* Clean up data
*/
foreach ($data as $index => $sample) {
$data[$index] = Str::limit($sample, 100);
if (!strlen($data[$index])) {
unset($data[$index]);
}
}
$this->vars['columnName'] = array_get($columns, $columnId);
$this->vars['columnData'] = $data;
return $this->importExportMakePartial('column_sample_form');
}
//
// Importing
//
/**
* Prepares the view data.
* @return void
*/
public function prepareImportVars()
{
$this->vars['importUploadFormWidget'] = $this->importUploadFormWidget;
$this->vars['importOptionsFormWidget'] = $this->importOptionsFormWidget;
$this->vars['importDbColumns'] = $this->getImportDbColumns();
$this->vars['importFileColumns'] = $this->getImportFileColumns();
// Make these variables available to widgets
$this->controller->vars += $this->vars;
}
public function importRender()
{
return $this->importExportMakePartial('container_import');
}
public function importGetModel()
{
return $this->getModelForType('import');
}
protected function getImportDbColumns()
{
if ($this->importColumns !== null) {
return $this->importColumns;
}
$columnConfig = $this->getConfig('import[list]');
$columns = $this->makeListColumns($columnConfig);
if (empty($columns)) {
throw new ApplicationException(Lang::get('backend::lang.import_export.empty_import_columns_error'));
}
return $this->importColumns = $columns;
}
protected function getImportFileColumns()
{
if (!$path = $this->getImportFilePath()) {
return null;
}
$reader = $this->createCsvReader($path);
$firstRow = $reader->fetchOne(0);
if (!post('first_row_titles')) {
array_walk($firstRow, function (&$value, $key) {
$value = 'Column #'.($key + 1);
});
}
/*
* Prevents unfriendly error to be thrown due to bad encoding at response time.
*/
if (json_encode($firstRow) === false) {
throw new ApplicationException(Lang::get('backend::lang.import_export.encoding_not_supported_error'));
}
return $firstRow;
}
/**
* Get the index offset to add to the reported row number in status messages
*
* @param bool $firstRowTitles Whether or not the first row contains column titles
* @return int $offset
*/
protected function getImportSourceIndexOffset($firstRowTitles)
{
return $firstRowTitles ? 2 : 1;
}
protected function makeImportUploadFormWidget()
{
if (!$this->getConfig('import')) {
return null;
}
$widgetConfig = $this->makeConfig('~/modules/backend/behaviors/importexportcontroller/partials/fields_import.yaml');
$widgetConfig->model = $this->importGetModel();
$widgetConfig->alias = 'importUploadForm';
$widget = $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
$widget->bindEvent('form.beforeRefresh', function ($holder) {
$holder->data = [];
});
return $widget;
}
protected function makeImportOptionsFormWidget()
{
$widget = $this->makeOptionsFormWidgetForType('import');
if (!$widget && $this->importUploadFormWidget) {
$stepSection = $this->importUploadFormWidget->getField('step3_section');
$stepSection->hidden = true;
}
return $widget;
}
protected function getImportFilePath()
{
return $this
->importGetModel()
->getImportFilePath($this->importUploadFormWidget->getSessionKey());
}
public function importIsColumnRequired($columnName)
{
$model = $this->importGetModel();
return $model->isAttributeRequired($columnName);
}
protected function checkRequiredImportColumns()
{
if (!$matches = post('column_match', [])) {
throw new ApplicationException(Lang::get('backend::lang.import_export.match_some_column_error'));
}
$dbColumns = $this->getImportDbColumns();
foreach ($dbColumns as $column => $label) {
if (!$this->importIsColumnRequired($column)) {
continue;
}
$found = false;
foreach ($matches as $matchedColumns) {
if (in_array($column, $matchedColumns)) {
$found = true;
break;
}
}
if (!$found) {
throw new ApplicationException(Lang::get('backend::lang.import_export.required_match_column_error', [
'label' => Lang::get($label)
]));
}
}
}
//
// Exporting AJAX
//
public function onExport()
{
if (!$this->userHasAccess('export')) {
abort(403);
}
try {
$model = $this->exportGetModel();
$columns = $this->processExportColumnsFromPost();
if ($optionData = post('ExportOptions')) {
$model->fill($optionData);
}
$exportOptions = $this->getFormatOptionsFromPost();
$exportOptions['sessionKey'] = $this->exportFormatFormWidget->getSessionKey();
$reference = $model->export($columns, $exportOptions);
$fileUrl = $this->controller->actionUrl(
'download',
$reference.'/'.$this->exportFileName
);
$this->vars['fileUrl'] = $fileUrl;
$this->vars['returnUrl'] = $this->getRedirectUrlForType('export');
}
catch (MassAssignmentException $ex) {
$this->controller->handleError(new ApplicationException(Lang::get(
'backend::lang.model.mass_assignment_failed',
['attribute' => $ex->getMessage()]
)));
}
catch (Exception $ex) {
$this->controller->handleError($ex);
}
return $this->importExportMakePartial('export_result_form');
}
public function onExportLoadForm()
{
if (!$this->userHasAccess('export')) {
abort(403);
}
return $this->importExportMakePartial('export_form');
}
//
// Exporting
//
/**
* Prepares the view data.
* @return void
*/
public function prepareExportVars()
{
$this->vars['exportFormatFormWidget'] = $this->exportFormatFormWidget;
$this->vars['exportOptionsFormWidget'] = $this->exportOptionsFormWidget;
$this->vars['exportColumns'] = $this->getExportColumns();
// Make these variables available to widgets
$this->controller->vars += $this->vars;
}
public function exportRender()
{
return $this->importExportMakePartial('container_export');
}
public function exportGetModel()
{
return $this->getModelForType('export');
}
protected function getExportColumns()
{
if ($this->exportColumns !== null) {
return $this->exportColumns;
}
$columnConfig = $this->getConfig('export[list]');
$columns = $this->makeListColumns($columnConfig);
if (empty($columns)) {
throw new ApplicationException(Lang::get('backend::lang.import_export.empty_export_columns_error'));
}
return $this->exportColumns = $columns;
}
protected function makeExportFormatFormWidget()
{
if (!$this->getConfig('export') || $this->getConfig('export[useList]')) {
return null;
}
$widgetConfig = $this->makeConfig('~/modules/backend/behaviors/importexportcontroller/partials/fields_export.yaml');
$widgetConfig->model = $this->exportGetModel();
$widgetConfig->alias = 'exportUploadForm';
$widget = $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
$widget->bindEvent('form.beforeRefresh', function ($holder) {
$holder->data = [];
});
return $widget;
}
protected function makeExportOptionsFormWidget()
{
$widget = $this->makeOptionsFormWidgetForType('export');
if (!$widget && $this->exportFormatFormWidget) {
$stepSection = $this->exportFormatFormWidget->getField('step3_section');
$stepSection->hidden = true;
}
return $widget;
}
protected function processExportColumnsFromPost()
{
$visibleColumns = post('visible_columns', []);
$columns = post('export_columns', []);
foreach ($columns as $key => $columnName) {
if (!isset($visibleColumns[$columnName])) {
unset($columns[$key]);
}
}
$result = [];
$definitions = $this->getExportColumns();
foreach ($columns as $column) {
$result[$column] = array_get($definitions, $column, '???');
}
return $result;
}
//
// ListController integration
//
protected function checkUseListExportMode()
{
if (!$useList = $this->getConfig('export[useList]')) {
return false;
}
if (!$this->controller->isClassExtendedWith(\Backend\Behaviors\ListController::class)) {
throw new ApplicationException(Lang::get('backend::lang.import_export.behavior_missing_uselist_error'));
}
if (is_array($useList)) {
$listDefinition = array_get($useList, 'definition');
}
else {
$listDefinition = $useList;
}
return $this->exportFromList($listDefinition);
}
/**
* Outputs the list results as a CSV export.
* @param string $definition
* @param array $options
* @return void
*/
public function exportFromList($definition = null, $options = [])
{
$lists = $this->controller->makeLists();
$widget = $lists[$definition] ?? reset($lists);
/*
* Parse options
*/
$defaultOptions = [
'fileName' => $this->exportFileName,
'delimiter' => $this->getConfig('defaultFormatOptions[delimiter]', ','),
'enclosure' => $this->getConfig('defaultFormatOptions[enclosure]', '"'),
'escape' => $this->getConfig('defaultFormatOptions[escape]', '\\'),
];
$options = array_merge($defaultOptions, $options);
$filename = filter_var($options['fileName'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW);
/*
* Prepare CSV
*/
$csv = CsvWriter::createFromFileObject(new SplTempFileObject);
$csv->setOutputBOM(CsvWriter::BOM_UTF8);
$csv->setDelimiter($options['delimiter']);
$csv->setEnclosure($options['enclosure']);
$csv->setEscape($options['escape']);
$csv->addFormatter(new CsvEscapeFormula());
/*
* Add headers
*/
$headers = [];
$columns = $widget->getVisibleColumns();
foreach ($columns as $column) {
$headers[] = $widget->getHeaderValue($column);
}
$csv->insertOne($headers);
/*
* Add records
*/
$getter = $this->getConfig('export[useList][raw]', false)
? 'getColumnValueRaw'
: 'getColumnValue';
$query = $widget->prepareQuery();
$results = $query->get();
if ($event = $widget->fireSystemEvent('backend.list.extendRecords', [&$results])) {
$results = $event;
}
foreach ($results as $result) {
$record = [];
foreach ($columns as $column) {
$value = $widget->$getter($result, $column);
if (is_array($value)) {
$value = implode('|', $value);
}
$record[] = $value;
}
$csv->insertOne($record);
}
/*
* Response
*/
$response = Response::make();
$response->header('Content-Type', 'text/csv');
$response->header('Content-Transfer-Encoding', 'binary');
$response->header('Content-Disposition', sprintf('%s; filename="%s"', 'attachment', $filename));
$response->setContent((string) $csv);
return $response;
}
//
// Helpers
//
/**
* Controller accessor for making partials within this behavior.
* @param string $partial
* @param array $params
* @return string Partial contents
*/
public function importExportMakePartial($partial, $params = [])
{
$contents = $this->controller->makePartial('import_export_'.$partial, $params + $this->vars, false);
if (!$contents) {
$contents = $this->makePartial($partial, $params);
}
return $contents;
}
/**
* Check if the current user has access to the provided import/export action
*/
public function userHasAccess(string $type): bool
{
if (
($permissions = $this->getConfig($type.'[permissions]')) &&
(!BackendAuth::getUser()->hasAnyAccess((array) $permissions))
) {
return false;
}
return true;
}
protected function makeOptionsFormWidgetForType($type)
{
if (!$this->getConfig($type)) {
return null;
}
if ($fieldConfig = $this->getConfig($type.'[form]')) {
$widgetConfig = $this->makeConfig($fieldConfig);
$widgetConfig->model = $this->getModelForType($type);
$widgetConfig->alias = $type.'OptionsForm';
$widgetConfig->arrayName = ucfirst($type).'Options';
return $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
}
return null;
}
protected function getModelForType($type)
{
$cacheProperty = $type.'Model';
if ($this->{$cacheProperty} !== null) {
return $this->{$cacheProperty};
}
$modelClass = $this->getConfig($type.'[modelClass]');
if (!$modelClass) {
throw new ApplicationException(Lang::get('backend::lang.import_export.missing_model_class_error', [
'type' => $type
]));
}
return $this->{$cacheProperty} = new $modelClass;
}
protected function makeListColumns($config)
{
$config = $this->makeConfig($config);
if (!isset($config->columns) || !is_array($config->columns)) {
return null;
}
$result = [];
foreach ($config->columns as $attribute => $column) {
if (is_array($column)) {
$result[$attribute] = array_get($column, 'label', $attribute);
}
else {
$result[$attribute] = $column ?: $attribute;
}
}
return $result;
}
protected function getRedirectUrlForType($type)
{
$redirect = $this->getConfig($type.'[redirect]');
if ($redirect !== null) {
return $redirect ? Backend::url($redirect) : 'javascript:;';
}
return $this->controller->actionUrl($type);
}
/**
* Create a new CSV reader with options selected by the user
* @param string $path
*
* @return CsvReader
*/
protected function createCsvReader($path)
{
$reader = CsvReader::createFromPath($path);
$options = $this->getFormatOptionsFromPost();
if ($options['delimiter'] !== null) {
$reader->setDelimiter($options['delimiter']);
}
if ($options['enclosure'] !== null) {
$reader->setEnclosure($options['enclosure']);
}
if ($options['escape'] !== null) {
$reader->setEscape($options['escape']);
}
if (
$options['encoding'] !== null &&
$reader->supportsStreamFilter()
) {
$reader->addStreamFilter(sprintf(
'%s%s:%s',
TranscodeFilter::FILTER_NAME,
strtolower($options['encoding']),
'utf-8'
));
}
return $reader;
}
/**
* Returns the file format options from postback. This method
* can be used to define presets.
* @return array
*/
protected function getFormatOptionsFromPost()
{
$presetMode = post('format_preset');
$options = [
'delimiter' => $this->getConfig('defaultFormatOptions[delimiter]'),
'enclosure' => $this->getConfig('defaultFormatOptions[enclosure]'),
'escape' => $this->getConfig('defaultFormatOptions[escape]'),
'encoding' => $this->getConfig('defaultFormatOptions[encoding]'),
];
if ($presetMode == 'custom') {
$options['delimiter'] = post('format_delimiter');
$options['enclosure'] = post('format_enclosure');
$options['escape'] = post('format_escape');
$options['encoding'] = post('format_encoding');
}
return $options;
}
}

View File

@@ -0,0 +1,652 @@
<?php
namespace Backend\Behaviors;
use Backend\Classes\ControllerBehavior;
use Illuminate\Support\Facades\Lang;
use Winter\Storm\Exception\ApplicationException;
use Winter\Storm\Support\Facades\Event;
use Winter\Storm\Support\Facades\Flash;
/**
* Adds features for working with backend lists.
*
* This behavior is implemented in the controller like so:
*
* public $implement = [
* \Backend\Behaviors\ListController::class,
* ];
*
* public $listConfig = 'config_list.yaml';
*
* The `$listConfig` property makes reference to the list configuration
* values as either a YAML file, located in the controller view directory,
* or directly as a PHP array.
*
* @package winter\wn-backend-module
* @author Alexey Bobkov, Samuel Georges
*/
class ListController extends ControllerBehavior
{
/**
* @var array List definitions, keys for alias and value for configuration.
*/
protected $listDefinitions;
/**
* @var string The primary list alias to use. Default: list
*/
protected $primaryDefinition;
/**
* @var \Backend\Classes\WidgetBase[] Reference to the list widget object.
*/
protected $listWidgets = [];
/**
* @var \Backend\Classes\WidgetBase[] Reference to the toolbar widget objects.
*/
protected $toolbarWidgets = [];
/**
* @var \Backend\Classes\WidgetBase[] Reference to the filter widget objects.
*/
protected $filterWidgets = [];
/**
* @var array Configuration values that must exist when applying the primary config file.
* - modelClass: Class name for the model
* - list: List column definitions
*/
protected $requiredConfig = ['modelClass', 'list'];
/**
* @var array Visible actions in context of the controller
*/
protected $actions = ['index'];
/**
* @var mixed Configuration for this behaviour
*/
public $listConfig = 'config_list.yaml';
/**
* Behavior constructor
* @param \Backend\Classes\Controller $controller
*/
public function __construct($controller)
{
parent::__construct($controller);
/*
* Extract list definitions
*/
$config = $controller->listConfig ?: $this->listConfig;
if (is_array($config)) {
$this->listDefinitions = $config;
$this->primaryDefinition = key($this->listDefinitions);
}
else {
$this->listDefinitions = ['list' => $config];
$this->primaryDefinition = 'list';
}
/*
* Build configuration
*/
$this->setConfig($this->listDefinitions[$this->primaryDefinition], $this->requiredConfig);
}
/**
* Creates all the list widgets based on the definitions.
* @return array
*/
public function makeLists()
{
foreach ($this->listDefinitions as $definition => $config) {
$this->listWidgets[$definition] = $this->makeList($definition);
}
return $this->listWidgets;
}
/**
* Prepare the widgets used by this action
* @return \Backend\Widgets\Lists
*/
public function makeList($definition = null)
{
if (!$definition || !isset($this->listDefinitions[$definition])) {
$definition = $this->primaryDefinition;
}
$listConfig = $this->controller->listGetConfig($definition);
/*
* Create the model
*/
$class = $listConfig->modelClass;
$model = new $class;
$model = $this->controller->listExtendModel($model, $definition);
/*
* Prepare the list widget
*/
$columnConfig = $this->makeConfig($listConfig->list);
$columnConfig->model = $model;
$columnConfig->alias = $definition;
/*
* Prepare the columns configuration
*/
$configFieldsToTransfer = [
'recordUrl',
'recordOnClick',
'recordsPerPage',
'perPageOptions',
'showPageNumbers',
'noRecordsMessage',
'defaultSort',
'showSorting',
'showSetup',
'showCheckboxes',
'showTree',
'treeExpanded',
'customViewPath',
'sortable',
];
foreach ($configFieldsToTransfer as $field) {
if (isset($listConfig->{$field})) {
$columnConfig->{$field} = $listConfig->{$field};
}
}
/*
* List Widget with extensibility
*/
$widget = $this->makeWidget(\Backend\Widgets\Lists::class, $columnConfig);
/*
* Drag-and-drop reordering - requires the model to use the Sortable trait.
*/
if (!empty($listConfig->sortable)) {
if (!in_array(\Winter\Storm\Database\Traits\Sortable::class, class_uses_recursive($model))) {
throw new ApplicationException(sprintf(
'To use "sortable" on a list, the model "%s" must use the %s trait.',
get_class($model),
\Winter\Storm\Database\Traits\Sortable::class
));
}
/*
* Drag-and-drop reordering presents every record in a single fixed order, so it
* cannot coexist with features that show a partial or re-ordered view. Reject those
* combinations up front rather than silently producing a wrong order.
*/
$toolbar = $listConfig->toolbar ?? null;
$conflicts = array_keys(array_filter([
'toolbar search' => is_array($toolbar) && !empty($toolbar['search']),
'filter' => $listConfig->filter ?? null,
'recordsPerPage' => $listConfig->recordsPerPage ?? null,
'defaultSort' => $listConfig->defaultSort ?? null,
]));
if ($conflicts) {
throw new ApplicationException(sprintf(
'A "sortable" list cannot also use: %s. Drag-and-drop reordering requires the whole list in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.',
implode(', ', $conflicts)
));
}
$widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) {
$model->setSortableOrder($ids, $orders);
});
}
$widget->bindEvent('list.extendColumnsBefore', function () use ($widget) {
$this->controller->listExtendColumnsBefore($widget);
});
$widget->bindEvent('list.extendColumns', function () use ($widget) {
$this->controller->listExtendColumns($widget);
});
$widget->bindEvent('list.extendQueryBefore', function ($query) use ($definition) {
$this->controller->listExtendQueryBefore($query, $definition);
});
$widget->bindEvent('list.extendQuery', function ($query) use ($definition) {
$this->controller->listExtendQuery($query, $definition);
});
$widget->bindEvent('list.extendRecords', function ($records) use ($definition) {
return $this->controller->listExtendRecords($records, $definition);
});
$widget->bindEvent('list.injectRowClass', function ($record) use ($definition) {
return $this->controller->listInjectRowClass($record, $definition);
});
$widget->bindEvent('list.overrideColumnValue', function ($record, $column, $value) use ($definition) {
return $this->controller->listOverrideColumnValue($record, $column->columnName, $definition);
});
$widget->bindEvent('list.overrideHeaderValue', function ($column, $value) use ($definition) {
return $this->controller->listOverrideHeaderValue($column->columnName, $definition);
});
$widget->bindToController();
/*
* Prepare the toolbar widget (optional)
*/
if (isset($listConfig->toolbar)) {
$toolbarConfig = $this->makeConfig($listConfig->toolbar);
$toolbarConfig->alias = $widget->alias . 'Toolbar';
$toolbarWidget = $this->makeWidget(\Backend\Widgets\Toolbar::class, $toolbarConfig);
$toolbarWidget->bindToController();
$toolbarWidget->cssClasses[] = 'list-header';
/*
* Link the Search Widget to the List Widget
*/
if ($searchWidget = $toolbarWidget->getSearchWidget()) {
$searchWidget->bindEvent('search.submit', function () use ($widget, $searchWidget) {
$widget->setSearchTerm($searchWidget->getActiveTerm(), true);
return $widget->onRefresh();
});
$widget->setSearchOptions([
'mode' => $searchWidget->mode,
'scope' => $searchWidget->scope,
]);
// Find predefined search term
$widget->setSearchTerm($searchWidget->getActiveTerm());
}
$this->toolbarWidgets[$definition] = $toolbarWidget;
}
/*
* Prepare the filter widget (optional)
*/
if (isset($listConfig->filter)) {
$filterConfig = $this->makeConfig($listConfig->filter);
$widget->cssClasses[] = 'list-flush';
$filterConfig->alias = $widget->alias . 'Filter';
$filterWidget = $this->makeWidget(\Backend\Widgets\Filter::class, $filterConfig);
$filterWidget->bindToController();
/*
* Filter the list when the scopes are changed
*/
$filterWidget->bindEvent('filter.update', function () use ($widget, $filterWidget) {
return $widget->onFilter();
});
/*
* Filter Widget with extensibility
*/
$filterWidget->bindEvent('filter.extendScopes', function () use ($filterWidget) {
$this->controller->listFilterExtendScopes($filterWidget);
});
/*
* Extend the query of the list of options
*/
$filterWidget->bindEvent('filter.extendQuery', function ($query, $scope) {
$this->controller->listFilterExtendQuery($query, $scope);
});
// Apply predefined filter values
$widget->addFilter([$filterWidget, 'applyAllScopesToQuery']);
$this->filterWidgets[$definition] = $filterWidget;
}
return $widget;
}
/**
* Index Controller action.
* @return void
*/
public function index()
{
$this->controller->pageTitle = $this->controller->pageTitle ?: Lang::get($this->getConfig(
'title',
'backend::lang.list.default_title'
));
$this->controller->bodyClass = 'slim-container';
$this->makeLists();
}
/**
* Bulk delete records.
* @return void
* @throws \Winter\Storm\Exception\ApplicationException when the parent definition is missing.
*/
public function index_onDelete()
{
if (method_exists($this->controller, 'onDelete')) {
return call_user_func_array([$this->controller, 'onDelete'], func_get_args());
}
/*
* Establish the list definition
*/
$definition = post('definition', $this->primaryDefinition);
if (!isset($this->listDefinitions[$definition])) {
throw new ApplicationException(Lang::get('backend::lang.list.missing_parent_definition', compact('definition')));
}
$listConfig = $this->controller->listGetConfig($definition);
/*
* Validate checked identifiers
*/
$checkedIds = post('checked');
if (!$checkedIds || !is_array($checkedIds) || !count($checkedIds)) {
Flash::error(Lang::get(
(!empty($listConfig->noRecordsDeletedMessage))
? $listConfig->noRecordsDeletedMessage
: 'backend::lang.list.delete_selected_empty'
));
return $this->controller->listRefresh();
}
/*
* Create the model
*/
$class = $listConfig->modelClass;
$model = new $class;
$model = $this->controller->listExtendModel($model, $definition);
/*
* Create the query
*/
$query = $model->newQuery();
$this->controller->listExtendQueryBefore($query, $definition);
$query->whereIn($model->getKeyName(), $checkedIds);
$this->controller->listExtendQuery($query, $definition);
/*
* Delete records
*/
$records = $query->get();
if ($records->count()) {
foreach ($records as $record) {
$record->delete();
}
Flash::success(Lang::get(
(!empty($listConfig->deleteMessage))
? $listConfig->deleteMessage
: 'backend::lang.list.delete_selected_success'
));
}
else {
Flash::error(Lang::get(
(!empty($listConfig->noRecordsDeletedMessage))
? $listConfig->noRecordsDeletedMessage
: 'backend::lang.list.delete_selected_empty'
));
}
return $this->controller->listRefresh($definition);
}
/**
* Renders the widget collection.
* @param string $definition Optional list definition.
* @return string Rendered HTML for the list.
* @throws \Winter\Storm\Exception\ApplicationException when there are no list widgets set.
*/
public function listRender($definition = null)
{
if (!count($this->listWidgets)) {
throw new ApplicationException(Lang::get('backend::lang.list.behavior_not_ready'));
}
if (!$definition || !isset($this->listDefinitions[$definition])) {
$definition = $this->primaryDefinition;
}
$vars = [
'toolbar' => null,
'filter' => null,
'list' => null,
];
if (isset($this->toolbarWidgets[$definition])) {
$vars['toolbar'] = $this->toolbarWidgets[$definition];
}
if (isset($this->filterWidgets[$definition])) {
$vars['filter'] = $this->filterWidgets[$definition];
}
$vars['list'] = $this->listWidgets[$definition];
return $this->listMakePartial('container', $vars);
}
/**
* Controller accessor for making partials within this behavior.
* @param string $partial
* @param array $params
* @return string Partial contents
*/
public function listMakePartial($partial, $params = [])
{
$contents = $this->controller->makePartial('list_'.$partial, $params + $this->vars, false);
if (!$contents) {
$contents = $this->makePartial($partial, $params);
}
return $contents;
}
/**
* Refreshes the list container only, useful for returning in custom AJAX requests.
*
* @return array The list element selector as the key, and the list contents are the value.
*/
public function listRefresh(?string $definition = null)
{
if (!count($this->listWidgets)) {
$this->makeLists();
}
if (!$definition || !isset($this->listDefinitions[$definition])) {
$definition = $this->primaryDefinition;
}
return $this->listWidgets[$definition]->onRefresh();
}
/**
* Returns the widget used by this behavior.
* @return \Backend\Classes\WidgetBase
*/
public function listGetWidget(?string $definition = null)
{
if (!$definition) {
$definition = $this->primaryDefinition;
}
return array_get($this->listWidgets, $definition);
}
/**
* Returns the configuration used by this behavior.
* @return stdClass
*/
public function listGetConfig(?string $definition = null)
{
if (!$definition) {
$definition = $this->primaryDefinition;
}
if (
!($config = array_get($this->listDefinitions, $definition))
|| !is_object($config)
) {
$config = $this->listDefinitions[$definition] = $this->makeConfig($this->listDefinitions[$definition], $this->requiredConfig);
}
return $config;
}
//
// Overrides
//
/**
* Called before the list columns are defined.
* @param \Backend\Widgets\Lists $host The hosting list widget
* @return void
*/
public function listExtendColumnsBefore($host)
{
}
/**
* Called after the list columns are defined.
* @param \Backend\Widgets\Lists $host The hosting list widget
* @return void
*/
public function listExtendColumns($host)
{
}
/**
* Called after the filter scopes are defined.
* @param \Backend\Widgets\Filter $host The hosting filter widget
* @return void
*/
public function listFilterExtendScopes($host)
{
}
/**
* Controller override: Extend supplied model
* @param \Winter\Storm\Database\Model $model
* @param string|null $definition
* @return \Winter\Storm\Database\Model
*/
public function listExtendModel($model, $definition = null)
{
return $model;
}
/**
* Controller override: Extend the query used for populating the list
* before the default query is processed.
* @param \Winter\Storm\Database\Builder $query
* @param string|null $definition
*/
public function listExtendQueryBefore($query, $definition = null)
{
}
/**
* Controller override: Extend the query used for populating the list
* after the default query is processed.
* @param \Winter\Storm\Database\Builder $query
* @param string|null $definition
*/
public function listExtendQuery($query, $definition = null)
{
}
/**
* Controller override: Extend the records used for populating the list
* after the query is processed.
* @param \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Collection $records
* @param string|null $definition
*/
public function listExtendRecords($records, $definition = null)
{
}
/**
* Controller override: Extend the query used for populating the filter
* options before the default query is processed.
* @param \Winter\Storm\Database\Builder $query
* @param array $scope
*/
public function listFilterExtendQuery($query, $scope)
{
}
/**
* Returns a CSS class name for a list row (<tr class="...">).
* @param \Winter\Storm\Database\Model $record The populated model used for the column
* @param string|null $definition List definition (optional)
* @return string|void CSS class name
*/
public function listInjectRowClass($record, $definition = null)
{
}
/**
* Replace a table column value (<td>...</td>)
* @param \Winter\Storm\Database\Model $record The populated model used for the column
* @param string $columnName The column name to override
* @param string|null $definition List definition (optional)
* @return string|void HTML view
*/
public function listOverrideColumnValue($record, $columnName, $definition = null)
{
}
/**
* Replace the entire table header contents (<th>...</th>) with custom HTML
* @param string $columnName The column name to override
* @param string|null $definition List definition (optional)
* @return string|void HTML view
*/
public function listOverrideHeaderValue($columnName, $definition = null)
{
}
/**
* Static helper for extending list columns.
* @param callable $callback
* @return void
*/
public static function extendListColumns($callback)
{
$calledClass = self::getCalledExtensionClass();
Event::listen('backend.list.extendColumns', function (\Backend\Widgets\Lists $widget) use ($calledClass, $callback) {
if (!is_a($widget->getController(), $calledClass)) {
return;
}
call_user_func_array($callback, [$widget, $widget->model]);
});
}
/**
* Static helper for extending filter scopes.
* @param callable $callback
* @return void
*/
public static function extendListFilterScopes($callback)
{
$calledClass = self::getCalledExtensionClass();
Event::listen('backend.filter.extendScopes', function (\Backend\Widgets\Filter $widget) use ($calledClass, $callback) {
if (!is_a($widget->getController(), $calledClass)) {
return;
}
call_user_func_array($callback, [$widget]);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,315 @@
<?php namespace Backend\Behaviors;
use Lang;
use Backend;
use ApplicationException;
use Backend\Classes\ControllerBehavior;
/**
* Used for reordering and sorting records.
*
* This behavior is implemented in the controller like so:
*
* public $implement = [
* \Backend\Behaviors\ReorderController::class,
* ];
*
* public $reorderConfig = 'config_reorder.yaml';
*
* The `$reorderConfig` property makes reference to the configuration
* values as either a YAML file, located in the controller view directory,
* or directly as a PHP array.
*
* @package winter\wn-backend-module
* @author Alexey Bobkov, Samuel Georges
*/
class ReorderController extends ControllerBehavior
{
/**
* @var array Configuration values that must exist when applying the primary config file.
*/
protected $requiredConfig = ['modelClass'];
/**
* @var array Visible actions in context of the controller
*/
protected $actions = ['reorder'];
/**
* @var Model Import model
*/
public $model;
/**
* @var string Model attribute to use for the display name
*/
public $nameFrom = 'name';
/**
* @var bool Display parent/child relationships in the list.
*/
protected $showTree = false;
/**
* @var string Reordering mode:
* - simple: Winter\Storm\Database\Traits\Sortable
* - nested: Winter\Storm\Database\Traits\NestedTree
*/
protected $sortMode;
/**
* @var Backend\Classes\WidgetBase Reference to the widget used for the toolbar.
*/
protected $toolbarWidget;
/**
* @var mixed Configuration for this behaviour
*/
public $reorderConfig = 'config_reorder.yaml';
/**
* Behavior constructor
* @param Backend\Classes\Controller $controller
*/
public function __construct($controller)
{
parent::__construct($controller);
/*
* Build configuration
*/
$this->config = $this->makeConfig($controller->reorderConfig ?: $this->reorderConfig, $this->requiredConfig);
/*
* Widgets
*/
if ($this->toolbarWidget = $this->makeToolbarWidget()) {
$this->toolbarWidget->bindToController();
}
/*
* Populate from config
*/
$this->nameFrom = $this->getConfig('nameFrom', $this->nameFrom);
}
//
// Controller actions
//
public function reorder()
{
$this->addJs('js/winter.reorder.js', 'core');
$this->controller->pageTitle = $this->controller->pageTitle
?: Lang::get($this->getConfig('title', 'backend::lang.reorder.default_title'));
$this->validateModel();
$this->prepareVars();
}
//
// AJAX
//
public function onReorder()
{
$model = $this->validateModel();
/*
* Simple
*/
if ($this->sortMode == 'simple') {
if (
(!$ids = post('record_ids')) ||
(!$orders = post('sort_orders'))
) {
return;
}
$model->setSortableOrder($ids, $orders);
}
/*
* Nested set
*/
elseif ($this->sortMode == 'nested') {
$sourceNode = $model->find(post('sourceNode'));
$targetNode = post('targetNode') ? $model->find(post('targetNode')) : null;
if ($sourceNode == $targetNode) {
return;
}
switch (post('position')) {
case 'before':
$sourceNode->moveBefore($targetNode);
break;
case 'after':
$sourceNode->moveAfter($targetNode);
break;
case 'child':
$sourceNode->makeChildOf($targetNode);
break;
default:
$sourceNode->makeRoot();
break;
}
}
}
//
// Reordering
//
/**
* Prepares common form data
*/
protected function prepareVars()
{
$this->vars['reorderRecords'] = $this->getRecords();
$this->vars['reorderModel'] = $this->model;
$this->vars['reorderSortMode'] = $this->sortMode;
$this->vars['reorderShowTree'] = $this->showTree;
$this->vars['reorderToolbarWidget'] = $this->toolbarWidget;
}
public function reorderRender()
{
return $this->reorderMakePartial('container');
}
public function reorderGetModel()
{
if ($this->model !== null) {
return $this->model;
}
$modelClass = $this->getConfig('modelClass');
if (!$modelClass) {
throw new ApplicationException('Please specify the modelClass property for reordering');
}
return $this->model = new $modelClass;
}
/**
* Returns the display name for a record.
* @return string
*/
public function reorderGetRecordName($record)
{
return $record->{$this->nameFrom};
}
/**
* Validate the supplied form model.
* @return void
*/
protected function validateModel()
{
$model = $this->controller->reorderGetModel();
$modelTraits = class_uses($model);
if (
isset($modelTraits[\Winter\Storm\Database\Traits\Sortable::class]) ||
$model->isClassExtendedWith(\Winter\Storm\Database\Behaviors\Sortable::class) ||
isset($modelTraits[\October\Rain\Database\Traits\Sortable::class]) ||
$model->isClassExtendedWith(\October\Rain\Database\Behaviors\Sortable::class)
) {
$this->sortMode = 'simple';
}
elseif (
isset($modelTraits[\Winter\Storm\Database\Traits\NestedTree::class]) ||
isset($modelTraits[\October\Rain\Database\Traits\NestedTree::class])
) {
$this->sortMode = 'nested';
$this->showTree = true;
}
else {
throw new ApplicationException('The model must implement the Sortable trait/behavior or the NestedTree trait.');
}
return $model;
}
/**
* Returns all the records from the supplied model.
* @return Collection
*/
protected function getRecords()
{
$records = null;
$model = $this->controller->reorderGetModel();
$query = $model->newQuery();
$this->controller->reorderExtendQuery($query);
if ($this->sortMode == 'simple') {
$records = $query
->orderBy($model->getSortOrderColumn())
->get()
;
}
elseif ($this->sortMode == 'nested') {
$records = $query->getNested();
}
return $records;
}
/**
* Extend the query used for finding reorder records. Extra conditions
* can be applied to the query, for example, $query->withTrashed();
* @param Winter\Storm\Database\Builder $query
* @return void
*/
public function reorderExtendQuery($query)
{
}
//
// Widgets
//
protected function makeToolbarWidget()
{
if ($toolbarConfig = $this->getConfig('toolbar')) {
$toolbarConfig = $this->makeConfig($toolbarConfig);
$toolbarWidget = $this->makeWidget('Backend\Widgets\Toolbar', $toolbarConfig);
}
else {
$toolbarWidget = null;
}
return $toolbarWidget;
}
//
// Helpers
//
/**
* Controller accessor for making partials within this behavior.
* @param string $partial
* @param array $params
* @return string Partial contents
*/
public function reorderMakePartial($partial, $params = [])
{
$contents = $this->controller->makePartial(
'reorder_' . $partial,
$params + $this->vars,
false
);
if (!$contents) {
$contents = $this->makePartial($partial, $params);
}
return $contents;
}
}

View File

@@ -0,0 +1,117 @@
<?php namespace Backend\Behaviors;
use System\Behaviors\SettingsModel;
use Backend\Models\UserPreference;
use Winter\Storm\Database\Model;
/**
* User Preferences model extension, identical to System\Behaviors\SettingsModel
* except values are set against the logged in user's preferences via Backend\Models\UserPreference
*
* Add this the model class definition:
*
* public $implement = ['Backend.Behaviors.UserPreferencesModel'];
* public $settingsCode = 'author.plugin::code';
* public $settingsFields = 'fields.yaml';
*
*/
class UserPreferencesModel extends SettingsModel
{
/**
* @var array Internal cache of model objects.
*/
private static $instances = [];
/**
* Constructor
*/
public function __construct($model)
{
parent::__construct($model);
$this->model->setTable('backend_user_preferences');
}
/**
* Create an instance of the settings model, intended as a static method
*/
public function instance()
{
if (isset(self::$instances[$this->recordCode])) {
return self::$instances[$this->recordCode];
}
if (!$item = $this->getSettingsRecord()) {
$this->model->initSettingsData();
$item = $this->model;
}
return self::$instances[$this->recordCode] = $item;
}
/**
* Checks if the model has been set up previously, intended as a static method
*/
public function isConfigured(): bool
{
return $this->getSettingsRecord() !== null;
}
/**
* Returns the raw Model record that stores the settings.
*/
public function getSettingsRecord(): ?Model
{
$item = UserPreference::forUser();
$record = $item
->scopeApplyKeyAndUser($this->model, $this->recordCode, $item->userContext)
->remember(1440, $this->getCacheKey())
->first();
return $record ?: null;
}
/**
* Before the model is saved, ensure the record code is set
* and the jsonable field values
*/
public function beforeModelSave()
{
$preferences = UserPreference::forUser();
list($namespace, $group, $item) = $preferences->parseKey($this->recordCode);
$this->model->item = $item;
$this->model->group = $group;
$this->model->namespace = $namespace;
$this->model->user_id = $preferences->userContext->id;
if ($this->fieldValues) {
$this->model->value = $this->fieldValues;
}
}
/**
* Checks if a key is legitimate or should be added to
* the field value collection
*/
protected function isKeyAllowed($key)
{
/*
* Let the core columns through
*/
if ($key == 'namespace' || $key == 'group') {
return true;
}
return parent::isKeyAllowed($key);
}
/**
* Returns a cache key for this record.
*/
protected function getCacheKey()
{
$item = UserPreference::forUser();
$userId = $item->userContext ? $item->userContext->id : 0;
return $this->recordCode.'-userpreference-'.$userId;
}
}

View File

@@ -0,0 +1,28 @@
<?php
/** @var array{previous: mixed, next: mixed, current: int, total: int} $navigation */
$previousUrl = $navigation['previous'] !== null
? $this->actionUrl($navigationContext, $navigation['previous'])
: null;
$nextUrl = $navigation['next'] !== null
? $this->actionUrl($navigationContext, $navigation['next'])
: null;
$chevronUp = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
$chevronDown = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
?>
<div class="form-record-nav" role="navigation" aria-label="<?= e(trans('backend::lang.form.record_navigation')) ?>">
<span class="form-record-nav-position">
<?= e($navigation['current']) ?>&nbsp;/&nbsp;<?= e($navigation['total']) ?>
</span>
<span class="form-record-nav-group">
<?php if ($previousUrl): ?>
<a href="<?= e($previousUrl) ?>" class="form-record-nav-btn" title="<?= e(trans('backend::lang.form.previous_record')) ?>" data-hotkey="ctrl+up, cmd+up"><?= $chevronUp ?></a>
<?php else: ?>
<span class="form-record-nav-btn is-disabled" aria-disabled="true"><?= $chevronUp ?></span>
<?php endif ?>
<?php if ($nextUrl): ?>
<a href="<?= e($nextUrl) ?>" class="form-record-nav-btn" title="<?= e(trans('backend::lang.form.next_record')) ?>" data-hotkey="ctrl+down, cmd+down"><?= $chevronDown ?></a>
<?php else: ?>
<span class="form-record-nav-btn is-disabled" aria-disabled="true"><?= $chevronDown ?></span>
<?php endif ?>
</span>
</div>

View File

@@ -0,0 +1,81 @@
<?php
$modelName = $formConfig->name ?? '';
?>
<?php if ($formContext === 'create'): ?>
<div class="loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-request-data="new:1"
data-browser-validate
data-hotkey="ctrl+shift+s, cmd+shift+s"
data-load-indicator="<?= e(trans('backend::lang.form.creating_name', ['name' => trans($modelName)])); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-primary wn-icon-plus">
<?= e(trans('backend::lang.form.create_and_new')); ?>
</button>
<button
type="button"
data-request="onSave"
data-browser-validate
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.creating_name', ['name' => trans($modelName)])); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-primary wn-icon-save">
<?= e(trans('backend::lang.form.create')); ?>
</button>
<button
type="button"
data-request="onSave"
data-browser-validate
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.creating_name', ['name' => trans($modelName)])); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-default wn-icon-check">
<?= e(trans('backend::lang.form.create_and_close')); ?>
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url($formConfig->defaultRedirect) ?>"><?= e(trans('backend::lang.form.cancel')); ?></a>
</span>
</div>
<?php elseif ($formContext === 'update'): ?>
<div class="loading-indicator-container">
<button
type="button"
data-request="onSave"
data-browser-validate
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
data-request-data="redirect:0"
data-hotkey="ctrl+s, cmd+s"
class="btn btn-primary wn-icon-save"
>
<?= e(trans('backend::lang.form.save')); ?>
</button>
<button
type="button"
data-request="onSave"
data-browser-validate
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.saving')); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-default wn-icon-check"
>
<?= e(trans('backend::lang.form.save_and_close')); ?>
</button>
<button
type="button"
data-request="onDelete"
data-load-indicator="<?= e(trans('backend::lang.form.deleting_name', ['name' => trans($modelName)])); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
data-request-confirm="<?= e(trans('backend::lang.form.confirm_delete')); ?>"
class="wn-icon-trash-o btn-icon danger pull-right"
>
</button>
<span class="btn-text">
<?= e(trans('backend::lang.form.or')) ?> <a href="<?= Backend::url($formConfig->defaultRedirect) ?>"><?= e(trans('backend::lang.form.cancel')); ?></a>
</span>
</div>
<?php endif ?>

View File

@@ -0,0 +1,20 @@
<?php
// Decide which layout we should be rendering
$layout = $this->formLayout ?? $formConfig->formLayout ?? null;
if (!in_array($layout, ['standard', 'sidebar', 'fancy'])) {
$layout = 'standard';
}
// If required, set the appropriate body classes
$this->bodyClass = match ($layout) {
'fancy' => 'fancy-layout compact-container breadcrumb-flush breadcrumb-fancy',
'sidebar' => 'compact-container',
default => '',
};
// Define layout mode view path for inclusion
$this->appendViewPath(sprintf('%s/create/%s', __DIR__, $layout));
// Render the form layout
echo $this->makePartial(sprintf('create/%s.php', $layout));

View File

@@ -0,0 +1,21 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<div class="layout fancy-layout">
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<?= Form::close() ?>
</div>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1,41 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?php Block::put('form-contents') ?>
<div class="layout">
<div class="layout-row">
<?= $this->formRenderOutsideFields() ?>
<?= $this->formRenderPrimaryTabs() ?>
</div>
<div class="form-buttons p-t">
<?= $this->formMakePartial('toolbar') ?>
</div>
</div>
<?php Block::endPut() ?>
<?php Block::put('form-sidebar') ?>
<div class="hide-tabs"><?= $this->formRenderSecondaryTabs() ?></div>
<?php Block::endPut() ?>
<?php Block::put('body') ?>
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout stretch',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<?= $this->makeLayout('form-with-sidebar') ?>
<?= Form::close() ?>
<?php Block::endPut() ?>
<?php else: ?>
<div class="control-breadcrumb">
<?= Block::placeholder('breadcrumb') ?>
</div>
<div class="padded-container">
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
</div>
<?php endif ?>

View File

@@ -0,0 +1,23 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons p-t">
<?= $this->formMakePartial('toolbar') ?>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1,36 @@
<div class="form-buttons loading-indicator-container">
<button
type="submit"
data-request="onSave"
data-request-data="new:1"
data-browser-validate
data-hotkey="ctrl+shift+s, cmd+shift+s"
data-load-indicator="<?= e(trans('backend::lang.form.creating')); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-primary wn-icon-plus">
<?= e(trans('backend::lang.form.create_and_new')); ?>
</button>
<button
type="button"
data-request="onSave"
data-browser-validate
data-hotkey="ctrl+s, cmd+s"
data-load-indicator="<?= e(trans('backend::lang.form.creating')); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-primary wn-icon-save">
<?= e(trans('backend::lang.form.create')); ?>
</button>
<button
type="button"
data-request="onSave"
data-browser-validate
data-request-data="close:1"
data-hotkey="ctrl+enter, cmd+enter"
data-load-indicator="<?= e(trans('backend::lang.form.creating')); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
class="btn btn-default wn-icon-check">
<?= e(trans('backend::lang.form.create_and_close')); ?>
</button>
<a class="btn btn-default wn-icon-ban" href="<?= $this->actionUrl('') ?>"><?= e(trans('backend::lang.form.cancel')); ?></a>
</div>

View File

@@ -0,0 +1,21 @@
<?php
// Decide which layout we should be rendering
$layout = $this->formLayout ?? $formConfig->formLayout ?? null;
if (!in_array($layout, ['standard', 'sidebar', 'fancy'])) {
$layout = 'standard';
}
// If required, set the appropriate body classes
$this->bodyClass = match ($layout) {
'fancy' => 'fancy-layout compact-container breadcrumb-flush breadcrumb-fancy',
'sidebar' => 'compact-container',
default => '',
};
// Define layout mode view path for inclusion
$this->appendViewPath(sprintf('%s/preview/%s', __DIR__, $layout));
// Render the form layout
echo $this->makePartial(sprintf('preview/%s.php', $layout));

View File

@@ -0,0 +1,18 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout',
]) ?>
<div class="layout-row form-preview">
<?= $this->formRenderPreview() ?>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1,36 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?php Block::put('form-contents') ?>
<div class="layout">
<div class="layout-row">
<?= $this->formRenderOutsideFields() ?>
<?= $this->formRenderPrimaryTabs() ?>
</div>
</div>
<?php Block::endPut() ?>
<?php Block::put('form-sidebar') ?>
<div class="hide-tabs"><?= $this->formRenderSecondaryTabs() ?></div>
<?php Block::endPut() ?>
<?php Block::put('body') ?>
<?= Form::open([
'id' => $this->formGetId(),
'class'=>'layout stretch',
]) ?>
<?= $this->makeLayout('form-with-sidebar') ?>
<?= Form::close() ?>
<?php Block::endPut() ?>
<?php else: ?>
<div class="control-breadcrumb">
<?= Block::placeholder('breadcrumb') ?>
</div>
<div class="padded-container">
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
</div>
<?php endif ?>

View File

@@ -0,0 +1,13 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<div class="form-preview">
<?= $this->formRenderPreview() ?>
</div>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1 @@
<div class="form-buttons loading-indicator-container"></div>

View File

@@ -0,0 +1,20 @@
<?php
// Decide which layout we should be rendering
$layout = $this->formLayout ?? $formConfig->formLayout ?? null;
if (!in_array($layout, ['standard', 'sidebar', 'fancy'])) {
$layout = 'standard';
}
// If required, set the appropriate body classes
$this->bodyClass .= match ($layout) {
'fancy' => ' fancy-layout compact-container breadcrumb-flush breadcrumb-fancy',
'sidebar' => ' compact-container',
default => '',
};
// Define layout mode view path for inclusion
$this->appendViewPath(sprintf('%s/update/%s', __DIR__, $layout));
// Render the form layout
echo $this->makePartial(sprintf('update/%s.php', $layout));

View File

@@ -0,0 +1,22 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<div class="layout fancy-layout">
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<?= Form::close() ?>
</div>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1,42 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?php Block::put('form-contents') ?>
<div class="layout">
<div class="layout-row">
<?= $this->formRenderOutsideFields() ?>
<?= $this->formRenderPrimaryTabs() ?>
</div>
<div class="form-buttons p-t">
<?= $this->formMakePartial('toolbar') ?>
</div>
</div>
<?php Block::endPut() ?>
<?php Block::put('form-sidebar') ?>
<div class="hide-tabs"><?= $this->formRenderSecondaryTabs() ?></div>
<?php Block::endPut() ?>
<?php Block::put('body') ?>
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout stretch',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<?= $this->makeLayout('form-with-sidebar') ?>
<?= Form::close() ?>
<?php Block::endPut() ?>
<?php else: ?>
<div class="control-breadcrumb">
<?= Block::placeholder('breadcrumb') ?>
</div>
<div class="padded-container">
<p class="flash-message static error"><?= e(trans($this->fatalError)) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
</div>
<?php endif ?>

View File

@@ -0,0 +1,24 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?= $this->formRenderRecordNavigation() ?>
<?php Block::endPut() ?>
<?php if (!$this->fatalError): ?>
<?= Form::open([
'id' => $this->formGetId(),
'class' => 'layout',
'data-change-monitor' => 'true',
'data-window-close-confirm' => 'true',
]) ?>
<div class="layout-row">
<?= $this->formRender() ?>
</div>
<div class="form-buttons p-t">
<?= $this->formMakePartial('toolbar') ?>
</div>
<?= Form::close() ?>
<?php else: ?>
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
<p><a href="<?= isset($formConfig) ? Backend::url($formConfig->defaultRedirect) : 'javascript:history.back()' ?>" class="btn btn-default"><?= e(trans('backend::lang.form.return_to_list')); ?></a></p>
<?php endif ?>

View File

@@ -0,0 +1,53 @@
<div class="form-buttons loading-indicator-container">
<!-- Save -->
<a
href="javascript:;"
class="btn btn-primary wn-icon-save save"
data-request="onSave"
data-browser-validate
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
data-request-data="redirect:0"
data-hotkey="ctrl+s, cmd+s"
>
<?= e(trans('backend::lang.form.save')) ?>
</a>
<!-- Save and Close -->
<a
href="javascript:;"
class="btn btn-primary wn-icon-check save"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
data-request="onSave"
data-browser-validate
data-load-indicator="<?= e(trans('backend::lang.form.saving')) ?>"
>
<?= e(trans('backend::lang.form.save_and_close')) ?>
</a>
<?php if ($formModel->url): ?>
<!-- Preview -->
<a
href="<?= e($formModel->url) ?>"
target="_blank"
class="btn btn-primary wn-icon-crosshairs"
data-control="preview-button"
>
<?= e(trans('backend::lang.form.preview')) ?>
</a>
<?php endif ?>
<a class="btn btn-default wn-icon-ban" href="<?= $this->actionUrl('') ?>"><?= e(trans('backend::lang.form.cancel')); ?></a>
<!-- Delete -->
<button
type="button"
class="btn btn-default empty wn-icon-trash-o"
data-request="onDelete"
title="<?= e(trans('backend::lang.form.delete')); ?>"
data-load-indicator="<?= e(trans('backend::lang.form.deleting')); ?>"
data-request-before-update="$el.trigger('unchange.oc.changeMonitor')"
data-request-confirm="<?= e(trans('backend::lang.form.confirm_delete')); ?>"
data-control="delete-button"
></button>
</div>

View File

@@ -0,0 +1,77 @@
<?php namespace Backend\Behaviors\ImportExportController;
use php_user_filter;
stream_filter_register(TranscodeFilter::FILTER_NAME . "*", TranscodeFilter::class);
/**
* Transcode stream filter.
*
* Convert CSV source files from one encoding to another.
*/
class TranscodeFilter extends php_user_filter
{
const FILTER_NAME = 'winter.csv.transcode.';
protected $encodingFrom = 'auto';
protected $encodingTo;
public function filter($in, $out, &$consumed, $closing)
{
while ($resource = stream_bucket_make_writeable($in)) {
if (in_array($this->encodingFrom, mb_list_encodings())) {
$resource->data = @mb_convert_encoding(
$resource->data,
$this->encodingTo,
$this->encodingFrom
);
} else {
$resource->data = @iconv(
$this->encodingFrom,
$this->encodingTo,
$resource->data
);
}
$consumed += $resource->datalen;
stream_bucket_append($out, $resource);
}
return PSFS_PASS_ON;
}
public function onCreate()
{
if (strpos($this->filtername, self::FILTER_NAME) !== 0) {
return false;
}
$params = substr($this->filtername, strlen(self::FILTER_NAME));
if (!preg_match('/^([-\w]+)(:([-\w]+))?$/', $params, $matches)) {
return false;
}
if (isset($matches[1])) {
$this->encodingFrom = $matches[1];
}
$this->encodingTo = mb_internal_encoding();
if (isset($matches[3])) {
$this->encodingTo = $matches[3];
}
$this->params['locale'] = setlocale(LC_CTYPE, '0');
if (stripos($this->params['locale'], 'UTF-8') === false) {
setlocale(LC_CTYPE, 'en_US.UTF-8');
}
return true;
}
public function onClose()
{
setlocale(LC_CTYPE, $this->params['locale']);
}
}

View File

@@ -0,0 +1,7 @@
.export-behavior .export-columns {
max-height: 400px;
background: #f0f0f0;
padding: 20px;
padding-bottom: 0;
overflow: auto;
}

View File

@@ -0,0 +1,164 @@
.import-behavior ul {
margin: 0;
padding: 0;
list-style: none;
}
.import-behavior ul li {
font-size: 13px;
}
.import-behavior ul li.placeholder {
display: block;
position: relative;
}
.import-behavior ul li.dragged {
position: absolute;
z-index: 2000;
-webkit-box-shadow: 0 3px 6px rgba(0, 0, 0, 0.075);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.075);
}
.import-behavior .import-file-columns,
.import-behavior .import-db-columns {
height: 400px;
background: #f0f0f0;
padding: 5px;
overflow: auto;
}
.import-behavior .import-file-columns .upload-prompt {
display: block;
text-align: center;
position: absolute;
top: 50%;
left: 0;
right: 0;
margin-top: -10px;
}
.import-behavior .import-column-bindings > ul > li,
.import-behavior .import-db-columns > ul > li {
cursor: pointer;
}
.import-behavior ul li.dragged,
.import-behavior .import-file-columns > ul > li,
.import-behavior .import-db-columns > ul > li {
background: #ffffff;
border: 1px solid #cccccc;
border-radius: 3px;
margin-bottom: 5px;
}
.import-behavior ul li.dragged div.import-column-name > span,
.import-behavior .import-file-columns > ul > li div.import-column-name > span,
.import-behavior .import-db-columns > ul > li div.import-column-name > span,
.import-behavior ul li.dragged > span,
.import-behavior .import-file-columns > ul > li > span,
.import-behavior .import-db-columns > ul > li > span {
display: block;
padding: 8px;
padding-left: 12px;
}
.import-behavior .import-db-columns > ul > li .column-icon {
color: #ccc;
position: relative;
left: -3px;
}
.import-behavior .import-db-columns > ul > li:hover .column-icon {
color: #4da7e8;
}
.import-behavior .import-db-columns > ul > li.is-required .column-icon {
color: #ab2a1c;
}
.import-behavior .import-file-columns > ul > li:before,
.import-behavior .import-file-columns > ul > li:after {
content: " ";
display: table;
}
.import-behavior .import-file-columns > ul > li:after {
clear: both;
}
.import-behavior .import-file-columns > ul > li.is-ignored {
display: none;
}
.import-behavior .import-file-columns > ul > li .column-success-icon {
display: none;
position: relative;
left: -2px;
width: 15px;
}
.import-behavior .import-file-columns > ul > li.is-matched .column-success-icon {
display: inline-block;
}
.import-behavior .import-file-columns > ul > li.is-matched .column-ignore-button {
display: none !important;
}
.import-behavior .import-file-columns > ul div.import-column-name {
float: left;
width: 45%;
}
.import-behavior .import-file-columns > ul div.import-column-name > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-behavior .import-file-columns > ul div.import-column-name a.column-label {
color: #333;
}
.import-behavior .import-file-columns > ul div.import-column-name a.column-ignore-button {
color: #fff;
background: #ccc;
font-size: 10px;
border-radius: 15px;
display: inline-block;
text-decoration: none;
width: 15px;
height: 15px;
text-align: center;
position: relative;
top: -1px;
left: -3px;
}
.import-behavior .import-file-columns > ul div.import-column-name a.column-ignore-button:hover {
background: #ab2a1c;
}
.import-behavior .import-file-columns > ul .import-column-bindings > ul {
float: right;
width: 55%;
}
.import-behavior .import-column-bindings > ul {
background: #dadedf;
position: relative;
min-height: 34px;
}
.import-behavior .import-column-bindings > ul:after {
content: '';
display: block;
width: 0;
height: 0;
border-top: 17px solid transparent;
border-bottom: 17px solid transparent;
border-left: 18px solid #ffffff;
position: absolute;
top: 0;
left: 0;
}
.import-behavior .import-column-bindings > ul:before {
position: absolute;
padding: 8px;
padding-left: 28px;
content: attr(data-empty-text);
color: rgba(0, 0, 0, 0.5);
}
.import-behavior .import-column-bindings > ul > li .column-icon {
color: #595959;
float: right;
margin: 3px;
}
.import-behavior .import-column-bindings > ul > li:hover .column-icon {
color: #333333;
}
.import-behavior .import-column-bindings > ul > li:not(.dragged) {
background: #e8eaeb;
position: relative;
}
.import-behavior .import-column-bindings > ul > li:not(.dragged) > span {
display: block;
padding: 8px;
padding-left: 28px;
}

View File

@@ -0,0 +1,22 @@
/*
* Scripts for the Export controller behavior.
*/
+function ($) { "use strict";
var ExportBehavior = function() {
this.processExport = function () {
var $form = $('#exportColumns').closest('form')
$form.request('onExport', {
success: function(data) {
$('#exportContainer').html(data.result)
$(document).trigger('render')
}
})
}
}
$.wn.exportBehavior = new ExportBehavior;
}(window.jQuery);

View File

@@ -0,0 +1,149 @@
/*
* Scripts for the Import controller behavior.
*/
+function ($) { "use strict";
var ImportBehavior = function() {
this.processImport = function () {
var $form = $('#importFileColumns').closest('form')
$form.request('onImport', {
success: function(data) {
$('#importContainer').html(data.result)
$(document).trigger('render')
}
})
}
this.loadFileColumnSample = function(el) {
var $el = $(el),
$column = $el.closest('[data-column-id]'),
columnId = $column.data('column-id')
$el.popup({
handler: 'onImportLoadColumnSampleForm',
extraData: {
file_column_id: columnId
}
})
}
this.bindColumnSorting = function() {
/*
* Unbind existing
*/
$('#importDbColumns > ul, .import-column-bindings > ul').each(function(){
var $this = $(this)
if ($this.data('oc.sortable')) {
$this.sortable('destroyGroup')
$this.sortable('destroy')
}
})
var sortableOptions = {
group: 'import-fields',
usePlaceholderClone: true,
nested: false,
onDrop: $.proxy(this.onDropColumn, this)
}
$('#importDbColumns > ul, .import-column-bindings > ul').sortable(sortableOptions)
}
this.onDropColumn = function ($dbItem, container, _super, event) {
var
$fileColumns = $('#importFileColumns'),
$fileItem,
isMatch = $.contains($fileColumns.get(0), $dbItem.get(0)),
matchColumnId
/*
* Has a previous match?
*/
matchColumnId = $dbItem.data('column-matched-id')
if (matchColumnId !== null) {
$fileItem = $('[data-column-id='+matchColumnId+']', $fileColumns)
this.toggleMatchState($fileItem)
}
/*
* Is a new match?
*/
if (isMatch) {
$fileItem = $dbItem.closest('[data-column-id]'),
this.matchColumn($dbItem, $fileItem)
}
else {
this.unmatchColumn($dbItem)
}
if (_super) {
_super($dbItem, container)
}
}
this.toggleMatchState = function ($container) {
var hasItems = !!$('.import-column-bindings li', $container).length
$container.toggleClass('is-matched', hasItems)
}
this.ignoreFileColumn = function(el) {
var $el = $(el),
$column = $el.closest('[data-column-id]')
$column.addClass('is-ignored')
$('#showIgnoredColumnsButton').removeClass('disabled')
}
this.showIgnoredColumns = function() {
$('#importFileColumns li.is-ignored').removeClass('is-ignored')
$('#showIgnoredColumnsButton').addClass('disabled')
}
this.autoMatchColumns = function() {
var self = this,
fileColumns = {},
$this,
name
$('#importFileColumns li').each(function() {
$this = $(this)
name = $.trim($('.column-label', $this).text())
fileColumns[name] = $this
})
$('#importDbColumns li').each(function() {
$this = $(this)
name = $.trim($('> span', $this).text())
if (fileColumns[name]) {
$this.appendTo($('.import-column-bindings > ul', fileColumns[name]))
self.matchColumn($this, fileColumns[name])
}
})
}
this.matchColumn = function($dbItem, $fileItem) {
var matchColumnId = $fileItem.data('column-id'),
dbColumnName = $dbItem.data('column-name'),
$dbItemMatchInput = $('[data-column-match-input]', $dbItem)
this.toggleMatchState($fileItem)
$dbItem.data('column-matched-id', matchColumnId)
$dbItemMatchInput.attr('name', 'column_match['+matchColumnId+'][]')
$dbItemMatchInput.attr('value', dbColumnName)
}
this.unmatchColumn = function($dbItem) {
var $dbItemMatchInput = $('[data-column-match-input]', $dbItem)
$dbItem.removeData('column-matched-id')
$dbItemMatchInput.attr('name', '');
$dbItemMatchInput.attr('value', '');
}
}
$.wn.importBehavior = new ImportBehavior;
}(window.jQuery);

View File

@@ -0,0 +1,13 @@
@import "../../../../assets/less/core/boot.less";
.export-behavior {
.export-columns {
max-height: 400px;
background: #f0f0f0;
padding: @padding-standard;
padding-bottom: 0;
overflow: auto;
}
}

View File

@@ -0,0 +1,201 @@
@import "../../../../assets/less/core/boot.less";
@color-import-column-bg: #fff;
@color-import-column-border: #ccc;
@color-import-bound-bg: #e8eaeb;
@import-column-padding: 8px;
@import-column-font-size: 13px;
.import-behavior {
ul {
margin: 0;
padding: 0;
list-style: none;
li {
font-size: @import-column-font-size;
}
li.placeholder {
display: block;
position: relative;
}
li.dragged {
position: absolute;
z-index: 2000;
.box-shadow(0 3px 6px rgba(0,0,0,.075));
}
}
.import-file-columns,
.import-db-columns {
height: 400px;
background: #f0f0f0;
padding: 5px;
overflow: auto;
}
.import-file-columns {
.upload-prompt {
display: block;
text-align: center;
position: absolute;
top: 50%;
left: 0;
right: 0;
margin-top: -10px;
}
}
.import-column-bindings > ul > li,
.import-db-columns > ul > li {
cursor: pointer;
}
ul li.dragged,
.import-file-columns > ul > li,
.import-db-columns > ul > li {
background: @color-import-column-bg;
border: 1px solid @color-import-column-border;
border-radius: 3px;
margin-bottom: 5px;
div.import-column-name > span,
> span {
display: block;
padding: @import-column-padding;
padding-left: (@import-column-padding * 1.5);
}
}
.import-db-columns > ul {
> li {
.column-icon {
color: #ccc;
position: relative;
left: -3px;
}
&:hover .column-icon {
color: #4da7e8;
}
&.is-required {
.column-icon {
color: @brand-danger;
}
}
}
}
.import-file-columns > ul {
> li {
.clearfix;
&.is-ignored {
display: none;
}
.column-success-icon {
display: none;
position: relative;
left: -2px;
width: 15px;
}
&.is-matched {
.column-success-icon {
display: inline-block;
}
.column-ignore-button {
display: none !important;
}
}
}
div.import-column-name {
float: left;
width: 45%;
> span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
a.column-label {
color: #333;
}
a.column-ignore-button {
color: #fff;
background: #ccc;
font-size: 10px;
border-radius: 15px;
display: inline-block;
text-decoration: none;
width: 15px;
height: 15px;
text-align: center;
position: relative;
top: -1px;
left: -3px;
&:hover {
background: @brand-danger;
}
}
}
.import-column-bindings > ul {
float: right;
width: 55%;
}
}
.import-column-bindings > ul {
background: darken(@color-import-bound-bg, 5%);
position: relative;
min-height: (@import-column-padding * 2) + 18px;
&:after {
.triangle(right, 18px, (@import-column-padding * 2) + 18px, @color-import-column-bg);
position: absolute;
top: 0;
left: 0;
}
&:before {
position: absolute;
padding: @import-column-padding;
padding-left: 28px;
content: attr(data-empty-text);
color: rgba(0,0,0,.5);
}
> li {
.column-icon {
color: #595959;
float: right;
margin: 3px;
}
&:hover {
.column-icon {
color: #333333;
}
}
}
> li:not(.dragged) {
background: @color-import-bound-bg;
position: relative;
> span {
display: block;
padding: @import-column-padding;
padding-left: 28px;
}
}
}
}

View File

@@ -0,0 +1,29 @@
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans('backend::lang.import_export.column_preview')) ?></h4>
</div>
<div class="modal-body">
<p>
<?= e(trans('backend::lang.import_export.column')) ?>:
<strong><?= $columnName ?></strong>
</p>
<div class="list-preview">
<div class="control-simplelist is-divided is-scrollable size-small" data-control="simplelist">
<ul>
<?php foreach ($columnData as $sample): ?>
<li class="wn-icon-file-o">
<?= e($sample) ?>
</li>
<?php endforeach ?>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.form.close')) ?>
</button>
</div>

View File

@@ -0,0 +1,9 @@
<div class="export-behavior">
<?= $exportFormatFormWidget->render() ?>
<?php if ($exportOptionsFormWidget): ?>
<?= $exportOptionsFormWidget->render() ?>
<?php endif ?>
</div>

View File

@@ -0,0 +1,9 @@
<div class="import-behavior">
<?= $importUploadFormWidget->render() ?>
<?php if ($importOptionsFormWidget): ?>
<?= $importOptionsFormWidget->render() ?>
<?php endif ?>
</div>

View File

@@ -0,0 +1,27 @@
<div class="export-columns" id="exportColumns">
<div class="control-simplelist with-checkboxes is-sortable" data-control="simplelist">
<ul>
<?php foreach ($exportColumns as $key => $column): ?>
<li>
<div class="checkbox custom-checkbox">
<input
type="hidden"
name="export_columns[]"
value="<?= $key ?>" />
<input
id="<?= $this->getId('exportCheckbox-'.$key) ?>"
name="visible_columns[<?= $key ?>]"
value="1"
checked="checked"
type="checkbox" />
<label
class="choice"
for="<?= $this->getId('exportCheckbox-'.$key) ?>">
<?= e(trans($column)) ?>
</label>
</div>
</li>
<?php endforeach ?>
</ul>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<div id="exportFormPopup">
<?php if (!$this->fatalError): ?>
<?= Form::open(['id' => 'exportForm']) ?>
<div class="modal-header">
<h4 class="modal-title"><?= e(trans('backend::lang.import_export.export_progress')) ?></h4>
</div>
<div id="exportContainer">
<div class="modal-body">
<div class="loading-indicator-container">
<p>&nbsp;</p>
<div class="loading-indicator transparent">
<div><?= e(trans('backend::lang.import_export.processing')) ?></div>
<span></span>
</div>
</div>
<p>&nbsp;</p>
</div>
</div>
<?= Form::close() ?>
<script>
$('#exportFormPopup').on('popupComplete', function() {
$.wn.exportBehavior.processExport()
})
</script>
<?php else: ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans('backend::lang.import_export.export_error')) ?></h4>
</div>
<div class="modal-body">
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.form.close')) ?>
</button>
</div>
<?php endif ?>
</div>

View File

@@ -0,0 +1,34 @@
<?php if (!$this->fatalError): ?>
<div class="modal-body">
<p>
<?= e(trans('backend::lang.import_export.processing_successful_line1')) ?>
<?= e(trans('backend::lang.import_export.processing_successful_line2')) ?>
</p>
</div>
<div class="modal-footer">
<a
href="<?= $returnUrl ?>"
class="btn btn-success"
data-dismiss="popup">
<?= e(trans('backend::lang.form.complete')) ?>
</a>
</div>
<script> window.location = '<?= $fileUrl ?>' </script>
<?php else: ?>
<div class="modal-body">
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.form.close')) ?>
</button>
</div>
<?php endif ?>

View File

@@ -0,0 +1,23 @@
<div class="import-db-columns" id="importDbColumns">
<ul>
<?php foreach ($importDbColumns as $column => $label): ?>
<?php
$isRequired = $this->importIsColumnRequired($column);
$iconName = $isRequired ? 'icon-asterisk' : 'icon-link';
?>
<li
class="<?= $isRequired ? 'is-required' : '' ?>"
data-column-name="<?= e($column) ?>">
<span>
<i class="column-icon <?= $iconName ?>"></i>
<?= e(trans($label)) ?>
</span>
<input type="hidden" data-column-match-input />
</li>
<?php endforeach ?>
</ul>
</div>
<script>
$.wn.importBehavior.bindColumnSorting()
</script>

View File

@@ -0,0 +1,44 @@
<div class="import-file-columns" id="importFileColumns">
<?php if ($importFileColumns): ?>
<ul>
<?php foreach ($importFileColumns as $index => $column): ?>
<li data-column-id="<?= $index ?>">
<div class="import-column-name">
<span>
<i class="column-success-icon text-success icon-check"></i>
<a
href="javascript:;"
class="column-ignore-button"
data-toggle="tooltip"
data-delay="300"
data-placement="right"
title="<?= e(trans('backend::lang.import_export.ignore_this_column')) ?>"
onclick="$.wn.importBehavior.ignoreFileColumn(this)"
>
<i class="icon-close"></i>
</a>
<a
href="javascript:;"
class="column-label"
onclick="$.wn.importBehavior.loadFileColumnSample(this)"
>
<?= e($column) ?>
</a>
</span>
</div>
<div class="import-column-bindings">
<ul data-empty-text="<?= e(trans('backend::lang.import_export.drop_column_here')) ?>"></ul>
</div>
</li>
<?php endforeach ?>
</ul>
<?php else: ?>
<p class="upload-prompt">
<?= e(trans('backend::lang.import_export.upload_valid_csv')) ?>
</p>
<?php endif ?>
</div>
<script>
$.wn.importBehavior.bindColumnSorting()
</script>

View File

@@ -0,0 +1,50 @@
<div id="importFormPopup">
<?php if (!$this->fatalError): ?>
<?= Form::open(['id' => 'importForm']) ?>
<div class="modal-header">
<h4 class="modal-title"><?= e(trans('backend::lang.import_export.import_progress')) ?></h4>
</div>
<div id="importContainer">
<div class="modal-body">
<div class="loading-indicator-container">
<p>&nbsp;</p>
<div class="loading-indicator transparent">
<div><?= e(trans('backend::lang.import_export.processing')) ?></div>
<span></span>
</div>
</div>
<p>&nbsp;</p>
</div>
</div>
<?= Form::close() ?>
<script>
$('#importFormPopup').on('popupComplete', function() {
$.wn.importBehavior.processImport()
})
</script>
<?php else: ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans('backend::lang.import_export.import_error')) ?></h4>
</div>
<div class="modal-body">
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.form.close')) ?>
</button>
</div>
<?php endif ?>
</div>

View File

@@ -0,0 +1,107 @@
<?php if (!$this->fatalError): ?>
<div class="modal-body">
<div class="scoreboard">
<div data-control="toolbar">
<div class="scoreboard-item title-value">
<h4><?= e(trans('backend::lang.import_export.created')) ?></h4>
<p><?= $importResults->created ?></p>
</div>
<div class="scoreboard-item title-value">
<h4><?= e(trans('backend::lang.import_export.updated')) ?></h4>
<p><?= $importResults->updated ?></p>
</div>
<?php if ($importResults->skippedCount): ?>
<div class="scoreboard-item title-value">
<h4><?= e(trans('backend::lang.import_export.skipped')) ?></h4>
<p><?= $importResults->skippedCount ?></p>
</div>
<?php endif ?>
<?php if ($importResults->warningCount): ?>
<div class="scoreboard-item title-value">
<h4><?= e(trans('backend::lang.import_export.warnings')) ?></h4>
<p><?= $importResults->warningCount ?></p>
</div>
<?php endif ?>
<div class="scoreboard-item title-value">
<h4><?= e(trans('backend::lang.import_export.errors')) ?></h4>
<p><?= $importResults->errorCount ?></p>
</div>
</div>
</div>
<?php if ($importResults->hasMessages): ?>
<?php
$tabs = [
'skipped' => trans('backend::lang.import_export.skipped_rows'),
'warnings' => trans('backend::lang.import_export.warnings'),
'errors' => trans('backend::lang.import_export.errors'),
];
if (!$importResults->skippedCount) {
unset($tabs['skipped']);
}
if (!$importResults->warningCount) {
unset($tabs['warnings']);
}
if (!$importResults->errorCount) {
unset($tabs['errors']);
}
?>
<div class="control-tabs secondary-tabs" data-control="tab">
<ul class="nav nav-tabs">
<?php $count = 0; foreach ($tabs as $code => $tab): ?>
<li class="<?= $count++ == 0 ? 'active' : '' ?>">
<a href="#importTab<?= $code ?>">
<?= $tab ?>
</a>
</li>
<?php endforeach ?>
</ul>
<div class="tab-content">
<?php $count = 0; foreach ($tabs as $code => $tab): ?>
<div class="tab-pane <?= $count++ == 0 ? 'active' : '' ?>">
<div class="list-preview">
<div class="control-simplelist is-divided is-scrollable size-small" data-control="simplelist">
<ul>
<?php foreach ($importResults->{$code} as $row => $message): ?>
<li>
<strong><?= e(trans('backend::lang.import_export.row', ['row' => $row + $sourceIndexOffset])) ?></strong>
- <?= e($message) ?>
</li>
<?php endforeach ?>
</ul>
</div>
</div>
</div>
<?php endforeach ?>
</div>
</div>
<?php endif ?>
</div>
<div class="modal-footer">
<a
href="<?= $returnUrl ?>"
class="btn btn-success"
data-dismiss="popup">
<?= e(trans('backend::lang.form.complete')) ?>
</a>
</div>
<?php else: ?>
<div class="modal-body">
<p class="flash-message static error"><?= e($this->fatalError) ?></p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.form.close')) ?>
</button>
</div>
<?php endif ?>

View File

@@ -0,0 +1,16 @@
<div data-control="toolbar">
<a
href="javascript:;"
id="showIgnoredColumnsButton"
class="btn btn-sm btn-secondary wn-icon-eye disabled"
onclick="$.wn.importBehavior.showIgnoredColumns()">
<?= e(trans('backend::lang.import_export.show_ignored_columns')) ?>
</a>
<a
href="javascript:;"
id="autoMatchColumnsButton"
class="btn btn-sm btn-secondary wn-icon-bullseye"
onclick="$.wn.importBehavior.autoMatchColumns()">
<?= e(trans('backend::lang.import_export.auto_match_columns')) ?>
</a>
</div>

View File

@@ -0,0 +1,58 @@
# ===================================
# Field Definitions
# ===================================
fields:
step1_section:
label: backend::lang.import_export.export_output_format
type: section
format_preset:
label: backend::lang.import_export.file_format
type: dropdown
default: standard
options:
standard: backend::lang.import_export.standard_format
custom: backend::lang.import_export.custom_format
span: left
format_delimiter:
label: backend::lang.import_export.delimiter_char
default: ','
span: left
trigger:
action: show
condition: value[custom]
field: format_preset
format_enclosure:
label: backend::lang.import_export.enclosure_char
span: auto
default: '"'
trigger:
action: show
condition: value[custom]
field: format_preset
format_escape:
label: backend::lang.import_export.escape_char
span: auto
default: '\'
trigger:
action: show
condition: value[custom]
field: format_preset
step2_section:
label: backend::lang.import_export.select_columns
type: section
export_columns:
label: backend::lang.import_export.columns
type: partial
path: ~/modules/backend/behaviors/importexportcontroller/partials/_export_columns.php
span: left
step3_section:
label: backend::lang.import_export.set_export_options
type: section

View File

@@ -0,0 +1,95 @@
# ===================================
# Field Definitions
# ===================================
fields:
step1_section:
label: backend::lang.import_export.upload_csv_file
type: section
import_file:
label: backend::lang.import_export.import_file
type: fileupload
mode: file
span: left
fileTypes: csv
useCaption: false
format_preset:
label: backend::lang.import_export.file_format
type: dropdown
default: standard
options:
standard: backend::lang.import_export.standard_format
custom: backend::lang.import_export.custom_format
span: right
format_delimiter:
label: backend::lang.import_export.delimiter_char
default: ','
span: left
trigger:
action: show
condition: value[custom]
field: format_preset
format_enclosure:
label: backend::lang.import_export.enclosure_char
span: auto
default: '"'
trigger:
action: show
condition: value[custom]
field: format_preset
format_escape:
label: backend::lang.import_export.escape_char
span: auto
default: '\'
trigger:
action: show
condition: value[custom]
field: format_preset
format_encoding:
label: backend::lang.import_export.encoding_format
span: auto
default: UTF-8
type: dropdown
trigger:
action: show
condition: value[custom]
field: format_preset
first_row_titles:
label: backend::lang.import_export.first_row_contains_titles
comment: backend::lang.import_export.first_row_contains_titles_desc
type: checkbox
default: true
span: left
step2_section:
label: backend::lang.import_export.match_columns
type: section
column_control_panel:
type: partial
path: ~/modules/backend/behaviors/importexportcontroller/partials/_import_toolbar.php
import_file_columns:
label: backend::lang.import_export.file_columns
type: partial
path: ~/modules/backend/behaviors/importexportcontroller/partials/_import_file_columns.php
dependsOn: [import_file, first_row_titles, format_delimiter, format_enclosure, format_escape, format_encoding]
span: left
import_db_columns:
label: backend::lang.import_export.database_fields
type: partial
path: ~/modules/backend/behaviors/importexportcontroller/partials/_import_db_columns.php
dependsOn: [import_file, first_row_titles, format_delimiter, format_enclosure, format_escape, format_encoding]
span: right
step3_section:
label: backend::lang.import_export.set_import_options
type: section

View File

@@ -0,0 +1,24 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->exportRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-control="popup"
data-handler="onExportLoadForm"
data-keyboard="false"
class="btn btn-primary">
<?= e(trans('backend::lang.import_export.export')) ?>
</button>
</div>
</div>
<?= Form::close() ?>

View File

@@ -0,0 +1,24 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<?= Form::open(['class' => 'layout']) ?>
<div class="layout-row">
<?= $this->importRender() ?>
</div>
<div class="form-buttons">
<div class="loading-indicator-container">
<button
type="submit"
data-control="popup"
data-handler="onImportLoadForm"
data-keyboard="false"
class="btn btn-primary">
<?= e(trans('backend::lang.import_export.import')) ?>
</button>
</div>
</div>
<?= Form::close() ?>

View File

@@ -0,0 +1,9 @@
<?php if ($toolbar): ?>
<?= $toolbar->render() ?>
<?php endif ?>
<?php if ($filter): ?>
<?= $filter->render() ?>
<?php endif ?>
<?= $list->render() ?>

View File

@@ -0,0 +1,59 @@
<?php
$listController = $this->getClassExtension(\Backend\Behaviors\ListController::class);
$listConfig = $listController->getConfig();
?>
<div data-control="toolbar">
<?php if ($this->isClassExtendedWith(\Backend\Behaviors\FormController::class)): ?>
<a
href="<?= $this->actionUrl('create') ?>"
class="btn btn-primary wn-icon-plus">
<?= e(trans('backend::lang.form.create_title', ['name' => trans(\Winter\Storm\Support\Str::before($listConfig->title, '_plural'))])); ?>
</a>
<?php endif ?>
<?php if (isset($listConfig->showCheckboxes) && $listConfig->showCheckboxes != false): ?>
<button
class="btn btn-danger wn-icon-trash-o"
disabled="disabled"
onclick="$(this).data('request-data', { checked: $('.control-list').listWidget('getChecked') })"
data-request="onDelete"
data-request-confirm="<?= e(trans('backend::lang.list.delete_selected_confirm')); ?>"
data-trigger-action="enable"
data-trigger=".control-list input[type=checkbox]"
data-trigger-condition="checked"
data-request-success="$(this).prop('disabled', 'disabled')"
data-stripe-load-indicator
>
<?= e(trans('backend::lang.list.delete_selected')); ?>
</button>
<?php endif ?>
<?php if ($this->isClassExtendedWith(\Backend\Behaviors\ReorderController::class)): ?>
<a
href="<?= $this->actionUrl('reorder') ?>"
class="btn btn-default wn-icon-arrows-up-down">
<?= e(trans('backend::lang.reorder.reorder_title', ['name' => trans($listConfig->title)])); ?>
</a>
<?php endif ?>
<?php if ($this->isClassExtendedWith(\Backend\Behaviors\ImportExportController::class)): ?>
<div class="btn-group">
<?php $importExport = $this->asExtension(\Backend\Behaviors\ImportExportController::class); ?>
<?php if ($importExport->userHasAccess('export')): ?>
<a
href="<?= $this->actionUrl('export') ?>"
class="btn btn-default wn-icon-download">
<?= e(trans('backend::lang.import_export.export')) ?>
</a>
<?php endif ?>
<?php if ($importExport->userHasAccess('import')): ?>
<a
href="<?= $this->actionUrl('import') ?>"
class="btn btn-default wn-icon-upload">
<?= e(trans('backend::lang.import_export.import')) ?>
</a>
<?php endif ?>
</div>
<?php endif ?>
</div>

View File

@@ -0,0 +1 @@
<?= $this->listRender() ?>

View File

@@ -0,0 +1,39 @@
.relation-behavior {
margin-bottom: 20px;
}
.relation-behavior .control-list {
border: 1px solid #eeeeee;
}
.relation-behavior .control-list thead > tr > th {
border-top: none !important;
border-color: #eeeeee;
}
.relation-behavior .control-toolbar {
padding: 0 20px 20px 20px;
}
.relation-behavior .control-toolbar .toolbar-item .form-control.search {
padding-top: 5px;
padding-bottom: 5px;
}
.relation-behavior .control-toolbar .loading-indicator-container.size-input-text {
min-height: 0;
}
.relation-behavior .control-toolbar .loading-indicator-container.size-input-text .loading-indicator > span {
top: 4px;
}
.relation-behavior .list-header {
padding: 0;
}
.relation-behavior .control-list:last-child > table {
margin-bottom: 0;
}
.relation-flush .control-list {
border-top: none;
}
.relation-inset {
margin-left: -20px;
margin-right: -20px;
}
.form-group > .relation-behavior .control-toolbar {
padding: 0 0 10px 0;
}

View File

@@ -0,0 +1,100 @@
/*
* Scripts for the Relation controller behavior.
*/
+function ($) { "use strict";
var RelationBehavior = function() {
this.toggleListCheckbox = function(el) {
$(el).closest('.control-list').listWidget('toggleChecked', [el])
}
this.clickViewListRecord = function(recordId, relationId, sessionKey) {
var newPopup = $('<a />'),
$container = $('#'+relationId),
requestData = paramToObj('data-request-data', $container.data('request-data'))
newPopup.popup({
handler: 'onRelationClickViewList',
size: 'huge',
extraData: $.extend({}, requestData, {
'manage_id': recordId,
'_session_key': sessionKey
})
})
}
this.clickManageListRecord = function(recordId, relationId, sessionKey) {
var oldPopup = $('#relationManagePopup'),
$container = $('#'+relationId),
requestData = paramToObj('data-request-data', $container.data('request-data'))
$.request('onRelationClickManageList', {
data: $.extend({}, requestData, {
'record_id': recordId,
'_session_key': sessionKey
})
})
oldPopup.popup('hide')
}
this.clickManagePivotListRecord = function(foreignId, relationId, sessionKey) {
var oldPopup = $('#relationManagePivotPopup'),
newPopup = $('<a />'),
$container = $('#'+relationId),
requestData = paramToObj('data-request-data', $container.data('request-data'))
if (oldPopup.length) {
oldPopup.popup('hide')
}
newPopup.popup({
handler: 'onRelationClickManageListPivot',
size: 'huge',
extraData: $.extend({}, requestData, {
'foreign_id': foreignId,
'_session_key': sessionKey
})
})
}
/*
* This function is called every time a record is created, added, removed
* or deleted using the relation widget. It triggers the change.oc.formwidget
* event to notify other elements on the page about the changed form state.
*/
this.changed = function(relationId, event) {
$('[data-field-name="' + relationId + '"]').trigger('change.oc.formwidget', {event: event});
}
/*
* This function transfers the supplied variables as hidden form inputs,
* to any popup that is spawned within the supplied container. The spawned
* popup must contain a form element.
*/
this.bindToPopups = function(container, vars) {
$(container).on('show.oc.popup', function(event, $trigger, $modal){
var $form = $('form', $modal)
$.each(vars, function(name, value){
$form.prepend($('<input />').attr({ type: 'hidden', name: name, value: value }))
})
})
}
function paramToObj(name, value) {
if (value === undefined) value = ''
if (typeof value == 'object') return value
try {
return ocJSON("{" + value + "}")
}
catch (e) {
throw new Error('Error parsing the '+name+' attribute value. '+e)
}
}
}
$.wn.relationBehavior = new RelationBehavior;
}(window.jQuery);

View File

@@ -0,0 +1,60 @@
@import "../../../../assets/less/core/boot.less";
@color-relation-border: #eeeeee;
.relation-behavior {
margin-bottom: 20px;
.control-list {
border: 1px solid @color-relation-border;
thead > tr > th {
border-top: none !important;
border-color: @color-relation-border;
}
}
.control-toolbar {
padding: 0 20px 20px 20px;
.toolbar-item .form-control.search {
padding-top: 5px;
padding-bottom: 5px;
}
.loading-indicator-container.size-input-text {
min-height: 0;
.loading-indicator > span {
top: 4px;
}
}
}
.list-header {
padding: 0;
}
.control-list:last-child > table {
margin-bottom: 0;
}
}
// Relation manager to sit flush to the element above
.relation-flush {
.control-list {
border-top: none;
}
}
// Relation manager to sit inset the standard padding (20px)
.relation-inset {
margin-left: -20px;
margin-right: -20px;
}
// Displayed in a form field
.form-group > .relation-behavior {
.control-toolbar {
padding: 0 0 10px 0;
}
}

View File

@@ -0,0 +1,8 @@
<a
data-control="popup"
data-size="huge"
data-handler="onRelationButtonAdd"
href="javascript:;"
class="btn btn-sm btn-secondary wn-icon-plus">
<?= e(trans($text, ['name' => trans($relationLabel)])) ?>
</a>

View File

@@ -0,0 +1,8 @@
<a
data-control="popup"
data-size="huge"
data-handler="onRelationButtonCreate"
href="javascript:;"
class="btn btn-sm btn-secondary wn-icon-file">
<?= e(trans($text, ['name' => trans($relationLabel)])) ?>
</a>

View File

@@ -0,0 +1,26 @@
<?php if ($relationViewMode == 'single'): ?>
<button
class="btn btn-sm btn-secondary wn-icon-trash-o"
data-request="onRelationButtonDelete"
data-request-confirm="<?= e(trans('backend::lang.relation.delete_confirm')) ?>"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'deleted')"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</button>
<?php else: ?>
<button
class="btn btn-sm btn-secondary wn-icon-trash-o"
onclick="$(this).data('request-data', {
checked: $('#<?= $this->relationGetId('view') ?> .control-list').listWidget('getChecked')
})"
disabled="disabled"
data-request="onRelationButtonDelete"
data-request-confirm="<?= e(trans('backend::lang.relation.delete_confirm')) ?>"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'deleted')"
data-trigger-action="enable"
data-trigger="#<?= $this->relationGetId('view') ?> .control-list input[type=checkbox]"
data-trigger-condition="checked"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</button>
<?php endif ?>

View File

@@ -0,0 +1,8 @@
<a
data-control="popup"
data-size="huge"
data-handler="onRelationButtonLink"
href="javascript:;"
class="btn btn-sm btn-secondary wn-icon-link">
<?= e(trans($text, ['name' => trans($relationLabel)])) ?>
</a>

View File

@@ -0,0 +1,6 @@
<button
class="btn btn-sm btn-secondary wn-icon-arrows-rotate"
data-request="onRelationButtonRefresh"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</button>

View File

@@ -0,0 +1,24 @@
<?php if ($relationViewMode == 'single'): ?>
<button
class="btn btn-sm btn-secondary wn-icon-minus"
data-request="onRelationButtonRemove"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'removed')"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</button>
<?php else: ?>
<button
class="btn btn-sm btn-secondary wn-icon-minus"
onclick="$(this).data('request-data', {
checked: $('#<?= $this->relationGetId('view') ?> .control-list').listWidget('getChecked')
})"
disabled="disabled"
data-request="onRelationButtonRemove"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'removed')"
data-trigger-action="enable"
data-trigger="#<?= $this->relationGetId('view') ?> .control-list input[type=checkbox]"
data-trigger-condition="checked"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</button>
<?php endif ?>

View File

@@ -0,0 +1,9 @@
<a
href="javascript:;"
class="btn btn-sm btn-secondary wn-icon-unlink"
data-request="onRelationButtonUnlink"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'removed')"
data-request-confirm="<?= e(trans('backend::lang.relation.unlink_confirm')) ?>"
data-stripe-load-indicator>
<?= e(trans($text)) ?>
</a>

View File

@@ -0,0 +1,9 @@
<a
data-control="popup"
data-size="huge"
data-handler="onRelationButtonUpdate"
data-request-data="manage_id: '<?= $relationManageId ?>'"
href="javascript:;"
class="btn btn-sm btn-secondary wn-icon-pencil">
<?= e(trans($text, ['name' => trans($relationLabel)])) ?>
</a>

View File

@@ -0,0 +1,18 @@
<div
id="<?= $this->relationGetId() ?>"
data-request-data="_relation_field: '<?= $relationField ?>', _relation_extra_config: '<?= e(base64_encode(json_encode($relationExtraConfig))) ?>'"
class="relation-behavior relation-view-<?= $relationViewMode ?>">
<?php if ($toolbar = $this->relationRenderToolbar()): ?>
<!-- Relation Toolbar -->
<div id="<?= $this->relationGetId('toolbar') ?>" class="relation-toolbar">
<?= $toolbar ?>
</div>
<?php endif ?>
<!-- Relation View -->
<div id="<?= $this->relationGetId('view') ?>" class="relation-manager">
<?= $this->relationRenderView() ?>
</div>
</div>

View File

@@ -0,0 +1,71 @@
<div id="<?= $relationManageWidget->getId('managePopup') ?>">
<?php if ($relationManageId): ?>
<?= Form::ajax('onRelationManageUpdate', [
'data-popup-load-indicator' => true,
'sessionKey' => $newSessionKey,
'data-request-success' => "$.wn.relationBehavior.changed('" . e($relationField) . "', 'updated')",
]) ?>
<!-- Passable fields -->
<input type="hidden" name="manage_id" value="<?= $relationManageId ?>" />
<input type="hidden" name="_relation_field" value="<?= $relationField ?>" />
<input type="hidden" name="_relation_mode" value="form" />
<input type="hidden" name="_relation_session_key" value="<?= $relationSessionKey ?>" />
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title">
<?= e(trans($relationManageTitle, ['name' => trans($relationLabel)])) ?>
</h4>
</div>
<div class="modal-body">
<?= $relationManageWidget->render(['preview' => $this->readOnly]) ?>
</div>
<div class="modal-footer">
<?= $this->relationMakePartial('manage_form_footer_update') ?>
</div>
<?= Form::close() ?>
<?php else: ?>
<?= Form::ajax('onRelationManageCreate', [
'data-popup-load-indicator' => true,
'data-request-success' => "$.wn.relationBehavior.changed('" . e($relationField) . "', 'created')",
'sessionKey' => $newSessionKey
]) ?>
<!-- Passable fields -->
<input type="hidden" name="_relation_field" value="<?= $relationField ?>" />
<input type="hidden" name="_relation_mode" value="form" />
<input type="hidden" name="_relation_session_key" value="<?= $relationSessionKey ?>" />
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title">
<?= e(trans($relationManageTitle, ['name' => trans($relationLabel)])) ?>
</h4>
</div>
<div class="modal-body">
<?= $relationManageWidget->render() ?>
</div>
<div class="modal-footer">
<?= $this->relationMakePartial('manage_form_footer_create') ?>
</div>
<?= Form::close() ?>
<?php endif ?>
</div>
<script>
$.wn.relationBehavior.bindToPopups('#<?= $relationManageWidget->getId("managePopup") ?>', {
_relation_field: '<?= $relationField ?>',
_relation_mode: 'form'
})
</script>

View File

@@ -0,0 +1,11 @@
<button
type="submit"
class="btn btn-primary">
<?= e(trans('backend::lang.relation.create')) ?>
</button>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>

View File

@@ -0,0 +1,20 @@
<?php if ($this->readOnly): ?>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.close')) ?>
</button>
<?php else: ?>
<button
type="submit"
class="btn btn-primary">
<?= e(trans('backend::lang.relation.update')) ?>
</button>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>
<?php endif ?>

View File

@@ -0,0 +1,24 @@
<div id="relationManagePopup" data-request-data="_relation_field: '<?= $relationField ?>', _relation_mode: 'list'">
<?= Form::open() ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans($relationManageTitle, [
'name' => trans($relationLabel)
])) ?></h4>
</div>
<div class="list-flush">
<?php if ($relationSearchWidget): ?>
<?= $relationSearchWidget->render() ?>
<?php endif ?>
<?php if ($relationManageFilterWidget): ?>
<?= $relationManageFilterWidget->render() ?>
<?php endif ?>
<?= $relationManageWidget->render() ?>
</div>
<div class="modal-footer">
<?= $this->relationMakePartial('manage_list_footer') ?>
</div>
<?= Form::close() ?>
</div>

View File

@@ -0,0 +1,17 @@
<?php if ($relationManageWidget->showCheckboxes): ?>
<button
type="button"
class="btn btn-primary"
data-request="onRelationManageAdd"
data-dismiss="popup"
data-request-success="$.wn.relationBehavior.changed('<?= e($relationField) ?>', 'added')"
data-stripe-load-indicator>
<?= e(trans('backend::lang.relation.add_selected')) ?>
</button>
<?php endif ?>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>

View File

@@ -0,0 +1,32 @@
<div id="relationManagePivotPopup" data-request-data="_relation_field: '<?= $relationField ?>'">
<?= Form::open() ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans($relationManageTitle, ['name'=>trans($relationLabel)])) ?></h4>
</div>
<?php if (!$relationSearchWidget): ?>
<div class="modal-body">
<p><?= e(trans('backend::lang.relation.help')) ?></p>
</div>
<?php endif ?>
<div class="list-flush">
<?php if ($relationSearchWidget): ?>
<?= $relationSearchWidget->render() ?>
<?php endif ?>
<?php if ($relationManageFilterWidget): ?>
<?= $relationManageFilterWidget->render() ?>
<?php endif ?>
<?= $relationManageWidget->render() ?>
</div>
<div class="modal-footer">
<?= $this->relationMakePartial('manage_pivot_footer') ?>
</div>
<?= Form::close() ?>
</div>
<script>
setTimeout(
function(){ $('#relationManagePivotPopup input.form-control:first').focus() },
310
)
</script>

View File

@@ -0,0 +1,18 @@
<?php if ($relationManageWidget->showCheckboxes): ?>
<button
type="button"
class="btn btn-primary"
data-control="popup"
data-handler="onRelationManageAddPivot"
data-size="huge"
data-dismiss="popup"
data-stripe-load-indicator>
<?= e(trans('backend::lang.relation.add_selected')) ?>
</button>
<?php endif ?>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>

View File

@@ -0,0 +1,53 @@
<?php if ($relationManageId): ?>
<?= Form::ajax('onRelationManagePivotUpdate', [
'data' => ['_relation_field' => $relationField, 'manage_id' => $relationManageId],
'data-request-success' => "$.wn.relationBehavior.changed('" . e($relationField) . "', 'updated')",
'data-popup-load-indicator' => true
]) ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans('backend::lang.relation.related_data', ['name'=>trans($relationLabel)])) ?></h4>
</div>
<div class="modal-body">
<?= $relationPivotWidget->render(['preview' => $this->readOnly]) ?>
</div>
<div class="modal-footer">
<?= $this->relationMakePartial('pivot_form_footer') ?>
</div>
<?= Form::close() ?>
<?php else: ?>
<?= Form::ajax('onRelationManagePivotCreate', [
'data' => ['_relation_field' => $relationField, 'foreign_id' => $foreignId],
'data-request-success' => "$.wn.relationBehavior.changed('" . e($relationField) . "', 'created')",
'data-popup-load-indicator' => true
]) ?>
<div class="modal-header">
<button type="button" class="close" data-dismiss="popup">&times;</button>
<h4 class="modal-title"><?= e(trans('backend::lang.relation.related_data', ['name'=>trans($relationLabel)])) ?></h4>
</div>
<div class="modal-body">
<?= $relationPivotWidget->render() ?>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary">
<?= e(trans('backend::lang.relation.add')) ?>
</button>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>
</div>
<?= Form::close() ?>
<?php endif ?>

View File

@@ -0,0 +1,20 @@
<?php if ($this->readOnly): ?>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.close')) ?>
</button>
<?php else: ?>
<button
type="submit"
class="btn btn-primary">
<?= e(trans('backend::lang.relation.update')) ?>
</button>
<button
type="button"
class="btn btn-default"
data-dismiss="popup">
<?= e(trans('backend::lang.relation.cancel')) ?>
</button>
<?php endif ?>

View File

@@ -0,0 +1,18 @@
<div data-control="toolbar">
<?php foreach ($relationToolbarButtons as $type => $text): ?>
<?php if ($type === 'update'): ?>
<?= $this->relationMakePartial('button_update', [
'relationManageId' => $relationViewModel->getKey(),
'text' => $text
]) ?>
<?php else: ?>
<?= $this->relationMakePartial('button_' . $type, [
'text' => $text
]) ?>
<?php endif ?>
<?php endforeach ?>
</div>

View File

@@ -0,0 +1,5 @@
<?php if ($relationViewFilterWidget): ?>
<?= $relationViewFilterWidget->render() ?>
<?php endif ?>
<?= $relationViewWidget->render() ?>

View File

@@ -0,0 +1,82 @@
/*
* Scripts for the Reorder controller behavior.
*
* The following functions are observed:
* - Simple sorting: Post back the original sort orders and the new ordered identifiers.
* - Nested sorting: Post back source and target nodes IDs and the move positioning.
*/
+function ($) { "use strict";
var ReorderBehavior = function() {
this.sortMode = null
this.simpleSortOrders = []
this.initSorting = function (mode) {
this.sortMode = mode
if (mode == 'simple') {
this.initSortingSimple()
}
$('#reorderTreeList').on('move.oc.treelist', $.proxy(this.processReorder, this))
}
this.processReorder = function(ev, sortData){
var postData
if (this.sortMode == 'simple') {
postData = { sort_orders: this.simpleSortOrders }
}
else if (this.sortMode == 'nested') {
postData = this.getNestedMoveData(sortData)
}
$('#reorderTreeList').request('onReorder', {
data: postData
})
}
this.getNestedMoveData = function (sortData) {
var
$el,
$item = sortData.item,
moveData = {
targetNode: 0,
sourceNode: $item.data('recordId'),
position: 'root'
}
if (($el = $item.next()) && $el.length) {
moveData.position = 'before'
}
else if (($el = $item.prev()) && $el.length) {
moveData.position = 'after'
}
else if (($el = $item.parents('li:first')) && $el.length) {
moveData.position = 'child'
}
if ($el.length) {
moveData.targetNode = $el.data('recordId')
}
return moveData
}
this.initSortingSimple = function () {
var sortOrders = []
$('#reorderTreeList li').each(function(i) {
sortOrders.push(i);
})
this.simpleSortOrders = sortOrders
}
}
$.wn.reorderBehavior = new ReorderBehavior;
}(window.jQuery);

View File

@@ -0,0 +1,29 @@
<?php if ($reorderToolbarWidget): ?>
<!-- Reorder Toolbar -->
<div id="<?= $this->getId('reorderToolbar') ?>" class="reorder-toolbar">
<?= $reorderToolbarWidget->render() ?>
</div>
<?php endif ?>
<!-- Reorder List -->
<?= Form::open() ?>
<div
id="reorderTreeList"
class="control-treelist"
data-control="treelist"
<?= $reorderShowTree ? '' : 'data-nested="0"' ?>
data-handle="<?= $reorderShowTree ? 'a.move' : '> li > .record > a.move' ?>"
data-stripe-load-indicator>
<?php if ($reorderRecords): ?>
<ol id="reorderRecords">
<?= $this->reorderMakePartial('records', ['records' => $reorderRecords]) ?>
</ol>
<?php else: ?>
<p><?= Lang::get('backend::lang.reorder.no_records') ?></p>
<?php endif ?>
</div>
<?= Form::close() ?>
<script>
$.wn.reorderBehavior.initSorting('<?= $reorderSortMode ?>')
</script>

View File

@@ -0,0 +1,23 @@
<?php foreach ($records as $record): ?>
<li data-record-id="<?= $record->getKey() ?>"
<?php if ($reorderSortMode === 'simple'): ?>
data-record-sort-order="<?= $record->{$record->getSortOrderColumn()} ?>"
<?php endif ?>
>
<div class="record">
<a href="javascript:;" class="move"></a>
<span><?= e($this->reorderGetRecordName($record)) ?></span>
<input name="record_ids[]" type="hidden" value="<?= $record->getKey() ?>" />
</div>
<?php if ($reorderShowTree): ?>
<ol>
<?php if ($record->children): ?>
<?= $this->reorderMakePartial('records', ['records' => $record->children]) ?>
<?php endif ?>
</ol>
<?php endif ?>
</li>
<?php endforeach ?>

View File

@@ -0,0 +1,5 @@
<div data-control="toolbar">
<a href="<?= $this->actionUrl('/') ?>" class="btn btn-primary oc-icon-caret-left">
<?= e(trans('backend::lang.form.return_to_list')) ?>
</a>
</div>

View File

@@ -0,0 +1,5 @@
<?php Block::put('breadcrumb') ?>
<?= $this->makeLayoutPartial('breadcrumb') ?>
<?php Block::endPut() ?>
<!-- Reorder Controller Widget -->
<?= $this->reorderRender() ?>