diff --git a/.tx/config b/.tx/config index 568c79f7..f42e3f17 100644 --- a/.tx/config +++ b/.tx/config @@ -35,3 +35,10 @@ minimum_perc = 0 source_file = localization/en/demo_data.php source_lang = en type = PHP_ARRAY + +[grocy.userfield_typesphp] +file_filter = localization//userfield_types.php +minimum_perc = 0 +source_file = localization/en/userfield_types.php +source_lang = en +type = PHP_ARRAY diff --git a/controllers/EquipmentController.php b/controllers/EquipmentController.php index a5804a5a..9f1394cb 100644 --- a/controllers/EquipmentController.php +++ b/controllers/EquipmentController.php @@ -2,13 +2,24 @@ namespace Grocy\Controllers; +use \Grocy\Services\UserfieldsService; class EquipmentController extends BaseController { + public function __construct(\Slim\Container $container) + { + parent::__construct($container); + $this->UserfieldsService = new UserfieldsService(); + } + + protected $UserfieldsService; + public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { return $this->AppContainer->view->render($response, 'equipment', [ - 'equipment' => $this->Database->equipment()->orderBy('name') + 'equipment' => $this->Database->equipment()->orderBy('name'), + 'userfields' => $this->UserfieldsService->GetFields('equipment'), + 'userfieldValues' => $this->UserfieldsService->GetAllValues('equipment') ]); } @@ -17,14 +28,16 @@ class EquipmentController extends BaseController if ($args['equipmentId'] == 'new') { return $this->AppContainer->view->render($response, 'equipmentform', [ - 'mode' => 'create' + 'mode' => 'create', + 'userfields' => $this->UserfieldsService->GetFields('equipment') ]); } else { return $this->AppContainer->view->render($response, 'equipmentform', [ 'equipment' => $this->Database->equipment($args['equipmentId']), - 'mode' => 'edit' + 'mode' => 'edit', + 'userfields' => $this->UserfieldsService->GetFields('equipment') ]); } } diff --git a/controllers/RecipesController.php b/controllers/RecipesController.php index f6c3cb14..d182778f 100644 --- a/controllers/RecipesController.php +++ b/controllers/RecipesController.php @@ -3,6 +3,7 @@ namespace Grocy\Controllers; use \Grocy\Services\RecipesService; +use \Grocy\Services\UserfieldsService; class RecipesController extends BaseController { @@ -10,9 +11,11 @@ class RecipesController extends BaseController { parent::__construct($container); $this->RecipesService = new RecipesService(); + $this->UserfieldsService = new UserfieldsService(); } protected $RecipesService; + protected $UserfieldsService; public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { @@ -57,7 +60,9 @@ class RecipesController extends BaseController 'selectedRecipeSubRecipes' => $selectedRecipeSubRecipes, 'selectedRecipeSubRecipesPositions' => $selectedRecipeSubRecipesPositions, 'includedRecipeIdsAbsolute' => $includedRecipeIdsAbsolute, - 'selectedRecipeTotalCosts' => FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $selectedRecipe->id)->costs + 'selectedRecipeTotalCosts' => FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $selectedRecipe->id)->costs, + 'userfields' => $this->UserfieldsService->GetFields('recipes'), + 'userfieldValues' => $this->UserfieldsService->GetAllValues('recipes') ]); } @@ -83,7 +88,8 @@ class RecipesController extends BaseController 'recipePositionsResolved' => $this->RecipesService->GetRecipesPosResolved(), 'recipesResolved' => $this->RecipesService->GetRecipesResolved(), 'recipes' => $this->Database->recipes()->orderBy('name'), - 'recipeNestings' => $this->Database->recipes_nestings()->where('recipe_id', $recipeId) + 'recipeNestings' => $this->Database->recipes_nestings()->where('recipe_id', $recipeId), + 'userfields' => $this->UserfieldsService->GetFields('recipes') ]); } diff --git a/controllers/TasksController.php b/controllers/TasksController.php index 98f79dc2..d53b0420 100644 --- a/controllers/TasksController.php +++ b/controllers/TasksController.php @@ -36,7 +36,9 @@ class TasksController extends BaseController 'tasks' => $tasks, 'nextXDays' => $nextXDays, 'taskCategories' => $this->Database->task_categories()->orderBy('name'), - 'users' => $this->Database->users() + 'users' => $this->Database->users(), + 'userfields' => $this->UserfieldsService->GetFields('tasks'), + 'userfieldValues' => $this->UserfieldsService->GetAllValues('tasks') ]); } diff --git a/localization/en/strings.php b/localization/en/strings.php index 4e009c4e..c2755c56 100644 --- a/localization/en/strings.php +++ b/localization/en/strings.php @@ -380,5 +380,18 @@ return array( 'Thursday' => 'Thursday', 'Friday' => 'Friday', 'Saturday' => 'Saturday', - 'Sunday' => 'Sunday' + 'Sunday' => 'Sunday', + 'Configure userfields' => 'Configure userfields', + 'Userfields' => 'Userfields', + 'Filter by entity' => 'Filter by entity', + 'Entity' => 'Entity', + 'Caption' => 'Caption', + 'Type' => 'Type', + 'Create userfield' => 'Create userfield', + 'A entity is required' => 'A entity is required', + 'A caption is required' => 'A caption is required', + 'A type is required' => 'A type is required', + 'Show as column in tables' => 'Show as column in tables', + 'This is required and can only contain letters and numbers' => 'This is required and can only contain letters and numbers', + 'Edit userfield' => 'Edit userfield' ); diff --git a/public/viewjs/components/userfieldsform.js b/public/viewjs/components/userfieldsform.js index 1202589c..0b6bec52 100644 --- a/public/viewjs/components/userfieldsform.js +++ b/public/viewjs/components/userfieldsform.js @@ -7,7 +7,7 @@ Grocy.Components.UserfieldsForm.Save = function(success, error) $("#userfields-form .userfield-input").each(function() { var input = $(this); - var fieldName = input.attr("id"); + var fieldName = input.attr("data-userfield-name"); var fieldValue = input.val(); if (input.attr("type") == "checkbox") @@ -49,7 +49,7 @@ Grocy.Components.UserfieldsForm.Load = function() { $.each(result, function(key, value) { - var input = $("#" + key + ".userfield-input"); + var input = $(".userfield-input[data-userfield-name='" + key + "']"); if (input.attr("type") == "checkbox" && value == 1) { diff --git a/public/viewjs/equipmentform.js b/public/viewjs/equipmentform.js index fe6cb18e..b30d853e 100644 --- a/public/viewjs/equipmentform.js +++ b/public/viewjs/equipmentform.js @@ -21,24 +21,28 @@ Grocy.Api.Post('objects/equipment', jsonData, function(result) { - if (jsonData.hasOwnProperty("instruction_manual_file_name") && !Grocy.DeleteInstructionManualOnSave) + Grocy.EditObjectId = result.created_object_id; + Grocy.Components.UserfieldsForm.Save(function() { - Grocy.Api.UploadFile($("#instruction-manual")[0].files[0], 'equipmentmanuals', jsonData.instruction_manual_file_name, - function(result) - { - window.location.href = U('/equipment'); - }, - function(xhr) - { - Grocy.FrontendHelpers.EndUiBusy("equipment-form"); - Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) - } - ); - } - else - { - window.location.href = U('/equipment'); - } + if (jsonData.hasOwnProperty("instruction_manual_file_name") && !Grocy.DeleteInstructionManualOnSave) + { + Grocy.Api.UploadFile($("#instruction-manual")[0].files[0], 'equipmentmanuals', jsonData.instruction_manual_file_name, + function (result) + { + window.location.href = U('/equipment'); + }, + function (xhr) + { + Grocy.FrontendHelpers.EndUiBusy("equipment-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + window.location.href = U('/equipment'); + } + }); }, function(xhr) { @@ -67,24 +71,27 @@ Grocy.Api.Put('objects/equipment/' + Grocy.EditObjectId, jsonData, function(result) { - if (jsonData.hasOwnProperty("instruction_manual_file_name") && !Grocy.DeleteInstructionManualOnSave) + Grocy.Components.UserfieldsForm.Save(function() { - Grocy.Api.UploadFile($("#instruction-manual")[0].files[0], 'equipmentmanuals', jsonData.instruction_manual_file_name, - function(result) - { - window.location.href = U('/equipment');; - }, - function(xhr) - { - Grocy.FrontendHelpers.EndUiBusy("equipment-form"); - Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) - } - ); - } - else - { - window.location.href = U('/equipment');; - } + if (jsonData.hasOwnProperty("instruction_manual_file_name") && !Grocy.DeleteInstructionManualOnSave) + { + Grocy.Api.UploadFile($("#instruction-manual")[0].files[0], 'equipmentmanuals', jsonData.instruction_manual_file_name, + function(result) + { + window.location.href = U('/equipment');; + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("equipment-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + window.location.href = U('/equipment');; + } + }); }, function(xhr) { @@ -133,5 +140,6 @@ $('#description').summernote({ ResizeResponsiveEmbeds(); +Grocy.Components.UserfieldsForm.Load(); $('#name').focus(); Grocy.FrontendHelpers.ValidateForm('equipment-form'); diff --git a/public/viewjs/recipeform.js b/public/viewjs/recipeform.js index f7d8f791..a9aacd30 100644 --- a/public/viewjs/recipeform.js +++ b/public/viewjs/recipeform.js @@ -31,24 +31,27 @@ Grocy.Api.Put('objects/recipes/' + Grocy.EditObjectId, jsonData, function(result) { - if (jsonData.hasOwnProperty("picture_file_name") && !Grocy.DeleteRecipePictureOnSave) + Grocy.Components.UserfieldsForm.Save(function() { - Grocy.Api.UploadFile($("#recipe-picture")[0].files[0], 'recipepictures', jsonData.picture_file_name, - function(result) - { - window.location.href = U('/recipes?recipe=' + Grocy.EditObjectId); - }, - function (xhr) - { - Grocy.FrontendHelpers.EndUiBusy("recipe-form"); - Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) - } - ); - } - else - { - window.location.href = U('/recipes?recipe=' + Grocy.EditObjectId); - } + if (jsonData.hasOwnProperty("picture_file_name") && !Grocy.DeleteRecipePictureOnSave) + { + Grocy.Api.UploadFile($("#recipe-picture")[0].files[0], 'recipepictures', jsonData.picture_file_name, + function (result) + { + window.location.href = U('/recipes?recipe=' + Grocy.EditObjectId); + }, + function (xhr) + { + Grocy.FrontendHelpers.EndUiBusy("recipe-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + window.location.href = U('/recipes?recipe=' + Grocy.EditObjectId); + } + }); }, function(xhr) { @@ -345,3 +348,5 @@ $('#description').summernote({ minHeight: '300px', lang: L('summernote_locale') }); + +Grocy.Components.UserfieldsForm.Load(); diff --git a/public/viewjs/taskform.js b/public/viewjs/taskform.js index cfaa3422..b7f124da 100644 --- a/public/viewjs/taskform.js +++ b/public/viewjs/taskform.js @@ -14,7 +14,11 @@ Grocy.Api.Post('objects/tasks', jsonData, function(result) { - window.location.href = U('/tasks'); + Grocy.EditObjectId = result.created_object_id; + Grocy.Components.UserfieldsForm.Save(function() + { + window.location.href = U('/tasks'); + }); }, function(xhr) { @@ -28,7 +32,10 @@ Grocy.Api.Put('objects/tasks/' + Grocy.EditObjectId, jsonData, function(result) { - window.location.href = U('/tasks'); + Grocy.Components.UserfieldsForm.Save(function() + { + window.location.href = U('/tasks'); + }); }, function(xhr) { @@ -61,6 +68,7 @@ $('#task-form input').keydown(function(event) } }); +Grocy.Components.UserfieldsForm.Load(); $('#name').focus(); Grocy.Components.DateTimePicker.GetInputElement().trigger('input'); Grocy.FrontendHelpers.ValidateForm('task-form'); diff --git a/public/viewjs/userfieldform.js b/public/viewjs/userfieldform.js index f4f308b2..73aa5dbe 100644 --- a/public/viewjs/userfieldform.js +++ b/public/viewjs/userfieldform.js @@ -5,12 +5,18 @@ var jsonData = $('#userfield-form').serializeJSON(); Grocy.FrontendHelpers.BeginUiBusy("userfield-form"); + var redirectUrl = U("/userfields"); + if (typeof GetUriParam("entity") !== "undefined" && !GetUriParam("entity").isEmpty()) + { + redirectUrl = U("/userfields?entity=" + GetUriParam("entity")); + } + if (Grocy.EditMode === 'create') { Grocy.Api.Post('objects/userfields', jsonData, function(result) { - window.location.href = U('/userfields'); + window.location.href = redirectUrl; }, function(xhr) { @@ -24,7 +30,7 @@ Grocy.Api.Put('objects/userfields/' + Grocy.EditObjectId, jsonData, function(result) { - window.location.href = U('/userfields'); + window.location.href = redirectUrl; }, function(xhr) { @@ -64,7 +70,7 @@ $('#userfield-form input').keydown(function(event) $('#entity').focus(); -if (typeof GetUriParam("entity") !== "undefined") +if (typeof GetUriParam("entity") !== "undefined" && !GetUriParam("entity").isEmpty()) { $("#entity").val(GetUriParam("entity")); $("#entity").trigger("change"); diff --git a/public/viewjs/userfields.js b/public/viewjs/userfields.js index 32a80a0b..3b01f204 100644 --- a/public/viewjs/userfields.js +++ b/public/viewjs/userfields.js @@ -41,6 +41,7 @@ $("#entity-filter").on("change", function() } userfieldsTable.column(1).search(value).draw(); + $("#new-userfield-button").attr("href", U("/userfield/new?entity=" + value)); }); $(document).on('click', '.userfield-delete-button', function (e) @@ -79,7 +80,7 @@ $(document).on('click', '.userfield-delete-button', function (e) }); }); -if (typeof GetUriParam("entity") !== "undefined") +if (typeof GetUriParam("entity") !== "undefined" && !GetUriParam("entity").isEmpty()) { $("#entity-filter").val(GetUriParam("entity")); $("#entity-filter").trigger("change"); diff --git a/views/components/datetimepicker.blade.php b/views/components/datetimepicker.blade.php index cbb87f11..24c50b6a 100644 --- a/views/components/datetimepicker.blade.php +++ b/views/components/datetimepicker.blade.php @@ -6,6 +6,13 @@ @php if(!isset($initialValue)) { $initialValue = ''; } @endphp @php if(empty($earlierThanInfoLimit)) { $earlierThanInfoLimit = ''; } @endphp @php if(empty($earlierThanInfoText)) { $earlierThanInfoText = ''; } @endphp +@php if(empty($additionalCssClasses)) { $additionalCssClasses = ''; } @endphp +@php if(empty($additionalGroupCssClasses)) { $additionalGroupCssClasses = ''; } @endphp +@php if(empty($invalidFeedback)) { $invalidFeedback = ''; } @endphp +@php if(!isset($isRequired)) { $isRequired = true; } @endphp +@php if(!isset($noNameAttribute)) { $noNameAttribute = false; } @endphp +@php if(!isset($nextInputSelector)) { $nextInputSelector = false; } @endphp +@php if(empty($additionalAttributes)) { $additionalAttributes = ''; } @endphp
-
- +
- +
diff --git a/views/components/userfields_thead.blade.php b/views/components/userfields_thead.blade.php index cd7022f4..62f0986d 100644 --- a/views/components/userfields_thead.blade.php +++ b/views/components/userfields_thead.blade.php @@ -7,7 +7,7 @@ @foreach($userfields as $userfield) @if($userfield->show_as_column_in_tables == 1) - {{ $userfield->name }} + {{ $userfield->caption }} @endif @endforeach diff --git a/views/components/userfieldsform.blade.php b/views/components/userfieldsform.blade.php index af55b17a..a3766dff 100644 --- a/views/components/userfieldsform.blade.php +++ b/views/components/userfieldsform.blade.php @@ -12,14 +12,65 @@ @if($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_SINGLE_LINE_TEXT)
- +
- @endif - - @if($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_CHECKBOX) + @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_SINGLE_MULTILINE_TEXT) +
+ + +
+ @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_INTEGRAL_NUMBER) + @include('components.numberpicker', array( + 'id' => $userfield->name, + 'label' => $userfield->caption, + 'noNameAttribute' => true, + 'min' => 0, + 'isRequired' => false, + 'additionalCssClasses' => 'userfield-input', + 'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"' + )) + @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_DECIMAL_NUMBER) + @include('components.numberpicker', array( + 'id' => '', + 'label' => $userfield->caption, + 'noNameAttribute' => true, + 'min' => 0, + 'step' => 0.01, + 'isRequired' => false, + 'additionalCssClasses' => 'userfield-input', + 'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"' + )) + @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_DATE) + @include('components.datetimepicker', array( + 'id' => $userfield->name, + 'label' => $userfield->caption, + 'noNameAttribute' => true, + 'format' => 'YYYY-MM-DD', + 'initWithNow' => false, + 'limitEndToNow' => false, + 'limitStartToNow' => false, + 'additionalGroupCssClasses' => 'date-only-datetimepicker', + 'isRequired' => false, + 'additionalCssClasses' => 'userfield-input', + 'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"' + )) + @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_DATETIME) + @include('components.datetimepicker', array( + 'id' => $userfield->name, + 'label' => $userfield->caption, + 'noNameAttribute' => true, + 'format' => 'YYYY-MM-DD HH:mm:ss', + 'initWithNow' => false, + 'limitEndToNow' => false, + 'limitStartToNow' => false, + 'isRequired' => false, + 'additionalCssClasses' => 'userfield-input', + 'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"' + )) + @elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_CHECKBOX)
- +
diff --git a/views/equipment.blade.php b/views/equipment.blade.php index 89472eeb..56c1c123 100644 --- a/views/equipment.blade.php +++ b/views/equipment.blade.php @@ -22,6 +22,11 @@ {{ $L('Name') }} + + @include('components.userfields_thead', array( + 'userfields' => $userfields + )) + @@ -30,6 +35,12 @@ {{ $equipmentItem->name }} + + @include('components.userfields_tbody', array( + 'userfields' => $userfields, + 'userfieldValues' => FindAllObjectsInArrayByPropertyValue($userfieldValues, 'object_id', $equipmentItem->id) + )) + @endforeach diff --git a/views/equipmentform.blade.php b/views/equipmentform.blade.php index e3ba2dfe..255c68ad 100644 --- a/views/equipmentform.blade.php +++ b/views/equipmentform.blade.php @@ -56,6 +56,11 @@
+ @include('components.userfieldsform', array( + 'userfields' => $userfields, + 'entity' => 'equipment' + )) + diff --git a/views/inventory.blade.php b/views/inventory.blade.php index 32fcf12e..bdb82879 100644 --- a/views/inventory.blade.php +++ b/views/inventory.blade.php @@ -38,7 +38,7 @@ 'limitStartToNow' => false, 'invalidFeedback' => $L('A best before date is required'), 'nextInputSelector' => '#best_before_date', - 'additionalCssClasses' => 'date-only-datetimepicker', + 'additionalGroupCssClasses' => 'date-only-datetimepicker', 'shortcutValue' => '2999-12-31', 'shortcutLabel' => 'Never expires', 'earlierThanInfoLimit' => date('Y-m-d'), diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index 7881bcb9..76ee1113 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -226,6 +226,12 @@ @endif +
  • + + + {{ $L('Userfields') }} + +
  • diff --git a/views/recipeform.blade.php b/views/recipeform.blade.php index 4ae23922..20dab4d2 100644 --- a/views/recipeform.blade.php +++ b/views/recipeform.blade.php @@ -82,6 +82,11 @@
    + @include('components.userfieldsform', array( + 'userfields' => $userfields, + 'entity' => 'recipes' + )) + diff --git a/views/recipes.blade.php b/views/recipes.blade.php index 189e967a..209e8548 100644 --- a/views/recipes.blade.php +++ b/views/recipes.blade.php @@ -37,6 +37,11 @@ {{ $L('Servings') }} {{ $L('Requirements fulfilled') }} Hidden status for sorting of "Requirements fulfilled" column + + @include('components.userfields_thead', array( + 'userfields' => $userfields + )) + @@ -55,6 +60,12 @@ {{ FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $recipe->id)->missing_products_count }} + + @include('components.userfields_tbody', array( + 'userfields' => $userfields, + 'userfieldValues' => FindAllObjectsInArrayByPropertyValue($userfieldValues, 'object_id', $recipe->id) + )) + @endforeach diff --git a/views/taskform.blade.php b/views/taskform.blade.php index db30f8c3..1911e365 100644 --- a/views/taskform.blade.php +++ b/views/taskform.blade.php @@ -49,7 +49,7 @@ 'limitStartToNow' => false, 'invalidFeedback' => $L('A due date is required'), 'nextInputSelector' => 'category_id', - 'additionalCssClasses' => 'date-only-datetimepicker', + 'additionalGroupCssClasses' => 'date-only-datetimepicker', 'isRequired' => false )) @@ -76,6 +76,11 @@ 'prefillByUserId' => $initUserId )) + @include('components.userfieldsform', array( + 'userfields' => $userfields, + 'entity' => 'tasks' + )) + diff --git a/views/tasks.blade.php b/views/tasks.blade.php index baf6b17c..9c6977df 100644 --- a/views/tasks.blade.php +++ b/views/tasks.blade.php @@ -62,6 +62,11 @@ Hidden category {{ $L('Assigned to') }} Hidden status + + @include('components.userfields_thead', array( + 'userfields' => $userfields + )) + @@ -98,6 +103,12 @@ @if($task->done == 1) text-muted @endif @if(!empty($task->due_date) && $task->due_date < date('Y-m-d')) overdue @elseif(!empty($task->due_date) && $task->due_date < date('Y-m-d', strtotime("+$nextXDays days"))) duesoon @endif + + @include('components.userfields_tbody', array( + 'userfields' => $userfields, + 'userfieldValues' => FindAllObjectsInArrayByPropertyValue($userfieldValues, 'object_id', $task->id) + )) + @endforeach diff --git a/views/userfieldform.blade.php b/views/userfieldform.blade.php index ccc224cb..4c19c035 100644 --- a/views/userfieldform.blade.php +++ b/views/userfieldform.blade.php @@ -34,8 +34,8 @@
    - -
    {{ $L('A name is required') }}
    + +
    {{ $L('This is required and can only contain letters and numbers') }}
    diff --git a/views/userfields.blade.php b/views/userfields.blade.php index 7fd8dfde..ae8974f5 100644 --- a/views/userfields.blade.php +++ b/views/userfields.blade.php @@ -9,7 +9,7 @@

    @yield('title') - +  {{ $L('Add') }}