diff --git a/changelog/52_UNRELEASED_2019-xx-xx.md b/changelog/52_UNRELEASED_2019-xx-xx.md
index 525ac354..48b7d2fc 100644
--- a/changelog/52_UNRELEASED_2019-xx-xx.md
+++ b/changelog/52_UNRELEASED_2019-xx-xx.md
@@ -1,3 +1,8 @@
+### New feature: Custom entities / objects
+- Custom entities are based on Userfields and can be used to add any custom lists you want to have in grocy
+- They can have an own menu entry in the sidebar
+- => See "Manage master data" -> Userentities or try it on the demo: https://demo.grocy.info/userobjects/exampleuserentity
+
### Stock improvements
- Products can now have variations (nested products)
- Define the parent product for a product on the product edit page (only one level is possible, means a product which is used as a parent product in another product, cannot have a parent product itself)
diff --git a/controllers/BaseController.php b/controllers/BaseController.php
index f48c2661..0d6349d5 100644
--- a/controllers/BaseController.php
+++ b/controllers/BaseController.php
@@ -16,21 +16,10 @@ class BaseController
$localizationService = new LocalizationService(GROCY_CULTURE);
$this->LocalizationService = $localizationService;
- if (GROCY_MODE === 'prerelease')
- {
- $commitHash = trim(exec('git log --pretty="%h" -n1 HEAD'));
- $commitDate = trim(exec('git log --date=iso --pretty="%cd" -n1 HEAD'));
-
- $container->view->set('version', "pre-release-$commitHash");
- $container->view->set('releaseDate', \substr($commitDate, 0, 19));
- }
- else
- {
- $applicationService = new ApplicationService();
- $versionInfo = $applicationService->GetInstalledVersion();
- $container->view->set('version', $versionInfo->Version);
- $container->view->set('releaseDate', $versionInfo->ReleaseDate);
- }
+ $applicationService = new ApplicationService();
+ $versionInfo = $applicationService->GetInstalledVersion();
+ $container->view->set('version', $versionInfo->Version);
+ $container->view->set('releaseDate', $versionInfo->ReleaseDate);
$container->view->set('__t', function(string $text, ...$placeholderValues) use($localizationService)
{
@@ -64,6 +53,8 @@ class BaseController
}
$container->view->set('featureFlags', $constants);
+ $container->view->set('userentitiesForSidebar', $this->Database->userentities()->where('show_in_sidebar_menu = 1')->orderBy('name'));
+
try
{
$usersService = new UsersService();
diff --git a/controllers/GenericEntityController.php b/controllers/GenericEntityController.php
index ed1956c6..65c5cd1d 100644
--- a/controllers/GenericEntityController.php
+++ b/controllers/GenericEntityController.php
@@ -22,6 +22,25 @@ class GenericEntityController extends BaseController
]);
}
+ public function UserentitiesList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
+ {
+ return $this->AppContainer->view->render($response, 'userentities', [
+ 'userentities' => $this->Database->userentities()->orderBy('name')
+ ]);
+ }
+
+ public function UserobjectsList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
+ {
+ $userentity = $this->Database->userentities()->where('name = :1', $args['userentityName'])->fetch();
+
+ return $this->AppContainer->view->render($response, 'userobjects', [
+ 'userentity' => $userentity,
+ 'userobjects' => $this->Database->userobjects()->where('userentity_id = :1', $userentity->id),
+ 'userfields' => $this->UserfieldsService->GetFields('userentity-' . $args['userentityName']),
+ 'userfieldValues' => $this->UserfieldsService->GetAllValues('userentity-' . $args['userentityName'])
+ ]);
+ }
+
public function UserfieldEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
if ($args['userfieldId'] == 'new')
@@ -42,4 +61,44 @@ class GenericEntityController extends BaseController
]);
}
}
+
+ public function UserentityEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
+ {
+ if ($args['userentityId'] == 'new')
+ {
+ return $this->AppContainer->view->render($response, 'userentityform', [
+ 'mode' => 'create'
+ ]);
+ }
+ else
+ {
+ return $this->AppContainer->view->render($response, 'userentityform', [
+ 'mode' => 'edit',
+ 'userentity' => $this->Database->userentities($args['userentityId'])
+ ]);
+ }
+ }
+
+ public function UserobjectEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
+ {
+ $userentity = $this->Database->userentities()->where('name = :1', $args['userentityName'])->fetch();
+
+ if ($args['userobjectId'] == 'new')
+ {
+ return $this->AppContainer->view->render($response, 'userobjectform', [
+ 'userentity' => $userentity,
+ 'mode' => 'create',
+ 'userfields' => $this->UserfieldsService->GetFields('userentity-' . $args['userentityName'])
+ ]);
+ }
+ else
+ {
+ return $this->AppContainer->view->render($response, 'userobjectform', [
+ 'userentity' => $userentity,
+ 'mode' => 'edit',
+ 'userobject' => $this->Database->userobjects($args['userobjectId']),
+ 'userfields' => $this->UserfieldsService->GetFields('userentity-' . $args['userentityName'])
+ ]);
+ }
+ }
}
diff --git a/grocy.openapi.json b/grocy.openapi.json
index ad023943..cea3794d 100644
--- a/grocy.openapi.json
+++ b/grocy.openapi.json
@@ -2515,6 +2515,8 @@
"equipment",
"api_keys",
"userfields",
+ "userentities",
+ "userobjects",
"meal_plan"
]
},
diff --git a/localization/demo_data.pot b/localization/demo_data.pot
index 0a1eb9bb..75978ada 100644
--- a/localization/demo_data.pot
+++ b/localization/demo_data.pot
@@ -288,3 +288,15 @@ msgid "Slice"
msgid_plural "Slices"
msgstr[0] ""
msgstr[1] ""
+
+msgid "Example userentity"
+msgstr ""
+
+msgid "This is an example user entity..."
+msgstr ""
+
+msgid "Custom field"
+msgstr ""
+
+msgid "Example field value..."
+msgstr ""
diff --git a/localization/strings.pot b/localization/strings.pot
index d702bea8..72aaff2b 100644
--- a/localization/strings.pot
+++ b/localization/strings.pot
@@ -1420,3 +1420,33 @@ msgstr ""
msgid "Consume product on chore execution"
msgstr ""
+
+msgid "Are you sure to delete user field \"%s\"?"
+msgstr ""
+
+msgid "Userentities"
+msgstr ""
+
+msgid "Create userentity"
+msgstr ""
+
+msgid "Show in sidebar menu"
+msgstr ""
+
+msgid "Edit userentity"
+msgstr ""
+
+msgid "Edit %s"
+msgstr ""
+
+msgid "Create %s"
+msgstr ""
+
+msgid "Are you sure to delete this userobject?"
+msgstr ""
+
+msgid "Icon CSS class"
+msgstr ""
+
+msgid "For example"
+msgstr ""
diff --git a/migrations/0085.sql b/migrations/0085.sql
new file mode 100644
index 00000000..17a8031c
--- /dev/null
+++ b/migrations/0085.sql
@@ -0,0 +1,17 @@
+CREATE TABLE userentities (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
+ name TEXT NOT NULL,
+ caption TEXT NOT NULL,
+ description TEXT,
+ show_in_sidebar_menu TINYINT NOT NULL DEFAULT 1,
+ icon_css_class TEXT,
+ row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')),
+
+ UNIQUE(name)
+);
+
+CREATE TABLE userobjects (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
+ userentity_id INTEGER NOT NULL,
+ row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
+);
diff --git a/public/viewjs/userentities.js b/public/viewjs/userentities.js
new file mode 100644
index 00000000..2ea3ddf2
--- /dev/null
+++ b/public/viewjs/userentities.js
@@ -0,0 +1,69 @@
+var userentitiesTable = $('#userentities-table').DataTable({
+ 'paginate': false,
+ 'order': [[1, 'asc']],
+ 'columnDefs': [
+ { 'orderable': false, 'targets': 0 }
+ ],
+ 'language': JSON.parse(__t('datatables_localization')),
+ 'scrollY': false,
+ 'colReorder': true,
+ 'stateSave': true,
+ 'stateSaveParams': function(settings, data)
+ {
+ data.search.search = "";
+
+ data.columns.forEach(column =>
+ {
+ column.search.search = "";
+ });
+ }
+});
+$('#userentities-table tbody').removeClass("d-none");
+userentitiesTable.columns.adjust().draw();
+
+$("#search").on("keyup", function()
+{
+ var value = $(this).val();
+ if (value === "all")
+ {
+ value = "";
+ }
+
+ userentitiesTable.search(value).draw();
+});
+
+$(document).on('click', '.userentity-delete-button', function (e)
+{
+ var objectName = $(e.currentTarget).attr('data-userentity-name');
+ var objectId = $(e.currentTarget).attr('data-userentity-id');
+
+ bootbox.confirm({
+ message: __t('Are you sure to delete userentity "%s"?', objectName),
+ buttons: {
+ confirm: {
+ label: __t('Yes'),
+ className: 'btn-success'
+ },
+ cancel: {
+ label: __t('No'),
+ className: 'btn-danger'
+ }
+ },
+ callback: function(result)
+ {
+ if (result === true)
+ {
+ Grocy.Api.Delete('objects/userentities/' + objectId, { },
+ function(result)
+ {
+ window.location.href = U('/userentities');
+ },
+ function(xhr)
+ {
+ console.error(xhr);
+ }
+ );
+ }
+ }
+ });
+});
diff --git a/public/viewjs/userentityform.js b/public/viewjs/userentityform.js
new file mode 100644
index 00000000..4c376ceb
--- /dev/null
+++ b/public/viewjs/userentityform.js
@@ -0,0 +1,68 @@
+$('#save-userentity-button').on('click', function(e)
+{
+ e.preventDefault();
+
+ var jsonData = $('#userentity-form').serializeJSON();
+ Grocy.FrontendHelpers.BeginUiBusy("userentity-form");
+
+ var redirectUrl = U("/userentities");
+
+ if (Grocy.EditMode === 'create')
+ {
+ Grocy.Api.Post('objects/userentities', jsonData,
+ function(result)
+ {
+ window.location.href = redirectUrl;
+ },
+ function(xhr)
+ {
+ Grocy.FrontendHelpers.EndUiBusy("userentity-form");
+ Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
+ }
+ );
+ }
+ else
+ {
+ Grocy.Api.Put('objects/userentities/' + Grocy.EditObjectId, jsonData,
+ function(result)
+ {
+ window.location.href = redirectUrl;
+ },
+ function(xhr)
+ {
+ Grocy.FrontendHelpers.EndUiBusy("userentity-form");
+ Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
+ }
+ );
+ }
+});
+
+$('#userentity-form input').keyup(function(event)
+{
+ Grocy.FrontendHelpers.ValidateForm('userentity-form');
+});
+
+$('#userentity-form select').change(function(event)
+{
+ Grocy.FrontendHelpers.ValidateForm('userentity-form');
+});
+
+$('#userentity-form input').keydown(function(event)
+{
+ if (event.keyCode === 13) //Enter
+ {
+ event.preventDefault();
+
+ if (document.getElementById('userentity-form').checkValidity() === false) //There is at least one validation error
+ {
+ return false;
+ }
+ else
+ {
+ $('#save-userentity-button').click();
+ }
+ }
+});
+
+$('#name').focus();
+Grocy.FrontendHelpers.ValidateForm('userentity-form');
diff --git a/public/viewjs/userobjectform.js b/public/viewjs/userobjectform.js
new file mode 100644
index 00000000..e4cb4081
--- /dev/null
+++ b/public/viewjs/userobjectform.js
@@ -0,0 +1,47 @@
+$('#save-userobject-button').on('click', function(e)
+{
+ e.preventDefault();
+
+ var jsonData = {};
+ jsonData.userentity_id = Grocy.EditObjectParentId;
+ console.log(jsonData);
+ Grocy.FrontendHelpers.BeginUiBusy("userobject-form");
+
+ if (Grocy.EditMode === 'create')
+ {
+ Grocy.Api.Post('objects/userobjects', jsonData,
+ function(result)
+ {
+ Grocy.EditObjectId = result.created_object_id;
+ Grocy.Components.UserfieldsForm.Save(function()
+ {
+ window.location.href = U('/userobjects/' + Grocy.EditObjectParentName);
+ });
+ },
+ function(xhr)
+ {
+ Grocy.FrontendHelpers.EndUiBusy("userobject-form");
+ Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
+ }
+ );
+ }
+ else
+ {
+ Grocy.Api.Put('objects/userobjects/' + Grocy.EditObjectId, jsonData,
+ function(result)
+ {
+ Grocy.Components.UserfieldsForm.Save(function()
+ {
+ window.location.href = U('/userobjects/' + Grocy.EditObjectParentName);
+ });
+ },
+ function(xhr)
+ {
+ Grocy.FrontendHelpers.EndUiBusy("userobject-form");
+ Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
+ }
+ );
+ }
+});
+
+Grocy.Components.UserfieldsForm.Load();
diff --git a/public/viewjs/userobjects.js b/public/viewjs/userobjects.js
new file mode 100644
index 00000000..21958fa8
--- /dev/null
+++ b/public/viewjs/userobjects.js
@@ -0,0 +1,68 @@
+var userobjectsTable = $('#userobjects-table').DataTable({
+ 'paginate': false,
+ 'order': [[1, 'asc']],
+ 'columnDefs': [
+ { 'orderable': false, 'targets': 0 }
+ ],
+ 'language': JSON.parse(__t('datatables_localization')),
+ 'scrollY': false,
+ 'colReorder': true,
+ 'stateSave': true,
+ 'stateSaveParams': function(settings, data)
+ {
+ data.search.search = "";
+
+ data.columns.forEach(column =>
+ {
+ column.search.search = "";
+ });
+ }
+});
+$('#userobjects-table tbody').removeClass("d-none");
+userobjectsTable.columns.adjust().draw();
+
+$("#search").on("keyup", function()
+{
+ var value = $(this).val();
+ if (value === "all")
+ {
+ value = "";
+ }
+
+ userobjectsTable.search(value).draw();
+});
+
+$(document).on('click', '.userobject-delete-button', function (e)
+{
+ var objectId = $(e.currentTarget).attr('data-userobject-id');
+
+ bootbox.confirm({
+ message: __t('Are you sure to delete this userobject?'),
+ buttons: {
+ confirm: {
+ label: __t('Yes'),
+ className: 'btn-success'
+ },
+ cancel: {
+ label: __t('No'),
+ className: 'btn-danger'
+ }
+ },
+ callback: function(result)
+ {
+ if (result === true)
+ {
+ Grocy.Api.Delete('objects/userobjects/' + objectId, { },
+ function(result)
+ {
+ window.location.reload();
+ },
+ function(xhr)
+ {
+ console.error(xhr);
+ }
+ );
+ }
+ }
+ });
+});
diff --git a/routes.php b/routes.php
index fb62cd9c..2766740f 100644
--- a/routes.php
+++ b/routes.php
@@ -19,6 +19,10 @@ $app->group('', function()
// Generic entity interaction
$this->get('/userfields', '\Grocy\Controllers\GenericEntityController:UserfieldsList');
$this->get('/userfield/{userfieldId}', '\Grocy\Controllers\GenericEntityController:UserfieldEditForm');
+ $this->get('/userentities', '\Grocy\Controllers\GenericEntityController:UserentitiesList');
+ $this->get('/userentity/{userentityId}', '\Grocy\Controllers\GenericEntityController:UserentityEditForm');
+ $this->get('/userobjects/{userentityName}', '\Grocy\Controllers\GenericEntityController:UserobjectsList');
+ $this->get('/userobject/{userentityName}/{userobjectId}', '\Grocy\Controllers\GenericEntityController:UserobjectEditForm');
// User routes
$this->get('/users', '\Grocy\Controllers\UsersController:UsersList');
diff --git a/services/ApplicationService.php b/services/ApplicationService.php
index 361d676a..27bc842c 100644
--- a/services/ApplicationService.php
+++ b/services/ApplicationService.php
@@ -9,7 +9,20 @@ class ApplicationService extends BaseService
{
if ($this->InstalledVersion == null)
{
- $this->InstalledVersion = json_decode(file_get_contents(__DIR__ . '/../version.json'));
+ if (GROCY_MODE === 'prerelease')
+ {
+ $commitHash = trim(exec('git log --pretty="%h" -n1 HEAD'));
+ $commitDate = trim(exec('git log --date=iso --pretty="%cd" -n1 HEAD'));
+
+ $this->InstalledVersion = array(
+ 'Version' => "pre-release-$commitHash",
+ 'ReleaseDate' => substr($commitDate, 0, 19)
+ );
+ }
+ else
+ {
+ $this->InstalledVersion = json_decode(file_get_contents(__DIR__ . '/../version.json'));
+ }
}
return $this->InstalledVersion;
diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php
index 770c96ab..0f7c45ac 100644
--- a/services/DemoDataGeneratorService.php
+++ b/services/DemoDataGeneratorService.php
@@ -150,6 +150,19 @@ class DemoDataGeneratorService extends BaseService
INSERT INTO equipment (name, description, instruction_manual_file_name) VALUES ('{$this->__t_sql('Coffee machine')}', '{$loremIpsumWithHtmlFormattings}', 'loremipsum.pdf'); --1
INSERT INTO equipment (name, description) VALUES ('{$this->__t_sql('Dishwasher')}', '{$loremIpsumWithHtmlFormattings}'); --2
+ INSERT INTO userentities (name, caption, description, show_in_sidebar_menu, icon_css_class) VALUES ('exampleuserentity', '{$this->__t_sql('Example userentity')}', '{$this->__t_sql('This is an example user entity...')}', 1, 'fas fa-smile'); --1
+
+ INSERT INTO userfields (entity, name, caption, type, show_as_column_in_tables) VALUES ('userentity-exampleuserentity', 'customfield1', '{$this->__t_sql('Custom field')} 1', 'text-single-line', 1); --1
+ INSERT INTO userfields (entity, name, caption, type, show_as_column_in_tables) VALUES ('userentity-exampleuserentity', 'customfield2', '{$this->__t_sql('Custom field')} 2', 'text-single-line', 1); --2
+
+ INSERT INTO userobjects (userentity_id) VALUES (1); --1
+ INSERT INTO userobjects (userentity_id) VALUES (1); --2
+
+ INSERT INTO userfield_values (field_id, object_id, value) VALUES (1, 1, '{$this->__t_sql('Example field value...')}');
+ INSERT INTO userfield_values (field_id, object_id, value) VALUES (2, 1, '{$this->__t_sql('Example field value...')}');
+ INSERT INTO userfield_values (field_id, object_id, value) VALUES (1, 2, '{$this->__t_sql('Example field value...')}');
+ INSERT INTO userfield_values (field_id, object_id, value) VALUES (2, 2, '{$this->__t_sql('Example field value...')}');
+
INSERT INTO migrations (migration) VALUES (-1);
";
diff --git a/services/UserfieldsService.php b/services/UserfieldsService.php
index 5fae50a2..619bc602 100644
--- a/services/UserfieldsService.php
+++ b/services/UserfieldsService.php
@@ -109,7 +109,15 @@ class UserfieldsService extends BaseService
public function GetEntities()
{
- return $this->OpenApiSpec->components->internalSchemas->ExposedEntity->enum;
+ $exposedDefaultEntities = $this->OpenApiSpec->components->internalSchemas->ExposedEntity->enum;
+
+ $userentities = array();
+ foreach ($this->Database->userentities()->orderBy('name') as $userentity)
+ {
+ $userentities[] = 'userentity-' . $userentity->name;
+ }
+
+ return array_merge($exposedDefaultEntities, $userentities);
}
public function GetFieldTypes()
@@ -119,6 +127,6 @@ class UserfieldsService extends BaseService
private function IsValidEntity($entity)
{
- return in_array($entity, $this->OpenApiSpec->components->internalSchemas->ExposedEntity->enum);
+ return in_array($entity, $this->GetEntities());
}
}
diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php
index 071ce8da..8cd97671 100644
--- a/views/layout/default.blade.php
+++ b/views/layout/default.blade.php
@@ -186,6 +186,17 @@
@endif
+
+ @php $firstUserentity = true; @endphp
+ @foreach($userentitiesForSidebar as $userentity)
+
+
+
+ {{ $userentity->caption }}
+
+
+ @php if ($firstUserentity) { $firstUserentity = false; } @endphp
+ @endforeach
@@ -249,6 +260,12 @@
{{ $__t('Userfields') }}
+
+
+
+ {{ $__t('Userentities') }}
+
+
diff --git a/views/userentities.blade.php b/views/userentities.blade.php
new file mode 100644
index 00000000..2c85f029
--- /dev/null
+++ b/views/userentities.blade.php
@@ -0,0 +1,62 @@
+@extends('layout.default')
+
+@section('title', $__t('Userentities'))
+@section('activeNav', 'userentities')
+@section('viewJsName', 'userentities')
+
+@section('content')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ $__t('Name') }} |
+ {{ $__t('Caption') }} |
+
+
+
+ @foreach($userentities as $userentity)
+
+
+
+
+
+
+
+
+
+ {{ $__t('Configure userfields') }}
+
+ |
+
+ {{ $userentity->name }}
+ |
+
+ {{ $userentity->caption }}
+ |
+
+ @endforeach
+
+
+
+
+@stop
diff --git a/views/userentityform.blade.php b/views/userentityform.blade.php
new file mode 100644
index 00000000..345d8535
--- /dev/null
+++ b/views/userentityform.blade.php
@@ -0,0 +1,59 @@
+@extends('layout.default')
+
+@if($mode == 'edit')
+ @section('title', $__t('Edit userentity'))
+@else
+ @section('title', $__t('Create userentity'))
+@endif
+
+@section('viewJsName', 'userentityform')
+
+@section('content')
+
+
+
@yield('title')
+
+
+
+ @if($mode == 'edit')
+
+ @endif
+
+
+
+
+@stop
diff --git a/views/userobjectform.blade.php b/views/userobjectform.blade.php
new file mode 100644
index 00000000..a1a09dc4
--- /dev/null
+++ b/views/userobjectform.blade.php
@@ -0,0 +1,38 @@
+@extends('layout.default')
+
+@if($mode == 'edit')
+ @section('title', $__t('Edit %s', $userentity->caption))
+@else
+ @section('title', $__t('Create %s', $userentity->caption))
+@endif
+
+@section('viewJsName', 'userobjectform')
+
+@section('content')
+
+
+
@yield('title')
+
+
+
+ @if($mode == 'edit')
+
+ @endif
+
+
+
+
+@stop
diff --git a/views/userobjects.blade.php b/views/userobjects.blade.php
new file mode 100644
index 00000000..19e9f95a
--- /dev/null
+++ b/views/userobjects.blade.php
@@ -0,0 +1,66 @@
+@extends('layout.default')
+
+@section('title', $userentity->caption)
+@section('activeNav', 'userentity-' . $userentity->name)
+@section('viewJsName', 'userobjects')
+
+@section('content')
+
+
+
+
{{ $userentity->description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ @include('components.userfields_thead', array(
+ 'userfields' => $userfields
+ ))
+
+
+
+
+ @foreach($userobjects as $userobject)
+
+
+
+
+
+
+
+
+ |
+
+ @include('components.userfields_tbody', array(
+ 'userfields' => $userfields,
+ 'userfieldValues' => FindAllObjectsInArrayByPropertyValue($userfieldValues, 'object_id', $userobject->id)
+ ))
+
+
+ @endforeach
+
+
+
+
+@stop