Implement custom entities / objects (closes #242)

This commit is contained in:
Bernd Bestel 2019-09-18 16:18:15 +02:00
parent 918f84f568
commit 096fb7a116
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
20 changed files with 666 additions and 18 deletions

View File

@ -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)

View File

@ -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();

View File

@ -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'])
]);
}
}
}

View File

@ -2515,6 +2515,8 @@
"equipment",
"api_keys",
"userfields",
"userentities",
"userobjects",
"meal_plan"
]
},

View File

@ -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 ""

View File

@ -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 ""

17
migrations/0085.sql Normal file
View File

@ -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'))
);

View File

@ -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);
}
);
}
}
});
});

View File

@ -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');

View File

@ -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();

View File

@ -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);
}
);
}
}
});
});

View File

@ -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');

View File

@ -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;

View File

@ -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);
";

View File

@ -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());
}
}

View File

@ -186,6 +186,17 @@
</a>
</li>
@endif
@php $firstUserentity = true; @endphp
@foreach($userentitiesForSidebar as $userentity)
<li class="nav-item @if($firstUserentity) mt-4 @endif" data-toggle="tooltip" data-placement="right" title="{{ $userentity->caption }}" data-nav-for-page="userentity-{{ $userentity->name }}">
<a class="nav-link discrete-link" href="{{ $U('/userobjects/' . $userentity->name) }}">
<i class="{{ $userentity->icon_css_class }}"></i>
<span class="nav-link-text">{{ $userentity->caption }}</span>
</a>
</li>
@php if ($firstUserentity) { $firstUserentity = false; } @endphp
@endforeach
<li class="nav-item mt-4" data-toggle="tooltip" data-placement="right" title="{{ $__t('Manage master data') }}">
<a class="nav-link nav-link-collapse collapsed discrete-link" data-toggle="collapse" href="#top-nav-manager-master-data">
@ -249,6 +260,12 @@
<span class="nav-link-text">{{ $__t('Userfields') }}</span>
</a>
</li>
<li data-nav-for-page="userentities" data-sub-menu-of="#top-nav-manager-master-data">
<a class="nav-link discrete-link" href="{{ $U('/userentities') }}">
<i class="fas fa-bookmark "></i>
<span class="nav-link-text">{{ $__t('Userentities') }}</span>
</a>
</li>
</ul>
</li>
</ul>

View File

@ -0,0 +1,62 @@
@extends('layout.default')
@section('title', $__t('Userentities'))
@section('activeNav', 'userentities')
@section('viewJsName', 'userentities')
@section('content')
<div class="row">
<div class="col">
<h1>
@yield('title')
<a id="new-userentity-button" class="btn btn-outline-dark" href="{{ $U('/userentity/new') }}">
<i class="fas fa-plus"></i>&nbsp;{{ $__t('Add') }}
</a>
</h1>
</div>
</div>
<div class="row mt-3">
<div class="col-xs-12 col-md-6 col-xl-3">
<label for="search">{{ $__t('Search') }}</label> <i class="fas fa-search"></i>
<input type="text" class="form-control" id="search">
</div>
</div>
<div class="row">
<div class="col">
<table id="userentities-table" class="table table-sm table-striped dt-responsive">
<thead>
<tr>
<th class="border-right"></th>
<th>{{ $__t('Name') }}</th>
<th>{{ $__t('Caption') }}</th>
</tr>
</thead>
<tbody class="d-none">
@foreach($userentities as $userentity)
<tr>
<td class="fit-content border-right">
<a class="btn btn-info btn-sm" href="{{ $U('/userentity/') }}{{ $userentity->id }}">
<i class="fas fa-edit"></i>
</a>
<a class="btn btn-danger btn-sm userentity-delete-button" href="#" data-userentity-id="{{ $userentity->id }}" data-userentity-name="{{ $userentity->name }}">
<i class="fas fa-trash"></i>
</a>
<a class="btn btn-secondary btn-sm" href="{{ $U('/userfields?entity=userentity-') }}{{ $userentity->name }}">
<i class="fas fa-th-list"></i> {{ $__t('Configure userfields') }}
</a>
</td>
<td>
{{ $userentity->name }}
</td>
<td>
{{ $userentity->caption }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop

View File

@ -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')
<div class="row">
<div class="col-lg-6 col-xs-12">
<h1>@yield('title')</h1>
<script>Grocy.EditMode = '{{ $mode }}';</script>
@if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $userentity->id }};</script>
@endif
<form id="userentity-form" novalidate>
<div class="form-group">
<label for="name">{{ $__t('Name') }}</label>
<input @if($mode == 'edit') disabled @endif type="text" class="form-control" required pattern="^[a-zA-Z0-9]*$" id="name" name="name" value="@if($mode == 'edit'){{ $userentity->name }}@endif">
<div class="invalid-feedback">{{ $__t('This is required and can only contain letters and numbers') }}</div>
</div>
<div class="form-group">
<label for="name">{{ $__t('Caption') }}</label>
<input type="text" class="form-control" required id="caption" name="caption" value="@if($mode == 'edit'){{ $userentity->caption }}@endif">
<div class="invalid-feedback">{{ $__t('A caption is required') }}</div>
</div>
<div class="form-group">
<label for="description">{{ $__t('Description') }}</label>
<textarea class="form-control" rows="2" id="description" name="description">@if($mode == 'edit'){{ $userentity->description }}@endif</textarea>
</div>
<div class="form-group">
<div class="form-check">
<input type="hidden" name="show_in_sidebar_menu" value="0">
<input @if($mode == 'edit' && $userentity->show_in_sidebar_menu == 1) checked @endif class="form-check-input" type="checkbox" id="show_in_sidebar_menu" name="show_in_sidebar_menu" value="1">
<label class="form-check-label" for="show_in_sidebar_menu">{{ $__t('Show in sidebar menu') }}</label>
</div>
</div>
<div class="form-group">
<label for="name">{{ $__t('Icon CSS class') }}</label>
<input type="text" class="form-control" id="icon_css_class" name="icon_css_class" value="@if($mode == 'edit'){{ $userentity->icon_css_class }}@endif" placeholder='{{ $__t('For example') }} "fas fa-smile"'>
</div>
<button id="save-userentity-button" class="btn btn-success">{{ $__t('Save') }}</button>
</form>
</div>
</div>
@stop

View File

@ -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')
<div class="row">
<div class="col-lg-6 col-xs-12">
<h1>@yield('title')</h1>
<script>
Grocy.EditMode = '{{ $mode }}';
Grocy.EditObjectParentId = {{ $userentity->id }};
Grocy.EditObjectParentName = "{{ $userentity->name }}";
</script>
@if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $userobject->id }};</script>
@endif
<form id="userobject-form" novalidate>
@include('components.userfieldsform', array(
'userfields' => $userfields,
'entity' => 'userentity-' . $userentity->name
))
<button id="save-userobject-button" class="btn btn-success">{{ $__t('Save') }}</button>
</form>
</div>
</div>
@stop

View File

@ -0,0 +1,66 @@
@extends('layout.default')
@section('title', $userentity->caption)
@section('activeNav', 'userentity-' . $userentity->name)
@section('viewJsName', 'userobjects')
@section('content')
<div class="row">
<div class="col">
<h1>
@yield('title')
<a class="btn btn-outline-dark" href="{{ $U('/userobject/' . $userentity->name . '/new') }}">
<i class="fas fa-plus"></i>&nbsp;{{ $__t('Add') }}
</a>
<a class="btn btn-outline-secondary" href="{{ $U('/userfields?entity=' . 'userentity-' . $userentity->name) }}">
<i class="fas fa-sliders-h"></i>&nbsp;{{ $__t('Configure userfields') }}
</a>
</h1>
<h5 class="text-muted">{{ $userentity->description }}</h5>
</div>
</div>
<div class="row mt-3">
<div class="col-xs-12 col-md-6 col-xl-3">
<label for="search">{{ $__t('Search') }}</label> <i class="fas fa-search"></i>
<input type="text" class="form-control" id="search">
</div>
</div>
<div class="row">
<div class="col">
<table id="userobjects-table" class="table table-sm table-striped dt-responsive">
<thead>
<tr>
<th class="border-right"></th>
@include('components.userfields_thead', array(
'userfields' => $userfields
))
</tr>
</thead>
<tbody class="d-none">
@foreach($userobjects as $userobject)
<tr>
<td class="fit-content border-right">
<a class="btn btn-info btn-sm" href="{{ $U('/userobject/' . $userentity->name . '/') }}{{ $userobject->id }}">
<i class="fas fa-edit"></i>
</a>
<a class="btn btn-danger btn-sm userobject-delete-button" href="#" data-userobject-id="{{ $userobject->id }}">
<i class="fas fa-trash"></i>
</a>
</td>
@include('components.userfields_tbody', array(
'userfields' => $userfields,
'userfieldValues' => FindAllObjectsInArrayByPropertyValue($userfieldValues, 'object_id', $userobject->id)
))
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop