support multiple teamleads in each team (#2702)
* fix jumping avatars * fix line-break after color dot for long names
This commit is contained in:
11
UPGRADING.md
11
UPGRADING.md
@@ -10,9 +10,12 @@ Perform EACH version specific task between your version and the new one, otherwi
|
||||
|
||||
## [1.15](https://github.com/kevinpapst/kimai2/releases/tag/1.15)
|
||||
|
||||
**Many database changes: don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
|
||||
|
||||
Updating the database might take quite a while, depending on the amount of timesheet entries and speed of your database server (~1 minute per 100k records).
|
||||
|
||||
**ATTENTION**
|
||||
**ATTENTION**
|
||||
|
||||
- This release bumps the minimum required [PHP version to 7.3](https://www.kimai.org/blog/2021/php8-support-php72-dropped/)
|
||||
- Self-registration is disabled by default
|
||||
- Self-registration now always requires email confirmation
|
||||
@@ -20,11 +23,13 @@ Updating the database might take quite a while, depending on the amount of times
|
||||
- Removed the YearChart widget and the related configs named `userRecapThisYear`, `userRecapLastYear`, `userRecapTwoYears`, `userRecapThreeYears`
|
||||
|
||||
**LDAP & SAML**
|
||||
|
||||
- SAML users need to activate SAML by setting the new `kimai.saml.activate: true` config key
|
||||
- LDAP and SAML users need to remove the complete `security` section from their `local.yaml`
|
||||
- Please verify your config with the [LDAP](https://www.kimai.org/documentation/ldap.html) and [SAML](https://www.kimai.org/documentation/saml.html) documentation
|
||||
|
||||
**DEVELOPER**
|
||||
**DEVELOPER**
|
||||
|
||||
PHP 8 compatibility forced to upgrade MANY libraries, including but not limited to:
|
||||
|
||||
- Removed FOSUserBundle and hslavich/oneloginsaml
|
||||
@@ -32,6 +37,8 @@ PHP 8 compatibility forced to upgrade MANY libraries, including but not limited
|
||||
- Gedmo v3 (which include BC breaks in definitions)
|
||||
- Doctrine DBAL and others, which required PHP 7.3 as well
|
||||
|
||||
**API BC break**: Due to team structure changes, it was impossible to keep the (writing) API structure. Please adjust your code accordingly!
|
||||
|
||||
## [1.14](https://github.com/kevinpapst/kimai2/releases/tag/1.14)
|
||||
|
||||
**CRITICAL BC break**: SQLite support was removed. If you are using SQLite, you have to [read this blog post](https://www.kimai.org/blog/2021/sqlite-and-ftp-support-removed/) and migrate to MySQL/MariaDB first!
|
||||
|
||||
@@ -54,7 +54,7 @@ export default class KimaiLoader {
|
||||
kimai.registerPlugin(new KimaiDateUtils());
|
||||
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
|
||||
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
|
||||
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
|
||||
kimai.registerPlugin(new KimaiActiveRecordsDuration());
|
||||
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
|
||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
|
||||
kimai.registerPlugin(new KimaiDateTimePicker('input[data-datetimepicker="on"]'));
|
||||
|
||||
@@ -48,6 +48,13 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
|
||||
_toggleMenu(hasEntries) {
|
||||
this.menu.style.display = hasEntries ? 'inline-block' : 'none';
|
||||
if (!hasEntries) {
|
||||
// make sure that template entries in the menu are removed, otherwise they
|
||||
// might still be shown in the browsers title
|
||||
for (let record of this.menu.querySelectorAll('[data-since]')) {
|
||||
record.dataset['since'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const menuEmpty = document.querySelector(this.selectorEmpty);
|
||||
if (menuEmpty !== null) {
|
||||
|
||||
@@ -9,16 +9,10 @@
|
||||
* [KIMAI] KimaiActiveRecordsDuration: activate the updates for all active timesheet records on this page
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return 'timesheet-duration';
|
||||
}
|
||||
@@ -39,7 +33,7 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
updateRecords() {
|
||||
let durations = [];
|
||||
const activeRecords = document.querySelectorAll(this.selector);
|
||||
const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this.updateBrowserTitle) {
|
||||
@@ -48,12 +42,16 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
for(let record of activeRecords) {
|
||||
const since = record.getAttribute('data-since');
|
||||
const duration = this.formatDuration(since);
|
||||
if (record.getAttribute('data-title') !== null && duration !== '?') {
|
||||
const DATE = this.getPlugin('date');
|
||||
|
||||
for (let record of activeRecords) {
|
||||
const since = record.dataset['since'];
|
||||
const duration = DATE.formatDuration(since);
|
||||
// only use the ones from the menu for the title
|
||||
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
|
||||
durations.push(duration);
|
||||
}
|
||||
// but update all on the page (running entries in list pages)
|
||||
record.textContent = duration;
|
||||
}
|
||||
|
||||
@@ -73,8 +71,4 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
}
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
formatDuration(since) {
|
||||
return this.getPlugin('date').formatDuration(since);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ $avatar-base-size: 30;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
font-size: ceil($avatar-base-size / 2 - 2)+px;
|
||||
top: ceil($avatar-base-size / 4 - 1)+px;
|
||||
top: floor($avatar-base-size / 4 - 1)+px;
|
||||
}
|
||||
|
||||
$avatarSizes: "xs" .75, "sm" 1.25, "md" 1.5, "lg" 2;
|
||||
@@ -54,3 +54,7 @@ table.dataTable.table > tbody > tr > td {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar.teamlead {
|
||||
box-shadow: 0px 0px 5px 1px rgba(0,0,0,.4),0 0px 3px 0 rgba(0,0,0,0.3);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ span.label-activity,
|
||||
span.label-project,
|
||||
span.label-customer {
|
||||
display: inline-block;
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
.name-inner {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
.dot {
|
||||
font-size: 9px;
|
||||
margin-right: 3px;
|
||||
|
||||
@@ -37,6 +37,8 @@ nelmio_api_doc:
|
||||
- { alias: TeamEditForm, type: App\Form\API\TeamApiEditForm, groups: [Default, Entity, Team, Team_Entity] }
|
||||
- { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] }
|
||||
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
|
||||
- { alias: TeamMember, type: App\Entity\TeamMember, groups: [Team_Entity] }
|
||||
- { alias: TeamMembership, type: App\Entity\TeamMember, groups: [User_Entity] }
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc)
|
||||
|
||||
2
public/build/app.39dfa7e1.js
Normal file
2
public/build/app.39dfa7e1.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/app.65f97645.js"
|
||||
"build/app.39dfa7e1.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.047d688b.css"
|
||||
"build/app.c80d7236.css"
|
||||
]
|
||||
},
|
||||
"invoice": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "build/app.047d688b.css",
|
||||
"build/app.js": "build/app.65f97645.js",
|
||||
"build/app.css": "build/app.c80d7236.css",
|
||||
"build/app.js": "build/app.39dfa7e1.js",
|
||||
"build/invoice.css": "build/invoice.ff32661a.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Form\API\TeamApiEditForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
@@ -22,7 +23,6 @@ use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
@@ -77,7 +77,7 @@ final class TeamController extends BaseApiController
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
public function cgetAction(): Response
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
|
||||
@@ -177,16 +177,10 @@ final class TeamController extends BaseApiController
|
||||
*/
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$team = new Team();
|
||||
$team->setTeamLead($user);
|
||||
|
||||
$form = $this->createForm(TeamApiEditForm::class, $team);
|
||||
|
||||
$form->submit($request->request->all());
|
||||
$team->addUser($team->getTeamLead());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$this->repository->saveTeam($team);
|
||||
@@ -241,11 +235,17 @@ final class TeamController extends BaseApiController
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// cache the current memberlist
|
||||
/** @var TeamMember[] $originalMembers */
|
||||
$originalMembers = [];
|
||||
foreach ($team->getMembers() as $member) {
|
||||
$originalMembers[] = $member;
|
||||
}
|
||||
|
||||
$form = $this->createForm(TeamApiEditForm::class, $team);
|
||||
|
||||
$form->setData($team);
|
||||
$form->submit($request->request->all(), false);
|
||||
$team->addUser($team->getTeamLead());
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
$view = new View($form, Response::HTTP_OK);
|
||||
@@ -254,6 +254,14 @@ final class TeamController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
// and now remove the ones, which are not in the list any longer
|
||||
foreach ($originalMembers as $member) {
|
||||
if (!$team->hasMember($member)) {
|
||||
$member->getUser()->removeMembership($member);
|
||||
$this->repository->removeTeamMember($member);
|
||||
}
|
||||
}
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
|
||||
@@ -115,6 +115,7 @@ final class CalendarService
|
||||
'timeframeEnd' => $this->configuration->getCalendarTimeframeEnd(),
|
||||
'dragDropAmount' => $this->configuration->getCalendarDragAndDropMaxEntries(),
|
||||
];
|
||||
|
||||
$event = new CalendarConfigurationEvent($config);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ class ImportProjectCommand extends Command
|
||||
|
||||
$team = new Team();
|
||||
$team->setName($project->getName());
|
||||
$team->setTeamLead($teamlead);
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
|
||||
|
||||
@@ -1873,20 +1873,20 @@ final class KimaiImporterCommand extends Command
|
||||
$team->addUser($user);
|
||||
|
||||
// first user in the team will become team lead
|
||||
if ($team->getTeamLead() == null) {
|
||||
$team->setTeamLead($user);
|
||||
if (!$team->hasTeamleads()) {
|
||||
$team->addTeamlead($user);
|
||||
}
|
||||
|
||||
// any other user with admin role in the team will become team lead
|
||||
// should be the last added admin of the source group
|
||||
if ($row['membershipRoleID'] === 1) {
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
}
|
||||
}
|
||||
|
||||
// if team has no users it will not be persisted
|
||||
foreach ($newTeams as $oldId => $team) {
|
||||
if ($team->getTeamLead() === null) {
|
||||
if (!$team->hasUsers()) {
|
||||
$io->warning(sprintf('Didn\'t import team: %s because it has no users.', $team->getName()));
|
||||
++$skippedEmpty;
|
||||
unset($newTeams[$oldId]);
|
||||
@@ -1998,7 +1998,7 @@ final class KimaiImporterCommand extends Command
|
||||
$team->setName($name);
|
||||
$teamlead = $users[array_key_first($users)];
|
||||
$teamlead = $this->getCachedUser($teamlead['userID']);
|
||||
$team->setTeamLead($teamlead);
|
||||
$team->addTeamlead($teamlead);
|
||||
foreach ($users as $oldUser) {
|
||||
$team->addUser($this->getCachedUser($oldUser['userID']));
|
||||
foreach ($activities as $oldActivity) {
|
||||
|
||||
@@ -128,7 +128,7 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
|
||||
$team = new Team();
|
||||
$team->setName('Test team');
|
||||
$team->setTeamLead($userEntities[6]);
|
||||
$team->addTeamlead($userEntities[6]);
|
||||
$team->addUser($userEntities[7]);
|
||||
$this->entityManager->persist($team);
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($activity->getName());
|
||||
$defaultTeam->setTeamLead($this->getUser());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addActivity($activity);
|
||||
|
||||
try {
|
||||
|
||||
@@ -227,7 +227,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($customer->getName());
|
||||
$defaultTeam->setTeamLead($this->getUser());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Event\PrepareUserEvent;
|
||||
@@ -21,6 +22,7 @@ use App\Form\UserTeamsType;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use App\User\UserService;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -179,13 +181,27 @@ final class ProfileController extends AbstractController
|
||||
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
|
||||
* @Security("is_granted('teams', profile)")
|
||||
*/
|
||||
public function teamsAction(User $profile, Request $request): Response
|
||||
public function teamsAction(User $profile, Request $request, UserService $service): Response
|
||||
{
|
||||
$originalMembers = new ArrayCollection();
|
||||
foreach ($profile->getMemberships() as $member) {
|
||||
$originalMembers->add($member);
|
||||
}
|
||||
|
||||
$form = $this->createTeamsForm($profile);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
|
||||
/** @var TeamMember $member */
|
||||
foreach ($originalMembers as $member) {
|
||||
if (!$profile->hasMembership($member)) {
|
||||
$member->getTeam()->removeMember($member);
|
||||
$entityManager->remove($profile);
|
||||
}
|
||||
}
|
||||
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($project->getName());
|
||||
$defaultTeam->setTeamLead($this->getUser());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addProject($project);
|
||||
|
||||
try {
|
||||
|
||||
@@ -86,25 +86,10 @@ final class TeamController extends AbstractController
|
||||
*/
|
||||
public function duplicateTeam(Team $team, Request $request)
|
||||
{
|
||||
$newTeam = new Team();
|
||||
$newTeam = clone $team;
|
||||
$newTeam->setName($team->getName() . ' [COPY]');
|
||||
$newTeam->setTeamLead($team->getTeamLead());
|
||||
|
||||
foreach ($team->getUsers() as $user) {
|
||||
$newTeam->addUser($user);
|
||||
}
|
||||
foreach ($team->getCustomers() as $customer) {
|
||||
$newTeam->addCustomer($customer);
|
||||
}
|
||||
foreach ($team->getProjects() as $project) {
|
||||
$newTeam->addProject($project);
|
||||
}
|
||||
|
||||
try {
|
||||
// make sure that the teamlead is always part of the team, otherwise permission checks
|
||||
// and filtering might not work as expected!
|
||||
$team->addUser($team->getTeamLead());
|
||||
|
||||
$this->repository->saveTeam($newTeam);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -140,10 +125,6 @@ final class TeamController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
// make sure that the teamlead is always part of the team, otherwise permission checks
|
||||
// and filtering might not work as expected!
|
||||
$team->addUser($team->getTeamLead());
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -175,22 +156,16 @@ final class TeamController extends AbstractController
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
if ($request->isMethod('POST') && (null !== ($editFormValues = $request->get($editForm->getName())))) {
|
||||
$editForm->submit($editFormValues, true);
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isValid()) {
|
||||
try {
|
||||
// make sure that the teamlead is always part of the team, otherwise permission checks
|
||||
// and filtering might not work as expected!
|
||||
$team->addUser($team->getTeamLead());
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->repository->saveTeam($team);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,10 +173,9 @@ final class TeamController extends AbstractController
|
||||
$customerForm = $this->createForm(TeamCustomerForm::class, $team, [
|
||||
'method' => 'POST',
|
||||
]);
|
||||
$customerForm->handleRequest($request);
|
||||
|
||||
if ($request->isMethod('POST') && (null !== ($customerFormValues = $request->get($customerForm->getName())))) {
|
||||
$customerForm->submit($customerFormValues, true);
|
||||
|
||||
if ($customerForm->isSubmitted() && $customerForm->isValid()) {
|
||||
if ($customerForm->isValid()) {
|
||||
try {
|
||||
$this->repository->saveTeam($team);
|
||||
@@ -217,10 +191,9 @@ final class TeamController extends AbstractController
|
||||
$projectForm = $this->createForm(TeamProjectForm::class, $team, [
|
||||
'method' => 'POST',
|
||||
]);
|
||||
$projectForm->handleRequest($request);
|
||||
|
||||
if ($request->isMethod('POST') && (null !== ($projectFormValues = $request->get($projectForm->getName())))) {
|
||||
$projectForm->submit($projectFormValues, true);
|
||||
|
||||
if ($projectForm->isSubmitted() && $projectForm->isValid()) {
|
||||
if ($projectForm->isValid()) {
|
||||
try {
|
||||
$this->repository->saveTeam($team);
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Controller;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Form\Model\MultiUserTimesheet;
|
||||
use App\Form\TimesheetAdminEditForm;
|
||||
@@ -101,14 +102,15 @@ class TimesheetTeamController extends TimesheetAbstractController
|
||||
|
||||
if ($createForm->isSubmitted() && $createForm->isValid()) {
|
||||
try {
|
||||
/** @var ArrayCollection $users */
|
||||
/** @var ArrayCollection<User> $users */
|
||||
$users = $createForm->get('users')->getData();
|
||||
/** @var ArrayCollection $teams */
|
||||
/** @var ArrayCollection<Team> $teams */
|
||||
$teams = $createForm->get('teams')->getData();
|
||||
|
||||
$allUsers = $users->toArray();
|
||||
/** @var Team $team */
|
||||
foreach ($teams as $team) {
|
||||
$allUsers = array_merge($allUsers, $team->getUsers()->toArray());
|
||||
$allUsers = array_merge($allUsers, $team->getUsers());
|
||||
}
|
||||
$allUsers = array_unique($allUsers);
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
|
||||
$team = new Team();
|
||||
$team
|
||||
->setName($faker->company . ' ' . $i)
|
||||
->setTeamLead($allUsers[array_rand($allUsers)])
|
||||
->addTeamlead($allUsers[array_rand($allUsers)])
|
||||
;
|
||||
|
||||
if ($userCount > 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -27,13 +28,12 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* @UniqueEntity("name")
|
||||
*
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
* @Constraints\Team
|
||||
*/
|
||||
class Team
|
||||
{
|
||||
/**
|
||||
* The internal ID
|
||||
*
|
||||
* @var int
|
||||
* @var int|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
@@ -57,41 +57,23 @@ class Team
|
||||
*/
|
||||
private $name;
|
||||
/**
|
||||
* Teamlead
|
||||
* All team member (including team leads)
|
||||
*
|
||||
* The teamlead for this team
|
||||
*
|
||||
* @var User
|
||||
* @var TeamMember[]|Collection<TeamMember>
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Team_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/User")
|
||||
* @SWG\Property(type="array", @SWG\Items(ref="#/definitions/TeamMember"))
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
* @ORM\OneToMany(targetEntity="App\Entity\TeamMember", mappedBy="team", fetch="LAZY", cascade={"persist"}, orphanRemoval=true)
|
||||
* @ORM\JoinColumn(onDelete="CASCADE")
|
||||
* @Assert\Count(min="1")
|
||||
*/
|
||||
private $teamlead;
|
||||
private $members;
|
||||
/**
|
||||
* Team member
|
||||
* Customers assigned to the team
|
||||
*
|
||||
* All team member, including the teamlead
|
||||
*
|
||||
* @var User[]|ArrayCollection
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Team_Entity"})
|
||||
* @SWG\Property(type="array", @SWG\Items(ref="#/definitions/User"))
|
||||
*
|
||||
* @ORM\ManyToMany(targetEntity="User", mappedBy="teams", fetch="EXTRA_LAZY")
|
||||
*/
|
||||
private $users;
|
||||
/**
|
||||
* Customers
|
||||
*
|
||||
* All customers assigned to the team
|
||||
*
|
||||
* @var Customer[]|ArrayCollection
|
||||
* @var Collection<Customer>
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Team_Entity"})
|
||||
@@ -101,11 +83,9 @@ class Team
|
||||
*/
|
||||
private $customers;
|
||||
/**
|
||||
* Projects
|
||||
* Projects assigned to the team
|
||||
*
|
||||
* All projects assigned to the team
|
||||
*
|
||||
* @var Project[]|ArrayCollection
|
||||
* @var Collection<Project>
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Team_Entity", "Expanded"})
|
||||
@@ -115,11 +95,9 @@ class Team
|
||||
*/
|
||||
private $projects;
|
||||
/**
|
||||
* Activities
|
||||
* Activities assigned to the team
|
||||
*
|
||||
* All activities assigned to the team
|
||||
*
|
||||
* @var Activity[]|ArrayCollection
|
||||
* @var Collection<Activity>
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Team_Entity", "Expanded"})
|
||||
@@ -133,7 +111,7 @@ class Team
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->users = new ArrayCollection();
|
||||
$this->members = new ArrayCollection();
|
||||
$this->customers = new ArrayCollection();
|
||||
$this->projects = new ArrayCollection();
|
||||
$this->activities = new ArrayCollection();
|
||||
@@ -156,55 +134,237 @@ class Team
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getTeamLead(): ?User
|
||||
/**
|
||||
* Indexed by ID to use it within collection type forms.
|
||||
*
|
||||
* @return TeamMember[]
|
||||
*/
|
||||
public function getMembers(): iterable
|
||||
{
|
||||
return $this->teamlead;
|
||||
$all = [];
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getId() === null) {
|
||||
$all[] = $member;
|
||||
} else {
|
||||
$all[$member->getId()] = $member;
|
||||
}
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
public function addMember(TeamMember $member): void
|
||||
{
|
||||
if ($this->members->contains($member)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($member->getTeam() === null) {
|
||||
$member->setTeam($this);
|
||||
}
|
||||
|
||||
if ($member->getTeam() !== $this) {
|
||||
throw new \InvalidArgumentException('Cannot set foreign team membership');
|
||||
}
|
||||
|
||||
if (null !== ($existing = $this->findMember($member))) {
|
||||
$existing->setTeamlead($member->isTeamlead());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// when using the API an invalid user id does not trigger the validation first, but after calling this method :-(
|
||||
if ($member->getUser() !== null) {
|
||||
$this->members->add($member);
|
||||
$member->getUser()->addMembership($member);
|
||||
}
|
||||
}
|
||||
|
||||
public function hasMember(TeamMember $member): bool
|
||||
{
|
||||
return $this->members->contains($member);
|
||||
}
|
||||
|
||||
private function findMember(TeamMember $member): ?TeamMember
|
||||
{
|
||||
foreach ($this->members as $oldMember) {
|
||||
if ($oldMember->getUser() === $member->getUser() && $oldMember->getTeam() === $member->getTeam()) {
|
||||
return $oldMember;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function removeMember(TeamMember $member): void
|
||||
{
|
||||
$existingMember = $this->findMember($member);
|
||||
if ($existingMember === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->members->removeElement($existingMember);
|
||||
$existingMember->getUser()->removeMembership($existingMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* BE AWARE: this property is deprecated and will be removed with 2.0 - teams can have multiple teamleads since 1.15!
|
||||
*
|
||||
* @Serializer\VirtualProperty
|
||||
* @Serializer\SerializedName("teamlead"),
|
||||
* @Serializer\Groups({"Team_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/User")
|
||||
*
|
||||
* @deprecated since 1.15 - will be removed with 2.0
|
||||
* @return User|null
|
||||
*/
|
||||
public function getTeamlead(): ?User
|
||||
{
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->isTeamlead()) {
|
||||
return $member->getUser();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
public function getTeamleads(): array
|
||||
{
|
||||
$leads = [];
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->isTeamlead()) {
|
||||
$leads[] = $member->getUser();
|
||||
}
|
||||
}
|
||||
|
||||
return $leads;
|
||||
}
|
||||
|
||||
public function isTeamlead(User $user): bool
|
||||
{
|
||||
return $this->teamlead === $user;
|
||||
foreach ($this->members as $member) {
|
||||
if ($user === $member->getUser()) {
|
||||
return $member->isTeamlead();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function setTeamLead(User $teamlead): Team
|
||||
/**
|
||||
* @deprecated since 1.15 - will be removed with 2.0
|
||||
* @param User $teamlead
|
||||
*/
|
||||
public function setTeamlead(User $teamlead): void
|
||||
{
|
||||
$this->teamlead = $teamlead;
|
||||
$this->addUser($teamlead);
|
||||
$this->addTeamlead($teamlead);
|
||||
}
|
||||
|
||||
return $this;
|
||||
public function addTeamlead(User $user): void
|
||||
{
|
||||
$this->addUser($user, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will only remove the teamlead flag, not the user from the team.
|
||||
*
|
||||
* @param User $user
|
||||
*/
|
||||
public function removeTeamlead(User $user): void
|
||||
{
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getUser() === $user) {
|
||||
$member->setTeamlead(false);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function hasUser(User $user): bool
|
||||
{
|
||||
return $this->users->contains($user);
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getUser() === $user) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function addUser(User $user)
|
||||
public function hasUsers(): bool
|
||||
{
|
||||
if ($this->users->contains($user)) {
|
||||
return !$this->members->isEmpty();
|
||||
}
|
||||
|
||||
public function hasTeamleads(): bool
|
||||
{
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->isTeamlead()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function addUser(User $user, bool $teamlead = false): void
|
||||
{
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getUser() === $user) {
|
||||
$member->setTeamlead($teamlead);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$member = new TeamMember();
|
||||
$member->setTeam($this);
|
||||
$member->setUser($user);
|
||||
$member->setTeamlead($teamlead);
|
||||
|
||||
$this->addMember($member);
|
||||
}
|
||||
|
||||
public function removeUser(User $user): void
|
||||
{
|
||||
$memberToRemove = null;
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->getUser() === $user) {
|
||||
$memberToRemove = $member;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($memberToRemove === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->users->add($user);
|
||||
$user->addTeam($this);
|
||||
}
|
||||
|
||||
public function removeUser(User $user)
|
||||
{
|
||||
if (!$this->users->contains($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->users->removeElement($user);
|
||||
$user->removeTeam($this);
|
||||
$this->removeMember($memberToRemove);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<User>
|
||||
* Returns all users in the team, both teamlead and normal member.
|
||||
*
|
||||
* @Serializer\VirtualProperty
|
||||
* @Serializer\SerializedName("users"),
|
||||
* @Serializer\Groups({"Team_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/User")
|
||||
*
|
||||
* @return array<User>
|
||||
*/
|
||||
public function getUsers(): iterable
|
||||
{
|
||||
return $this->users;
|
||||
$users = [];
|
||||
foreach ($this->members as $member) {
|
||||
$users[] = $member->getUser();
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function hasCustomer(Customer $customer): bool
|
||||
@@ -313,4 +473,37 @@ class Team
|
||||
{
|
||||
return $this->getName();
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id !== null) {
|
||||
$this->id = null;
|
||||
}
|
||||
|
||||
$members = $this->members;
|
||||
$this->members = new ArrayCollection();
|
||||
foreach ($members as $member) {
|
||||
$newMember = clone $member;
|
||||
$newMember->setTeam($this);
|
||||
$this->addMember($newMember);
|
||||
}
|
||||
|
||||
$customers = $this->customers;
|
||||
$this->customers = new ArrayCollection();
|
||||
foreach ($customers as $customer) {
|
||||
$this->addCustomer($customer);
|
||||
}
|
||||
|
||||
$projects = $this->projects;
|
||||
$this->projects = new ArrayCollection();
|
||||
foreach ($projects as $project) {
|
||||
$this->addProject($project);
|
||||
}
|
||||
|
||||
$activities = $this->activities;
|
||||
$this->activities = new ArrayCollection();
|
||||
foreach ($activities as $activity) {
|
||||
$this->addActivity($activity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
src/Entity/TeamMember.php
Normal file
113
src/Entity/TeamMember.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="kimai2_users_teams",
|
||||
* uniqueConstraints={
|
||||
* @ORM\UniqueConstraint(columns={"user_id", "team_id"})
|
||||
* }
|
||||
* )
|
||||
* @ORM\Entity
|
||||
*
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*/
|
||||
class TeamMember
|
||||
{
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
*/
|
||||
private $id;
|
||||
/**
|
||||
* @var User
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default", "Entity", "Team_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/User")
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="memberships")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $user;
|
||||
/**
|
||||
* @var Team
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default", "Entity", "User_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/TeamEntity")
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\Team", inversedBy="members")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $team;
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default", "Entity", "Team_Entity", "User_Entity"})
|
||||
*
|
||||
* @ORM\Column(name="teamlead", type="boolean", nullable=false, options={"default": false})
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $teamlead = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function isTeamlead(): bool
|
||||
{
|
||||
return $this->teamlead;
|
||||
}
|
||||
|
||||
public function setTeamlead(bool $teamlead): void
|
||||
{
|
||||
$this->teamlead = $teamlead;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): void
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function getTeam(): ?Team
|
||||
{
|
||||
return $this->team;
|
||||
}
|
||||
|
||||
public function setTeam(?Team $team): void
|
||||
{
|
||||
$this->team = $team;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id !== null) {
|
||||
$id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Exception;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Security\Core\User\EquatableInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
@@ -63,7 +64,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* @Exporter\Expose("language", label="label.language", exp="object.getLanguage()")
|
||||
* @Exporter\Expose("last_login", label="label.lastLogin", exp="object.getLastLogin()", type="datetime")
|
||||
* @Exporter\Expose("roles", label="label.roles", exp="object.getRoles()", type="array")
|
||||
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
|
||||
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams()", type="array")
|
||||
* @Exporter\Expose("active", label="label.active", exp="object.isEnabled()", type="boolean")
|
||||
*/
|
||||
class User implements UserInterface, EquatableInterface, \Serializable
|
||||
@@ -172,25 +173,17 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
*/
|
||||
private $preferences;
|
||||
/**
|
||||
* All teams of the user
|
||||
*
|
||||
* @var Team[]|ArrayCollection
|
||||
* @var TeamMember[]|ArrayCollection<TeamMember>
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"User_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/TeamMembership")
|
||||
*
|
||||
* @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"persist"})
|
||||
* @ORM\JoinTable(
|
||||
* name="kimai2_users_teams",
|
||||
* joinColumns={
|
||||
* @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE")
|
||||
* },
|
||||
* inverseJoinColumns={
|
||||
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
|
||||
* }
|
||||
* )
|
||||
* @ORM\OneToMany(targetEntity="App\Entity\TeamMember", mappedBy="user", fetch="LAZY", cascade={"persist"}, orphanRemoval=true)
|
||||
* @ORM\JoinColumn(onDelete="CASCADE")
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $teams;
|
||||
private $memberships;
|
||||
/**
|
||||
* The type of authentication used by the user (eg. "kimai", "ldap", "saml")
|
||||
*
|
||||
@@ -296,7 +289,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
{
|
||||
$this->registeredAt = new DateTime();
|
||||
$this->preferences = new ArrayCollection();
|
||||
$this->teams = new ArrayCollection();
|
||||
$this->memberships = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -515,37 +508,99 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addTeam(Team $team): User
|
||||
public function addMembership(TeamMember $member): void
|
||||
{
|
||||
if ($this->teams->contains($team)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->teams->add($team);
|
||||
$team->addUser($this);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeTeam(Team $team)
|
||||
{
|
||||
if (!$this->teams->contains($team)) {
|
||||
if ($this->memberships->contains($member)) {
|
||||
return;
|
||||
}
|
||||
$this->teams->removeElement($team);
|
||||
$team->removeUser($this);
|
||||
|
||||
if ($member->getUser() === null) {
|
||||
$member->setUser($this);
|
||||
}
|
||||
|
||||
if ($member->getUser() !== $this) {
|
||||
throw new \InvalidArgumentException('Cannot set foreign user membership');
|
||||
}
|
||||
|
||||
if (null !== ($existing = $this->findMember($member))) {
|
||||
$existing->setTeamlead($member->isTeamlead());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// when using the API an invalid team id does not trigger the validation first, but after calling this method :-(
|
||||
if ($member->getTeam() !== null) {
|
||||
$this->memberships->add($member);
|
||||
$member->getTeam()->addMember($member);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeMembership(TeamMember $member): void
|
||||
{
|
||||
$existingMember = $this->findMember($member);
|
||||
if ($existingMember === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->memberships->removeElement($existingMember);
|
||||
$existingMember->getUser()->removeMembership($existingMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexed by ID to use it within collection type forms.
|
||||
*
|
||||
* @return TeamMember[]
|
||||
*/
|
||||
public function getMemberships(): iterable
|
||||
{
|
||||
$all = [];
|
||||
foreach ($this->memberships as $member) {
|
||||
if ($member->getId() === null) {
|
||||
$all[] = $member;
|
||||
} else {
|
||||
$all[$member->getId()] = $member;
|
||||
}
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
public function hasMembership(TeamMember $member): bool
|
||||
{
|
||||
return $this->memberships->contains($member);
|
||||
}
|
||||
|
||||
private function findMember(TeamMember $member): ?TeamMember
|
||||
{
|
||||
foreach ($this->memberships as $oldMember) {
|
||||
if ($oldMember->getUser() === $member->getUser() && $oldMember->getTeam() === $member->getTeam()) {
|
||||
return $oldMember;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is member of any team.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTeamAssignment(): bool
|
||||
{
|
||||
return !$this->getTeams()->isEmpty();
|
||||
return !$this->memberships->isEmpty();
|
||||
}
|
||||
|
||||
public function hasTeamMember(User $user): bool
|
||||
/**
|
||||
* Checks is the user is teamlead in any of the assigned teams.
|
||||
*
|
||||
* @see User::hasTeamleadRole()
|
||||
* @return bool
|
||||
*/
|
||||
public function isTeamlead(): bool
|
||||
{
|
||||
/** @var Team $team */
|
||||
foreach ($this->getTeams() as $team) {
|
||||
if ($team->hasUser($user)) {
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->isTeamlead()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -554,21 +609,96 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<Team>
|
||||
* Checks if the given user is a team member.
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function getTeams(): Collection
|
||||
public function hasTeamMember(User $user): bool
|
||||
{
|
||||
return $this->teams;
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->getTeam()->hasUser($user)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Serializer\VirtualProperty
|
||||
* @Serializer\SerializedName("teams"),
|
||||
* @Serializer\Groups({"User_Entity"})
|
||||
* @SWG\Property(ref="#/definitions/TeamEntity")
|
||||
*
|
||||
* @return Team[]
|
||||
*/
|
||||
public function getTeams(): iterable
|
||||
{
|
||||
$teams = [];
|
||||
foreach ($this->memberships as $membership) {
|
||||
$teams[] = $membership->getTeam();
|
||||
}
|
||||
|
||||
return $teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function addTeam(Team $team): void
|
||||
{
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->getTeam() === $team) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$membership = new TeamMember();
|
||||
$membership->setUser($this);
|
||||
$membership->setTeam($team);
|
||||
|
||||
$this->addMembership($membership);
|
||||
}
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function removeTeam(Team $team): void
|
||||
{
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->getTeam() === $team) {
|
||||
$this->removeMembership($membership);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isInTeam(Team $team): bool
|
||||
{
|
||||
return $this->teams->contains($team);
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->getTeam() === $team) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isTeamleadOf(Team $team): bool
|
||||
{
|
||||
return $team->getTeamLead() === $this;
|
||||
foreach ($this->memberships as $membership) {
|
||||
if ($membership->getTeam() === $team) {
|
||||
return $membership->isTeamlead();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function canSeeAllData(): bool
|
||||
@@ -596,7 +726,7 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isTeamlead(): bool
|
||||
public function hasTeamleadRole(): bool
|
||||
{
|
||||
return $this->hasRole(static::ROLE_TEAMLEAD);
|
||||
}
|
||||
@@ -698,6 +828,11 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
|
||||
public function getLastLogin(): ?DateTime
|
||||
{
|
||||
if ($this->lastLogin !== null) {
|
||||
// make sure to use the users own timezone
|
||||
$this->lastLogin->setTimeZone(new \DateTimeZone($this->getTimezone()));
|
||||
}
|
||||
|
||||
return $this->lastLogin;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class TeamApiEditForm extends TeamEditForm
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
parent::buildForm($builder, $options);
|
||||
$builder->remove('users');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,6 @@ class TeamApiEditForm extends TeamEditForm
|
||||
parent::configureOptions($resolver);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'expand_users' => false,
|
||||
'csrf_protection' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Form\Type\TeamMemberType;
|
||||
use App\Form\Type\UserType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -34,38 +36,51 @@ class TeamEditForm extends AbstractType
|
||||
'attr' => [
|
||||
'autofocus' => 'autofocus'
|
||||
],
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Name of the team',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->addColor($builder);
|
||||
$builder
|
||||
->add('teamlead', UserType::class, [
|
||||
'label' => 'label.teamlead',
|
||||
'multiple' => false,
|
||||
'expanded' => false,
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'User ID for the teamlead',
|
||||
|
||||
$builder->add('members', CollectionType::class, [
|
||||
'entry_type' => TeamMemberType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
],
|
||||
'allow_add' => true,
|
||||
'by_reference' => false,
|
||||
'allow_delete' => true,
|
||||
'label' => 'label.user',
|
||||
'documentation' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'user' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'User ID',
|
||||
],
|
||||
'teamlead' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the user is a teamlead',
|
||||
],
|
||||
]
|
||||
],
|
||||
])
|
||||
->add('users', UserType::class, [
|
||||
'multiple' => true,
|
||||
'expanded' => $options['expand_users'],
|
||||
'by_reference' => false,
|
||||
'documentation' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'integer', 'description' => 'User IDs'],
|
||||
'title' => 'Team member',
|
||||
'description' => 'Array of team member IDs',
|
||||
],
|
||||
// make sure that disabled users show up in the result list
|
||||
'include_users' => (null !== $team && $team->getUsers()->count() > 0 ? $team->getUsers()->toArray() : [])
|
||||
])
|
||||
;
|
||||
'description' => 'All team members',
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->add('users', UserType::class, [
|
||||
'label' => 'add_user.label',
|
||||
'help' => 'team.add_user.help',
|
||||
'mapped' => false,
|
||||
'multiple' => false,
|
||||
'expanded' => false,
|
||||
'required' => false,
|
||||
'ignore_users' => $team !== null ? $team->getUsers() : []
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +92,6 @@ class TeamEditForm extends AbstractType
|
||||
'data_class' => Team::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'expand_users' => true,
|
||||
'csrf_token_id' => 'admin_team_edit',
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.teamUpdate'
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\TeamMemberType;
|
||||
use App\Form\Type\TeamType;
|
||||
use App\Form\Type\UserType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -24,7 +24,7 @@ class TimesheetMultiUserEditForm extends TimesheetAdminEditForm
|
||||
|
||||
parent::buildForm($builder, $options);
|
||||
|
||||
$builder->add('users', TeamMemberType::class, [
|
||||
$builder->add('users', UserType::class, [
|
||||
'multiple' => true,
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
@@ -85,9 +85,9 @@ abstract class AbstractToolbarForm extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTeamsChoice(FormBuilderInterface $builder)
|
||||
protected function addTeamsChoice(FormBuilderInterface $builder, string $field = 'teams')
|
||||
{
|
||||
$builder->add('teams', TeamType::class, [
|
||||
$builder->add($field, TeamType::class, [
|
||||
'documentation' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'integer', 'description' => 'Team ID'],
|
||||
|
||||
@@ -25,6 +25,7 @@ class UserToolbarForm extends AbstractToolbarForm
|
||||
{
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addUserRoleChoice($builder);
|
||||
$this->addTeamsChoice($builder, 'searchTeams');
|
||||
$this->addVisibilityChoice($builder, 'label.active');
|
||||
$this->addPageSizeChoice($builder);
|
||||
$this->addHiddenPagination($builder);
|
||||
|
||||
@@ -9,60 +9,38 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use App\Entity\TeamMember;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Select a user that
|
||||
*/
|
||||
class TeamMemberType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('user', UserType::class, [
|
||||
'selectpicker' => false,
|
||||
]);
|
||||
|
||||
$builder->add('teamlead', YesNoType::class, [
|
||||
'label' => 'label.teamlead'
|
||||
]);
|
||||
}
|
||||
|
||||
public function getBlockPrefix()
|
||||
{
|
||||
return 'team_member';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'class' => User::class,
|
||||
'data_class' => TeamMember::class,
|
||||
'label' => 'label.user',
|
||||
'choice_label' => function (User $user) {
|
||||
return $user->getDisplayName();
|
||||
},
|
||||
'compound' => true,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
return function (UserRepository $repo) use ($options) {
|
||||
$qb = $repo->createQueryBuilder('u');
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('u.enabled', ':enabled'))
|
||||
->setParameter('enabled', true, \PDO::PARAM_BOOL)
|
||||
->orderBy('u.username', 'ASC');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $options['user'];
|
||||
|
||||
if (null !== $user && $user->hasTeamAssignment() && !$user->canSeeAllData()) {
|
||||
$qb
|
||||
->leftJoin('u.teams', 'teams')
|
||||
->leftJoin('teams.users', 'users')
|
||||
->andWhere($qb->expr()->isMemberOf(':teams', 'u.teams'))
|
||||
->setParameter('teams', $user->getTeams());
|
||||
}
|
||||
|
||||
return $qb;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getParent()
|
||||
{
|
||||
return EntityType::class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class TeamType extends AbstractType
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
if (!$options['teamlead_only']) {
|
||||
$query->setTeams($user->getTeams()->toArray());
|
||||
$query->setTeams($user->getTeams());
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
|
||||
@@ -37,6 +37,8 @@ class UserType extends AbstractType
|
||||
'choice_translation_domain' => false,
|
||||
// whether disabled users should be included in the result list
|
||||
'include_disabled' => false,
|
||||
// an array of users that should not be included in the result list
|
||||
'ignore_users' => [],
|
||||
// an array of users, which will always be included in the result list
|
||||
// why? if the base entity could include disabled users, which should not be hidden in/removed from the list
|
||||
// eg. when editing a team that has disabled users, these users would be removed silently
|
||||
@@ -57,6 +59,10 @@ class UserType extends AbstractType
|
||||
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
|
||||
}
|
||||
|
||||
foreach ($options['ignore_users'] as $userToIgnore) {
|
||||
$query->addUserToIgnore($userToIgnore);
|
||||
}
|
||||
|
||||
if (!empty($options['include_users'])) {
|
||||
$query->setUsersAlwaysIncluded($options['include_users']);
|
||||
}
|
||||
|
||||
46
src/Migrations/Version20210802174318.php
Normal file
46
src/Migrations/Version20210802174318.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 1.15
|
||||
*/
|
||||
final class Version20210802174318 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds a new column to the team join table to support multiple teamleads';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$teamMember = $schema->getTable('kimai2_users_teams');
|
||||
$teamMember->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||
$teamMember->addColumn('teamlead', 'boolean', ['notnull' => true, 'default' => false]);
|
||||
$teamMember->dropPrimaryKey();
|
||||
$teamMember->setPrimaryKey(['id']);
|
||||
$teamMember->addUniqueIndex(['user_id', 'team_id'], 'UNIQ_B5E92CF8A76ED395296CD8AE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$teamMember = $schema->getTable('kimai2_users_teams');
|
||||
$teamMember->dropIndex('UNIQ_B5E92CF8A76ED395296CD8AE');
|
||||
$teamMember->dropPrimaryKey();
|
||||
$teamMember->dropColumn('teamlead');
|
||||
$teamMember->dropColumn('id');
|
||||
$teamMember->setPrimaryKey(['user_id', 'team_id']);
|
||||
}
|
||||
}
|
||||
52
src/Migrations/Version20210802174319.php
Normal file
52
src/Migrations/Version20210802174319.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 1.15
|
||||
*/
|
||||
final class Version20210802174319 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Migrate teamleads to join table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$fetch = $this->connection->prepare('SELECT id, teamlead_id FROM kimai2_teams');
|
||||
|
||||
foreach ($fetch->executeQuery()->iterateAssociative() as $row) {
|
||||
$this->addSql('UPDATE kimai2_users_teams SET teamlead = 1 WHERE user_id = ? AND team_id = ?', [$row['teamlead_id'], $row['id']]);
|
||||
}
|
||||
|
||||
$fetch->free();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$fetch = $this->connection->prepare('SELECT user_id, team_id, teamlead FROM kimai2_users_teams WHERE teamlead = 1');
|
||||
|
||||
foreach ($fetch->executeQuery()->iterateAssociative() as $row) {
|
||||
$this->addSql('UPDATE kimai2_teams SET teamlead_id = ? where id = ?', [$row['user_id'], $row['team_id']]);
|
||||
}
|
||||
|
||||
$fetch->free();
|
||||
|
||||
$teams = $schema->getTable('kimai2_teams');
|
||||
$teams->getColumn('teamlead_id')->setNotnull(true);
|
||||
$teams->addForeignKeyConstraint('kimai2_users', ['teamlead_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_3BEDDC7F8F7DE5D7');
|
||||
}
|
||||
}
|
||||
39
src/Migrations/Version20210802174320.php
Normal file
39
src/Migrations/Version20210802174320.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 1.15
|
||||
*/
|
||||
final class Version20210802174320 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remove teamlead id from teams table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$teams = $schema->getTable('kimai2_teams');
|
||||
$teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7');
|
||||
$teams->dropColumn('teamlead_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$teams = $schema->getTable('kimai2_teams');
|
||||
$teams->addColumn('teamlead_id', 'integer', ['length' => 11, 'notnull' => false]);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class ActivityRepository extends EntityRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
$loader = new ActivityLoader($this->getEntityManager());
|
||||
$loader = new ActivityLoader($this->getEntityManager(), true);
|
||||
$loader->loadResults([$activity]);
|
||||
|
||||
return $activity;
|
||||
@@ -75,7 +75,7 @@ class ActivityRepository extends EntityRepository
|
||||
|
||||
$activities = $qb->getQuery()->getResult();
|
||||
|
||||
$loader = new ActivityLoader($qb->getEntityManager());
|
||||
$loader = new ActivityLoader($qb->getEntityManager(), true);
|
||||
$loader->loadResults($activities);
|
||||
|
||||
return $activities;
|
||||
@@ -173,7 +173,7 @@ class ActivityRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
$teams = array_merge($teams, $user->getTeams());
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
|
||||
@@ -47,7 +47,7 @@ class CustomerRepository extends EntityRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
$loader = new CustomerLoader($this->getEntityManager());
|
||||
$loader = new CustomerLoader($this->getEntityManager(), true);
|
||||
$loader->loadResults([$customer]);
|
||||
|
||||
return $customer;
|
||||
@@ -168,7 +168,7 @@ class CustomerRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
$teams = array_merge($teams, $user->getTeams());
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
|
||||
@@ -127,7 +127,7 @@ class InvoiceRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
$teams = array_merge($teams, $user->getTeams());
|
||||
}
|
||||
|
||||
$qb->leftJoin('i.customer', 'c');
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
namespace App\Repository\Loader;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -18,14 +20,13 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class ActivityIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ final class ActivityIdLoader implements LoaderInterface
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var Activity[] $activities */
|
||||
$activities = $qb->select('PARTIAL a.{id}', 'project')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.project', 'project')
|
||||
@@ -58,51 +60,76 @@ final class ActivityIdLoader implements LoaderInterface
|
||||
|
||||
// global activities don't have projects
|
||||
if (!empty($activities)) {
|
||||
$projectIds = array_map(function (Activity $activity) {
|
||||
$projectIds = array_unique(array_map(function (Activity $activity) {
|
||||
if (null === $activity->getProject()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $activity->getProject()->getId();
|
||||
}, $activities);
|
||||
}, $activities));
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'customer')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('p.id', $projectIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'teams', 'teamlead')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.project', 'project')
|
||||
->leftJoin('project.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('a.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.project', 'project')
|
||||
$qb->select('PARTIAL project.{id}', 'customer')
|
||||
->from(Project::class, 'project')
|
||||
->leftJoin('project.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('project.id', $projectIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$customerIds = array_unique(array_map(function (Activity $activity) {
|
||||
if (null === $activity->getProject()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $activity->getProject()->getCustomer()->getId();
|
||||
}, $activities));
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL project.{id}', 'teams')
|
||||
->from(Project::class, 'project')
|
||||
->leftJoin('project.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('project.id', $projectIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL customer.{id}', 'teams')
|
||||
->from(Customer::class, 'customer')
|
||||
->leftJoin('customer.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('a.id', $ids))
|
||||
->andWhere($qb->expr()->in('customer.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'teams', 'teamlead')
|
||||
$qb->select('PARTIAL a.{id}', 'teams')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('a.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
|
||||
if ($this->fullyHydrated) {
|
||||
$teamIds = [];
|
||||
foreach ($activities as $activity) {
|
||||
foreach ($activity->getTeams() as $team) {
|
||||
$teamIds[] = $team->getId();
|
||||
}
|
||||
}
|
||||
$teamIds = array_unique($teamIds);
|
||||
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class ActivityLoader implements LoaderInterface
|
||||
{
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ final class ActivityLoader implements LoaderInterface
|
||||
return $activity->getId();
|
||||
}, $activities);
|
||||
|
||||
$loader = new ActivityIdLoader($this->entityManager);
|
||||
$loader = new ActivityIdLoader($this->entityManager, $this->fullyHydrated);
|
||||
$loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Repository\Loader;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Team;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -17,14 +18,13 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class CustomerIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,8 @@ final class CustomerIdLoader implements LoaderInterface
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'meta')
|
||||
/** @var Customer[] $customers */
|
||||
$customers = $qb->select('PARTIAL c.{id}', 'meta')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('c.id', $ids))
|
||||
@@ -47,12 +48,34 @@ final class CustomerIdLoader implements LoaderInterface
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'teams', 'teamlead')
|
||||
$qb->select('PARTIAL c.{id}', 'teams')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('c.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
|
||||
if ($this->fullyHydrated) {
|
||||
$teamIds = [];
|
||||
foreach ($customers as $customer) {
|
||||
foreach ($customer->getTeams() as $team) {
|
||||
$teamIds[] = $team->getId();
|
||||
}
|
||||
}
|
||||
$teamIds = array_unique($teamIds);
|
||||
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class CustomerLoader implements LoaderInterface
|
||||
{
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ final class CustomerLoader implements LoaderInterface
|
||||
return $customer->getId();
|
||||
}, $customers);
|
||||
|
||||
$loader = new CustomerIdLoader($this->entityManager);
|
||||
$loader = new CustomerIdLoader($this->entityManager, $this->fullyHydrated);
|
||||
$loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
namespace App\Repository\Loader;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -17,14 +19,13 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class ProjectIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,38 +40,63 @@ final class ProjectIdLoader implements LoaderInterface
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'customer')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('p.id', $ids))
|
||||
/** @var Project[] $projects */
|
||||
$projects = $qb->select('PARTIAL project.{id}', 'customer')
|
||||
->from(Project::class, 'project')
|
||||
->leftJoin('project.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('project.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'meta')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('p.id', $ids))
|
||||
$qb->select('PARTIAL project.{id}', 'meta')
|
||||
->from(Project::class, 'project')
|
||||
->leftJoin('project.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('project.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'teams', 'teamlead')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('p.id', $ids))
|
||||
$qb->select('PARTIAL project.{id}', 'teams')
|
||||
->from(Project::class, 'project')
|
||||
->leftJoin('project.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('project.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$customerIds = array_unique(array_map(function (Project $project) {
|
||||
return $project->getCustomer()->getId();
|
||||
}, $projects));
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.customer', 'customer')
|
||||
$qb->select('PARTIAL customer.{id}', 'teams')
|
||||
->from(Customer::class, 'customer')
|
||||
->leftJoin('customer.teams', 'teams')
|
||||
->leftJoin('teams.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('p.id', $ids))
|
||||
->andWhere($qb->expr()->in('customer.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
|
||||
if ($this->fullyHydrated) {
|
||||
$teamIds = [];
|
||||
foreach ($projects as $project) {
|
||||
foreach ($project->getTeams() as $team) {
|
||||
$teamIds[] = $team->getId();
|
||||
}
|
||||
}
|
||||
$teamIds = array_unique($teamIds);
|
||||
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class ProjectLoader implements LoaderInterface
|
||||
{
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ final class ProjectLoader implements LoaderInterface
|
||||
return $project->getId();
|
||||
}, $projects);
|
||||
|
||||
$loader = new ProjectIdLoader($this->entityManager);
|
||||
$loader = new ProjectIdLoader($this->entityManager, $this->fullyHydrated);
|
||||
$loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class TeamIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
@@ -28,29 +25,30 @@ final class TeamIdLoader implements LoaderInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ids
|
||||
* @param int[] $teamIds
|
||||
*/
|
||||
public function loadResults(array $ids): void
|
||||
public function loadResults(array $teamIds): void
|
||||
{
|
||||
if (empty($ids)) {
|
||||
if (empty($teamIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL t.{id}', 'users')
|
||||
->from(Team::class, 't')
|
||||
->leftJoin('t.users', 'users')
|
||||
->andWhere($qb->expr()->in('t.id', $ids))
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL t.{id}', 'projects')
|
||||
->from(Team::class, 't')
|
||||
->leftJoin('t.projects', 'projects')
|
||||
->andWhere($qb->expr()->in('t.id', $ids))
|
||||
$qb->select('PARTIAL team.{id}', 'projects')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.projects', 'projects')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
@@ -20,19 +20,13 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class TimesheetIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $fullyHydrated = false;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $hydrateFullTree = false)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $hydrateFullTree;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,12 +15,12 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class TimesheetLoader implements LoaderInterface
|
||||
{
|
||||
private $entityManager;
|
||||
private $hydrateFullTree;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $hydrateFullTree = false)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->hydrateFullTree = $hydrateFullTree;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ final class TimesheetLoader implements LoaderInterface
|
||||
return $timesheet->getId();
|
||||
}, $timesheets);
|
||||
|
||||
$loader = new TimesheetIdLoader($this->entityManager, $this->hydrateFullTree);
|
||||
$loader = new TimesheetIdLoader($this->entityManager, $this->fullyHydrated);
|
||||
$loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,13 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
*/
|
||||
final class UserIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,44 +40,44 @@ final class UserIdLoader implements LoaderInterface
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var User[] $users */
|
||||
$users = $qb->select('PARTIAL u.{id}', 'teams')
|
||||
->from(User::class, 'u')
|
||||
->leftJoin('u.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('u.id', $ids))
|
||||
$users = $qb->select('PARTIAL user.{id}', 'memberships', 'team')
|
||||
->from(User::class, 'user')
|
||||
->leftJoin('user.memberships', 'memberships')
|
||||
->leftJoin('memberships.team', 'team')
|
||||
->andWhere($qb->expr()->in('user.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL u.{id}', 'preferences')
|
||||
->from(User::class, 'u')
|
||||
->leftJoin('u.preferences', 'preferences')
|
||||
->andWhere($qb->expr()->in('u.id', $ids))
|
||||
$qb->select('PARTIAL user.{id}', 'preferences')
|
||||
->from(User::class, 'user')
|
||||
->leftJoin('user.preferences', 'preferences')
|
||||
->andWhere($qb->expr()->in('user.id', $ids))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$teamIds = [];
|
||||
foreach ($users as $user) {
|
||||
foreach ($user->getTeams() as $team) {
|
||||
$teamIds[] = $team->getId();
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
|
||||
if ($this->fullyHydrated) {
|
||||
$teamIds = [];
|
||||
foreach ($users as $user) {
|
||||
foreach ($user->getTeams() as $team) {
|
||||
$teamIds[] = $team->getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
$teamIds = array_unique($teamIds);
|
||||
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL t.{id}', 'teamlead')
|
||||
->from(Team::class, 't')
|
||||
->leftJoin('t.teamlead', 'teamlead')
|
||||
->andWhere($qb->expr()->in('t.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL t.{id}', 'users')
|
||||
->from(Team::class, 't')
|
||||
->leftJoin('t.users', 'users')
|
||||
->andWhere($qb->expr()->in('t.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var Team[] $teams */
|
||||
$teams = $qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
final class UserLoader implements LoaderInterface
|
||||
{
|
||||
private $entityManager;
|
||||
private $fullyHydrated;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->fullyHydrated = $fullyHydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ final class UserLoader implements LoaderInterface
|
||||
return $user->getId();
|
||||
}, $users);
|
||||
|
||||
$loader = new UserIdLoader($this->entityManager);
|
||||
$loader = new UserIdLoader($this->entityManager, $this->fullyHydrated);
|
||||
$loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class ProjectRepository extends EntityRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
$loader = new ProjectLoader($this->getEntityManager());
|
||||
$loader = new ProjectLoader($this->getEntityManager(), true);
|
||||
$loader->loadResults([$project]);
|
||||
|
||||
return $project;
|
||||
@@ -68,7 +68,7 @@ class ProjectRepository extends EntityRepository
|
||||
|
||||
$projects = $qb->getQuery()->getResult();
|
||||
|
||||
$loader = new ProjectLoader($qb->getEntityManager());
|
||||
$loader = new ProjectLoader($qb->getEntityManager(), true);
|
||||
$loader->loadResults($projects);
|
||||
|
||||
return $projects;
|
||||
@@ -195,7 +195,7 @@ class ProjectRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
$teams = array_merge($teams, $user->getTeams());
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Entity\User;
|
||||
|
||||
class TeamQuery extends BaseQuery
|
||||
{
|
||||
public const TEAM_ORDER_ALLOWED = ['id', 'name', 'teamlead'];
|
||||
public const TEAM_ORDER_ALLOWED = ['id', 'name'];
|
||||
|
||||
/**
|
||||
* @var User[]
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Repository\Query;
|
||||
|
||||
use App\Entity\Team;
|
||||
|
||||
/**
|
||||
* Can be used for advanced queries with the: UserRepository
|
||||
*/
|
||||
@@ -22,6 +24,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
|
||||
* @var string|null
|
||||
*/
|
||||
private $role;
|
||||
private $searchTeams = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -30,6 +33,22 @@ class UserQuery extends BaseQuery implements VisibilityInterface
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Team[]
|
||||
*/
|
||||
public function getSearchTeams(): array
|
||||
{
|
||||
return $this->searchTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Team[] $searchTeams
|
||||
*/
|
||||
public function setSearchTeams(array $searchTeams): void
|
||||
{
|
||||
$this->searchTeams = $searchTeams;
|
||||
}
|
||||
|
||||
public function getRole(): ?string
|
||||
{
|
||||
return $this->role;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Loader\TeamLoader;
|
||||
@@ -26,6 +27,19 @@ use Pagerfanta\Pagerfanta;
|
||||
*/
|
||||
class TeamRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @return Team[]
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
$result = parent::findAll();
|
||||
|
||||
$loader = new TeamLoader($this->getEntityManager());
|
||||
$loader->loadResults($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function find($id, $lockMode = null, $lockVersion = null)
|
||||
{
|
||||
/** @var Team|null $team */
|
||||
@@ -52,6 +66,16 @@ class TeamRepository extends EntityRepository
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TeamMember $member
|
||||
* @throws ORMException
|
||||
*/
|
||||
public function removeTeamMember(TeamMember $member)
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->remove($member);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Team $team
|
||||
* @throws ORMException
|
||||
@@ -131,21 +155,17 @@ class TeamRepository extends EntityRepository
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
switch ($orderBy) {
|
||||
case 'teamlead':
|
||||
$qb->leftJoin('t.teamlead', 'lead');
|
||||
$orderBy = 'lead.username';
|
||||
break;
|
||||
default:
|
||||
$orderBy = 't.' . $orderBy;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($query->hasUsers()) {
|
||||
$qb->leftJoin('t.members', 'qMembers');
|
||||
$qb->orWhere(
|
||||
$qb->expr()->in('t.teamlead', ':user'),
|
||||
$qb->expr()->isMemberOf(':user', 't.users')
|
||||
)
|
||||
->setParameter('user', $query->getUsers());
|
||||
$qb->expr()->in('qMembers.user', ':user')
|
||||
);
|
||||
$qb->setParameter('user', $query->getUsers());
|
||||
}
|
||||
|
||||
$qb->addOrderBy($orderBy, $query->getOrder());
|
||||
@@ -181,19 +201,31 @@ class TeamRepository extends EntityRepository
|
||||
return;
|
||||
}
|
||||
|
||||
// this is an OR on purpose because we either query only for teams where the user is teamlead
|
||||
// OR we query for all teams where the user is a member - in later case $teams is not empty
|
||||
$or = $qb->expr()->orX();
|
||||
|
||||
// this query should limit to teams where the user is a teamlead (eg. in dropdowns or listing page)
|
||||
if (null !== $user) {
|
||||
$or->add($qb->expr()->eq('t.teamlead', ':id'));
|
||||
$qb->leftJoin('t.members', 'members');
|
||||
$or->add(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('members.user', ':id'),
|
||||
$qb->expr()->eq('members.teamlead', true),
|
||||
)
|
||||
);
|
||||
$qb->setParameter('id', $user);
|
||||
}
|
||||
|
||||
// this is primarily used, if we want to query for teams of the current user
|
||||
// and not 'teamlead_only' as used in the teams form type
|
||||
if (!empty($teams)) {
|
||||
$ids = [];
|
||||
foreach ($teams as $team) {
|
||||
$ids[] = $team->getId();
|
||||
}
|
||||
$or->add($qb->expr()->in('t.id', $ids));
|
||||
$or->add($qb->expr()->in('t.id', ':teamIds'));
|
||||
$qb->setParameter('teamIds', array_unique($ids));
|
||||
}
|
||||
|
||||
$qb->andWhere($or);
|
||||
|
||||
@@ -765,7 +765,7 @@ class TimesheetRepository extends EntityRepository
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
$teams = array_merge($teams, $user->getTeams());
|
||||
}
|
||||
|
||||
if (empty($teams)) {
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Repository;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\Role;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Loader\UserIdLoader;
|
||||
use App\Repository\Loader\UserLoader;
|
||||
use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
@@ -59,11 +59,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
public function getUserById($id): ?User
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->findOneBy(['id' => $id]);
|
||||
$user = $this->find($id);
|
||||
|
||||
if ($user !== null) {
|
||||
$loader = new UserIdLoader($this->getEntityManager());
|
||||
$loader->loadResults([$user->getId()]);
|
||||
$loader = new UserLoader($this->getEntityManager(), true);
|
||||
$loader->loadResults([$user]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
@@ -82,11 +82,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
return parent::findOneBy($criteria, $orderBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|bool $enabled
|
||||
* @return int
|
||||
*/
|
||||
public function countUser($enabled = null)
|
||||
public function countUser(?bool $enabled = null): int
|
||||
{
|
||||
if (null !== $enabled) {
|
||||
return $this->count(['enabled' => (bool) $enabled]);
|
||||
@@ -135,8 +131,8 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
->getOneOrNullResult();
|
||||
|
||||
if ($user !== null) {
|
||||
$loader = new UserIdLoader($this->getEntityManager());
|
||||
$loader->loadResults([$user->getId()]);
|
||||
$loader = new UserLoader($this->getEntityManager(), true);
|
||||
$loader->loadResults([$user]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
@@ -178,6 +174,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param QueryBuilder $qb
|
||||
* @param User|null $user
|
||||
* @param Team[] $teams
|
||||
*/
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
{
|
||||
// make sure that all queries without a user see all user
|
||||
@@ -193,23 +194,38 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
$or = $qb->expr()->orX();
|
||||
|
||||
// if no explicit team was requested and the user is part of some teams
|
||||
// then find all members of teams where he is teamlead
|
||||
if (null !== $user && $user->hasTeamAssignment()) {
|
||||
$qb->leftJoin('u.teams', 't');
|
||||
$or->add($qb->expr()->eq('t.teamlead', ':teamlead'));
|
||||
$qb->setParameter('teamlead', $user);
|
||||
// then find all members of his teams (where he is teamlead)
|
||||
if (null !== $user && $user->isTeamlead()) {
|
||||
$userIds = [];
|
||||
foreach ($user->getTeams() as $team) {
|
||||
if ($team->isTeamlead($user)) {
|
||||
foreach ($team->getUsers() as $teamMember) {
|
||||
$userIds[] = $teamMember->getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
$userIds = array_unique($userIds);
|
||||
$qb->setParameter('teamMember', $userIds);
|
||||
$or->add($qb->expr()->in('u.id', ':teamMember'));
|
||||
}
|
||||
|
||||
// if teams where requested, then select all team members
|
||||
if (\count($teams) > 0) {
|
||||
$or->add($qb->expr()->isMemberOf(':teams', 'u.teams'));
|
||||
$qb->setParameter('teams', $teams);
|
||||
$userIds = [];
|
||||
foreach ($teams as $team) {
|
||||
foreach ($team->getUsers() as $user) {
|
||||
$userIds[] = $user->getId();
|
||||
}
|
||||
}
|
||||
$userIds = array_unique($userIds);
|
||||
$qb->setParameter('userIds', $userIds);
|
||||
$or->add($qb->expr()->in('u.id', ':userIds'));
|
||||
}
|
||||
|
||||
// and make sure, that the user himself is always returned
|
||||
if (null !== $user) {
|
||||
$or->add($qb->expr()->eq('u.id', ':user'));
|
||||
$qb->setParameter('user', $user);
|
||||
$or->add($qb->expr()->eq('u.id', ':self'));
|
||||
$qb->setParameter('self', $user);
|
||||
}
|
||||
|
||||
if ($or->count() > 0) {
|
||||
@@ -251,6 +267,17 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
|
||||
|
||||
if (\count($query->getSearchTeams()) > 0) {
|
||||
$userIds = [];
|
||||
foreach ($query->getSearchTeams() as $team) {
|
||||
foreach ($team->getUsers() as $user) {
|
||||
$userIds[] = $user->getId();
|
||||
}
|
||||
}
|
||||
$qb->andWhere($qb->expr()->in('u.id', ':searchTeams'));
|
||||
$qb->setParameter('searchTeams', array_unique($userIds));
|
||||
}
|
||||
|
||||
if ($query->isShowVisible()) {
|
||||
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
|
||||
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
|
||||
|
||||
33
src/Validator/Constraints/Team.php
Normal file
33
src/Validator/Constraints/Team.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Doctrine\Common\Annotations\Annotation\Target;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
|
||||
*/
|
||||
class Team extends Constraint
|
||||
{
|
||||
public const MISSING_TEAMLEAD = 'kimai-team-001';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::MISSING_TEAMLEAD => 'At least one team leader must be assigned to the team.',
|
||||
];
|
||||
|
||||
public $message = 'The team has invalid settings.';
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
41
src/Validator/Constraints/TeamValidator.php
Normal file
41
src/Validator/Constraints/TeamValidator.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity\Team as TeamEntity;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class TeamValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @param TeamEntity $value
|
||||
* @param Constraint $constraint
|
||||
*/
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!($constraint instanceof Team)) {
|
||||
throw new UnexpectedTypeException($constraint, Team::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof TeamEntity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$value->hasTeamleads()) {
|
||||
$this->context->buildViolation(Team::getErrorName(Team::MISSING_TEAMLEAD))
|
||||
->atPath('teamleads')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(Team::MISSING_TEAMLEAD)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,15 @@ final class TeamVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($attribute) {
|
||||
case 'edit':
|
||||
case 'delete':
|
||||
// changing existing teams should be limited to admins and teamleads
|
||||
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->permissionManager->hasRolePermission($user, $attribute . '_team');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ final class UserVoter extends Voter
|
||||
'preferences',
|
||||
'api-token',
|
||||
'hourly-rate',
|
||||
// teams_own_profile could be merged with view_team_member
|
||||
'view_team_member',
|
||||
];
|
||||
|
||||
private $permissionManager;
|
||||
@@ -70,6 +72,14 @@ final class UserVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attribute === 'view_team_member') {
|
||||
if ($subject->getId() !== $user->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permissionManager->hasRolePermission($user, 'view_team_member');
|
||||
}
|
||||
|
||||
if ($attribute === 'delete') {
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
return false;
|
||||
|
||||
@@ -50,7 +50,7 @@ class UserTeamProjects extends SimpleWidget implements AuthorizedWidget, UserWid
|
||||
$now = new \DateTime('now', new \DateTimeZone($user->getTimezone()));
|
||||
|
||||
$loader = new TeamLoader($this->entityManager);
|
||||
$loader->loadResults($user->getTeams()->toArray());
|
||||
$loader->loadResults($user->getTeams());
|
||||
|
||||
$teamProjects = [];
|
||||
$projects = [];
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
{% for type, messages in app.session.flashbag.all %}
|
||||
{% for message in messages %}
|
||||
{% if type == 'error' %}
|
||||
ALERT.error('{{ message|trans({}, domain) }}');
|
||||
ALERT.error('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'warning' %}
|
||||
ALERT.warning('{{ message|trans({}, domain) }}');
|
||||
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'success' %}
|
||||
ALERT.success('{{ message|trans({}, domain) }}');
|
||||
ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% else %}
|
||||
ALERT.info('{{ message|trans({}, domain) }}');
|
||||
ALERT.info('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
@@ -123,7 +123,7 @@
|
||||
{% set hasActiveRecords = active_timesheets is not empty %}
|
||||
{% if not hasActiveRecords %}
|
||||
{# fake entry, because at least one html template node is needed #}
|
||||
{% set active_timesheets = [{'id': '000', 'begin': create_date('now'), 'activity': {'name': ''}, 'project': {'name': '', 'customer': {'name': ''}}}] %}
|
||||
{% set active_timesheets = [{'id': '000', 'begin': null, 'activity': {'name': ''}, 'project': {'name': '', 'customer': {'name': ''}}}] %}
|
||||
{% endif %}
|
||||
{% set active_limit = kimai_config.timesheetActiveEntriesHardLimit %}
|
||||
<li class="messages-menu {% if active_limit > 1 and hasActiveRecords %}dropdown{% endif %}" data-api="{{ path('active_timesheet') }}" data-href="{{ path('stop_timesheet', {'id' : '000'}) }}" data-icon="{{ 'stop-small'|icon }}" style="{% if not hasActiveRecords %}display:none{% endif %}">
|
||||
@@ -132,7 +132,7 @@
|
||||
<div class="ddt-small ticktac-single ticktac-running">
|
||||
<div class="ticktac-stop">
|
||||
<a data-replacer="url" class="api-link" href="{{ path('stop_timesheet', {'id' : entry.id}) }}" data-event="kimai.timesheetStop kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.stop.error" data-msg-success="timesheet.stop.success">
|
||||
<i class="{{ 'stop-small'|icon }} fa-2x"></i><span data-replacer="duration" data-title="true" data-since="{{ entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</span>
|
||||
<i class="{{ 'stop-small'|icon }} fa-2x"></i><span data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@
|
||||
<h4>
|
||||
<span data-replacer="activity">{{ entry.activity.name }}</span>
|
||||
<small>
|
||||
<span data-replacer="duration" data-title="true" data-since="{{ entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</span>
|
||||
<span data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</span>
|
||||
</small>
|
||||
</h4>
|
||||
<p><span data-replacer="project">{{ entry.project.name }}</span> (<span data-replacer="customer">{{ entry.project.customer.name }}</span>)</p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{% if form_theme is defined %}
|
||||
{% form_theme form form_theme %}
|
||||
{% endif %}
|
||||
{% set _back = back is defined and back is not same as (false) ? back : false %}
|
||||
{% set _reset = reset is defined and reset is same as (false) ? false : true %}
|
||||
<div class="box box-primary">
|
||||
{% block form_before %}{% endblock %}
|
||||
{{ form_start(form) }}
|
||||
@@ -19,10 +21,10 @@
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
{% if back|default(false) %}
|
||||
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
|
||||
{% if _back is not same as (false) %}
|
||||
<a href="{{ _back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
|
||||
{% endif %}
|
||||
{% if reset|default(true) %}
|
||||
{% if _reset is not same as (false) %}
|
||||
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,31 @@
|
||||
{{ parent() }}
|
||||
{% endblock form_label %}
|
||||
|
||||
{% block _team_edit_form_members_entry_user_widget %}
|
||||
{# this will convert the select box into a hidden field, which are exchangable from an HTML perspective #}
|
||||
{%- set type = 'hidden' -%}
|
||||
{{ block('form_widget_simple') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block team_member_widget %}
|
||||
<div class="row">
|
||||
<div class="col-xs-7">
|
||||
<div class="checkbox">
|
||||
<button class="btn btn-default btn-xs" onclick="jQuery(this).parent().parent().parent().remove();return false;"><i class="{{ 'trash'|icon }}"></i></button>
|
||||
{% if form.vars.data is not null and form.vars.data.user is not null %}
|
||||
{{ form.vars.data.user.displayName }}
|
||||
{% else %}
|
||||
__USERNAME__
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ form_widget(form.user) }}
|
||||
</div>
|
||||
<div class="col-xs-5">
|
||||
{{ form_widget(form.teamlead) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock team_member_widget %}
|
||||
|
||||
{% block daterange_widget %}
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">
|
||||
|
||||
@@ -154,16 +154,20 @@
|
||||
|
||||
{# for @internal use only #}
|
||||
{% macro label_color_dot(type, isVisible, name, url, color) %}
|
||||
{% apply spaceless %}
|
||||
<span class="label-{{ type }}{{ isVisible ? '' : ' label-invisible' }}">
|
||||
<span class="name">
|
||||
<i class="dot {{ 'dot'|icon }} fa-fw"{% if color is not empty %} style="color:{{ color }}"{% endif %}></i>
|
||||
<span class="name-inner">
|
||||
{%- if url is not empty -%}
|
||||
<a href="{{ url }}">{{ name }}</a>
|
||||
{%- else -%}
|
||||
{{ name }}
|
||||
{%- endif -%}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endapply %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro color_dot(color, tooltip) %}
|
||||
@@ -511,23 +515,25 @@
|
||||
{% endif %}
|
||||
<tbody>
|
||||
{% for team in teams|sort((a, b) => a.name <=> b.name) %}
|
||||
{% set users = team.users|filter(u => u.enabled) %}
|
||||
{% set userTeamCount = users|length %}
|
||||
{% set members = team.members|filter(m => m.user.enabled) %}
|
||||
{% set userTeamCount = members|length %}
|
||||
<tr{% if is_granted('edit', team) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_team_member', {'id': team.id}) }}"{% endif %}>
|
||||
<td>
|
||||
{{ _self.label_team(team) }}
|
||||
{{ _self.label_color_dot('color', true, team.name, null, team.color|colorize(team.name)) }}
|
||||
</td>
|
||||
<td class="avatars">
|
||||
{{ _self.user_avatar(team.teamlead, ('label.teamlead'|trans ~ ': ' ~ team.teamlead.displayName), 'teamlead') }}
|
||||
{% set teamHiddenId = 'team_' ~ team.id ~ '_hiddenUser' ~ random() %}
|
||||
{% set counter = 0 %}
|
||||
{% for user in users %}
|
||||
{% if user != team.teamlead %}
|
||||
{% for member in members|sort((a, b) => a.teamlead < b.teamlead) %}
|
||||
{% set user = member.user %}
|
||||
{% if member.teamlead %}
|
||||
{{ _self.user_avatar(user, ('label.teamlead'|trans ~ ': ' ~ user.displayName), 'teamlead') }}
|
||||
{% else %}
|
||||
{{ _self.user_avatar(user) }}
|
||||
{% set counter = counter + 1 %}
|
||||
{% endif %}
|
||||
{% set counter = counter + 1 %}
|
||||
{% if userTeamCount > (collapseAt + 1) and counter == (collapseAt - 1) and loop.index != userTeamCount %}
|
||||
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - collapseAt)}) }}</a>
|
||||
<a href="#" onclick="$(this).parent().append($('#{{ teamHiddenId }}').contents());$('#{{ teamHiddenId }}').remove();$(this).remove();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - collapseAt)}) }}</a>
|
||||
<span class="hidden" id="{{ teamHiddenId }}">
|
||||
{% set counter = counter + 1 %}
|
||||
{% endif %}
|
||||
|
||||
@@ -8,25 +8,21 @@
|
||||
{% block main %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ include('default/_form.html.twig', {
|
||||
'title': team.name|default('create'|trans),
|
||||
'form': form,
|
||||
'back': path('admin_team')
|
||||
}) }}
|
||||
{% include 'team/member-form.html.twig' %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if not customerForm is null %}
|
||||
{{ include('default/_form.html.twig', {
|
||||
'title': 'teams.customer_access'|trans({}, 'teams'),
|
||||
'form': customerForm,
|
||||
}) }}
|
||||
{{ include('default/_form.html.twig', {
|
||||
'title': 'teams.customer_access'|trans({}, 'teams'),
|
||||
'form': customerForm,
|
||||
}) }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not projectForm is null %}
|
||||
{{ include('default/_form.html.twig', {
|
||||
'title': 'teams.project_access'|trans({}, 'teams'),
|
||||
'form': projectForm,
|
||||
}) }}
|
||||
{{ include('default/_form.html.twig', {
|
||||
'title': 'teams.project_access'|trans({}, 'teams'),
|
||||
'form': projectForm,
|
||||
}) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
{% block page_actions %}{{ actions.team(team, 'edit') }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
|
||||
'title': team.name|default('create'|trans),
|
||||
'form': form,
|
||||
'back': path('admin_team')
|
||||
}) }}
|
||||
{% include 'team/member-form.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
{% set columns = {
|
||||
'name': {'class': ''},
|
||||
'teamlead': {'class': ''},
|
||||
'teamlead': {'class': '', 'orderBy': false},
|
||||
'user': {'class': 'hidden-xs', 'orderBy': false},
|
||||
'actions': {'class': 'actions alwaysVisible'},
|
||||
} %}
|
||||
@@ -30,11 +30,17 @@
|
||||
{{ widgets.label_name(team.name, team.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'teamlead') }}">
|
||||
{{ widgets.label_user(team.teamlead) }}
|
||||
{% for member in team.members %}
|
||||
{% if member.teamlead %}
|
||||
{{ widgets.label_user(member.user) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
|
||||
{% for user in team.users %}
|
||||
{{ widgets.label_user(user) }}
|
||||
{% for member in team.members %}
|
||||
{% if not member.teamlead %}
|
||||
{{ widgets.label_user(member.user) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
|
||||
44
templates/team/member-form.html.twig
Normal file
44
templates/team/member-form.html.twig
Normal file
@@ -0,0 +1,44 @@
|
||||
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
|
||||
{% set formOptions = {
|
||||
'title': team.name|default('create'|trans),
|
||||
'form': form,
|
||||
'back': path('admin_team'),
|
||||
'reset': false
|
||||
} %}
|
||||
{% embed formEditTemplate with formOptions %}
|
||||
{% block form_body %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{{ form_row(form.name) }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{{ form_row(form.color) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ form_row(form.users) }}
|
||||
{{ form_row(form.members) }}
|
||||
{{ form_widget(form) }}
|
||||
{% endblock %}
|
||||
{% block form_after %}
|
||||
{% set blockPrefix = form.vars.id %}
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function () {
|
||||
jQuery('#{{ blockPrefix }}_users').change(function (e) {
|
||||
var select = jQuery(e.target);
|
||||
var selected = select.find(':selected');
|
||||
var username = selected.text();
|
||||
var userid = select.val();
|
||||
selected.remove();
|
||||
|
||||
var prototype = jQuery('#{{ blockPrefix }}_members');
|
||||
var counter = prototype.data('widget-counter') || prototype.children().length;
|
||||
var newWidget = prototype.attr('data-prototype');
|
||||
newWidget = newWidget.replace(/__name__/g, counter);
|
||||
newWidget = newWidget.replace(/__USERNAME__/g, username);
|
||||
prototype.data('widget-counter', ++counter);
|
||||
jQuery(newWidget).prependTo(prototype).find('input[type=hidden]').val(userid);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
@@ -48,7 +48,7 @@
|
||||
{% endembed %}
|
||||
{% endfor %}
|
||||
|
||||
{% if user.teams is not empty and (is_granted('teams', user) or (app.user == user and is_granted('view_team_member'))) %}
|
||||
{% if user.teams is not empty and (is_granted('teams', user) or is_granted('view_team_member', user)) %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'label.my_teams'|trans }}{% endblock %}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{% extends 'user/layout.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% block profile_content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
<div class="box box-{{ admin_lte_context.widget.type }} data_table">
|
||||
<div class="box-body no-padding">
|
||||
<div class="dataTables_wrapper form-inline dt-bootstrap">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<table class="table table-hover dataTable" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'label.team'|trans }}</th>
|
||||
<th>{{ 'label.teamlead'|trans }}</th>
|
||||
<th>{{ 'label.user'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for team in user.teams %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ team.name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ widgets.user_avatar(team.teamlead) }}
|
||||
{#
|
||||
{{ widgets.label(team.teamlead.displayName, 'success', 'label.teamlead'|trans) }}
|
||||
#}
|
||||
</td>
|
||||
<td class="avatars">
|
||||
{% for user in team.users %}
|
||||
{{ widgets.user_avatar(user) }}
|
||||
{#
|
||||
{{ widgets.label_user(user) }}
|
||||
#}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -235,6 +235,8 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
self::assertArrayHasKey('children', $result['errors']);
|
||||
$data = $result['errors']['children'];
|
||||
|
||||
$foundErrors = [];
|
||||
|
||||
foreach ($failedFields as $key => $value) {
|
||||
$messages = [];
|
||||
$fieldName = $value;
|
||||
@@ -245,17 +247,25 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
$messages = [$value];
|
||||
}
|
||||
}
|
||||
|
||||
while (stripos($fieldName, '.') !== false) {
|
||||
$parts = explode('.', $fieldName);
|
||||
$tmp = array_shift($parts);
|
||||
self::assertArrayHasKey($tmp, $data, sprintf('Could not find field "%s" in result', $tmp));
|
||||
$data = $data[$tmp];
|
||||
if (\count($data) === 1 && \array_key_exists('children', $data)) {
|
||||
$data = $data['children'];
|
||||
}
|
||||
$fieldName = implode('.', $parts);
|
||||
}
|
||||
|
||||
self::assertArrayHasKey($fieldName, $data, sprintf('Could not find validation error for field "%s" in list: %s', $fieldName, implode(', ', $failedFields)));
|
||||
self::assertArrayHasKey('errors', $data[$fieldName], sprintf('Field %s has no validation problem', $fieldName));
|
||||
foreach ($messages as $i => $message) {
|
||||
self::assertEquals($message, $data[$fieldName]['errors'][$i]);
|
||||
}
|
||||
}
|
||||
|
||||
$foundErrors = [];
|
||||
foreach ($data as $fieldName => $field) {
|
||||
if (\array_key_exists('errors', $field) && \count($field['errors']) > 0) {
|
||||
$foundErrors[$fieldName] = \count($field['errors']);
|
||||
if (\array_key_exists('errors', $data[$fieldName]) && \count($data[$fieldName]['errors']) > 0) {
|
||||
$foundErrors[$fieldName] = \count($data[$fieldName]['errors']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +320,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'language' => 'string',
|
||||
'timezone' => 'string',
|
||||
'accountNumber' => '@string',
|
||||
'memberships' => ['result' => 'array', 'type' => 'TeamMembership'],
|
||||
];
|
||||
|
||||
// if a team is embedded
|
||||
@@ -328,13 +339,28 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'color' => '@string',
|
||||
'teamlead' => ['result' => 'object', 'type' => 'User'],
|
||||
'teamlead' => ['result' => 'object', 'type' => '@User'],
|
||||
'members' => ['result' => 'array', 'type' => 'TeamMember'],
|
||||
'users' => ['result' => 'array', 'type' => 'User'],
|
||||
'customers' => ['result' => 'array', 'type' => '@Customer'],
|
||||
'projects' => ['result' => 'array', 'type' => '@Project'],
|
||||
'activities' => ['result' => 'array', 'type' => '@Activity'],
|
||||
];
|
||||
|
||||
// if the team is used inside the team context
|
||||
case 'TeamMember':
|
||||
return [
|
||||
'user' => ['result' => 'object', 'type' => 'User'],
|
||||
'teamlead' => 'bool',
|
||||
];
|
||||
|
||||
// if the team is used inside the user context
|
||||
case 'TeamMembership':
|
||||
return [
|
||||
'team' => ['result' => 'object', 'type' => 'Team'],
|
||||
'teamlead' => 'bool',
|
||||
];
|
||||
|
||||
// if a customer is embedded in other objects
|
||||
case 'Customer':
|
||||
return [
|
||||
|
||||
@@ -160,7 +160,7 @@ class CustomerControllerTest extends APIControllerBaseTest
|
||||
// and finally a team
|
||||
$team = new Team();
|
||||
$team->setName('Testing customer 1 team');
|
||||
$team->setTeamLead($this->getUserByRole(User::ROLE_USER));
|
||||
$team->addTeamlead($this->getUserByRole(User::ROLE_USER));
|
||||
$team->addCustomer($customer);
|
||||
$team->addProject($project);
|
||||
$team->addUser($this->getUserByRole(User::ROLE_TEAMLEAD));
|
||||
|
||||
@@ -144,7 +144,7 @@ class ProjectControllerTest extends APIControllerBaseTest
|
||||
// and a team
|
||||
$team = new Team();
|
||||
$team->setName('Testing project team');
|
||||
$team->setTeamLead($this->getUserByRole(User::ROLE_USER));
|
||||
$team->addTeamlead($this->getUserByRole(User::ROLE_USER));
|
||||
$team->addCustomer($customer);
|
||||
$team->addProject($project);
|
||||
$team->addUser($this->getUserByRole(User::ROLE_TEAMLEAD));
|
||||
|
||||
@@ -92,7 +92,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1]
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -123,39 +125,62 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => '',
|
||||
'teamlead' => 9999,
|
||||
'members' => [
|
||||
['user' => 9999, 'teamlead' => 1]
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
|
||||
$response = $client->getResponse();
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
$this->assertApiCallValidationError($response, ['name', 'teamlead']);
|
||||
$this->assertApiCallValidationError($response, ['name', 'members.0.user']);
|
||||
}
|
||||
|
||||
public function testPatchAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => true],
|
||||
['user' => 5, 'teamlead' => true],
|
||||
]
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
$updateId = $result['id'];
|
||||
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 2,
|
||||
'users' => [1, 5, 4]
|
||||
'members' => [
|
||||
['user' => 2, 'teamlead' => true],
|
||||
['user' => 1, 'teamlead' => false],
|
||||
['user' => 4, 'teamlead' => true],
|
||||
]
|
||||
];
|
||||
$this->request($client, '/api/teams/' . $result['id'], 'PATCH', [], json_encode($data));
|
||||
|
||||
$this->request($client, '/api/teams/' . $updateId, 'PATCH', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TeamEntity', $result);
|
||||
$this->assertNotEmpty($result['id']);
|
||||
self::assertCount(4, $result['users']);
|
||||
self::assertCount(3, $result['users']);
|
||||
|
||||
self::assertFalse($result['members'][1]['teamlead']);
|
||||
self::assertEquals(1, $result['members'][1]['user']['id']);
|
||||
self::assertEquals('clara_customer', $result['members'][1]['user']['username']);
|
||||
|
||||
self::assertTrue($result['members'][2]['teamlead']);
|
||||
self::assertEquals(4, $result['members'][2]['user']['id']);
|
||||
self::assertEquals('tony_teamlead', $result['members'][2]['user']['username']);
|
||||
|
||||
self::assertTrue(true, $result['members'][0]['teamlead']);
|
||||
self::assertEquals(2, $result['members'][0]['user']['id']);
|
||||
self::assertEquals('john_user', $result['members'][0]['user']['username']);
|
||||
}
|
||||
|
||||
public function testPatchActionWithValidationErrors()
|
||||
@@ -163,7 +188,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -171,14 +198,15 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
|
||||
$data = [
|
||||
'name' => '1',
|
||||
'teamlead' => 9999,
|
||||
'users' => [9999]
|
||||
'members' => [
|
||||
['user' => 9999, 'teamlead' => 1],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams/' . $result['id'], 'PATCH', [], json_encode($data));
|
||||
|
||||
$response = $client->getResponse();
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
$this->assertApiCallValidationError($response, ['name', 'teamlead', 'users']);
|
||||
$this->assertApiCallValidationError($response, ['name', 'members.0.user']);
|
||||
}
|
||||
|
||||
public function testDeleteAction()
|
||||
@@ -205,7 +233,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1]
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -226,8 +256,10 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -261,8 +293,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -283,8 +319,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -324,7 +364,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -346,8 +388,10 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -383,8 +427,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -411,8 +459,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -442,7 +494,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -463,8 +517,10 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -500,8 +556,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -528,8 +588,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -559,7 +623,9 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -580,8 +646,10 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -617,8 +685,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -645,8 +717,12 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'teamlead' => 1,
|
||||
'users' => [2, 4, 5]
|
||||
'members' => [
|
||||
['user' => 1, 'teamlead' => 1],
|
||||
['user' => 2, 'teamlead' => 0],
|
||||
['user' => 4, 'teamlead' => 0],
|
||||
['user' => 5, 'teamlead' => 0],
|
||||
],
|
||||
];
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
@@ -90,7 +90,7 @@ class DemoteUserCommandTest extends KernelTestCase
|
||||
$userRepository = $container->get('doctrine')->getRepository(User::class);
|
||||
$user = $userRepository->loadUserByUsername('tony_teamlead');
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
self::assertFalse($user->isTeamlead());
|
||||
self::assertFalse($user->hasTeamleadRole());
|
||||
}
|
||||
|
||||
public function testDemoteSuper()
|
||||
|
||||
@@ -90,7 +90,7 @@ class PromoteUserCommandTest extends KernelTestCase
|
||||
$userRepository = $container->get('doctrine')->getRepository(User::class);
|
||||
$user = $userRepository->loadUserByUsername('john_user');
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
self::assertTrue($user->isTeamlead());
|
||||
self::assertTrue($user->hasTeamleadRole());
|
||||
}
|
||||
|
||||
public function testPromoteSuper()
|
||||
|
||||
@@ -364,11 +364,7 @@ abstract class ControllerBaseTest extends WebTestCase
|
||||
*/
|
||||
protected function assertHasFlashSuccess(HttpKernelBrowser $client, string $message = null)
|
||||
{
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('ALERT.success(\'', $content, 'Could not find flash success message');
|
||||
if (null !== $message) {
|
||||
self::assertStringContainsString($message, $content);
|
||||
}
|
||||
$this->assertHasFlashMessage($client, 'success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,10 +372,18 @@ abstract class ControllerBaseTest extends WebTestCase
|
||||
* @param string|null $message
|
||||
*/
|
||||
protected function assertHasFlashError(HttpKernelBrowser $client, string $message = null)
|
||||
{
|
||||
$this->assertHasFlashMessage($client, 'error', $message);
|
||||
}
|
||||
|
||||
private function assertHasFlashMessage(HttpKernelBrowser $client, string $type, string $message = null)
|
||||
{
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('ALERT.error(\'', $content, 'Could not find flash error message');
|
||||
self::assertStringContainsString('ALERT.' . $type . '(\'', $content, 'Could not find flash ' . $type . ' message');
|
||||
if (null !== $message) {
|
||||
// this is a lazy workaround, the templates use the javascript escape filter: |e('js')
|
||||
// if you ever want to test more complex strings, this logic has to be enhanced
|
||||
$message = str_replace([' ', ':'], ['\u0020', '\u003A'], $message);
|
||||
self::assertStringContainsString($message, $content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
/** @var Team $team */
|
||||
$team = new Team();
|
||||
$team->setName('fooo');
|
||||
$team->setTeamLead($teamlead);
|
||||
$team->addTeamlead($teamlead);
|
||||
$team->addUser($user);
|
||||
$em->persist($team);
|
||||
$em->persist($user);
|
||||
|
||||
@@ -355,7 +355,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
public function testTeamsAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
@@ -371,7 +370,7 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
$this->assertEquals([], $user->getTeams()->toArray());
|
||||
$this->assertEquals([], $user->getTeams());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=user_teams]')->form();
|
||||
/** @var ChoiceFormField $team */
|
||||
@@ -386,10 +385,9 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$this->assertEquals(1, $user->getTeams()->count());
|
||||
$this->assertCount(1, $user->getTeams());
|
||||
}
|
||||
|
||||
public function getPreferencesTestData()
|
||||
|
||||
@@ -197,7 +197,7 @@ class ProjectControllerTest extends ControllerBaseTest
|
||||
$project->setEnd(new \DateTime());
|
||||
$em->persist($project);
|
||||
$team = new Team();
|
||||
$team->setTeamLead($this->getUserByRole(User::ROLE_ADMIN));
|
||||
$team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN));
|
||||
$team->addProject($project);
|
||||
$team->setName('project 1');
|
||||
$em->persist($team);
|
||||
|
||||
@@ -79,13 +79,13 @@ class TeamControllerTest extends ControllerBaseTest
|
||||
$form = $client->getCrawler()->filter('form[name=team_edit_form]')->form();
|
||||
|
||||
$this->assertEquals('', $form->get('team_edit_form[name]')->getValue());
|
||||
$this->assertEquals('5', $form->get('team_edit_form[teamlead]')->getValue());
|
||||
|
||||
$client->submit($form, [
|
||||
'team_edit_form' => [
|
||||
'name' => 'Test Team' . uniqid(),
|
||||
]
|
||||
]);
|
||||
$values = $form->getPhpValues();
|
||||
$values['team_edit_form']['name'] = 'Test Team' . uniqid();
|
||||
$values['team_edit_form']['members'][0]['user'] = 5;
|
||||
$values['team_edit_form']['members'][0]['teamlead'] = 1;
|
||||
$client->request($form->getMethod(), $form->getUri(), $values, $form->getPhpFiles());
|
||||
|
||||
$this->assertIsRedirect($client, '/edit');
|
||||
$client->followRedirect();
|
||||
$this->assertHasFlashSuccess($client);
|
||||
@@ -105,14 +105,13 @@ class TeamControllerTest extends ControllerBaseTest
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$fixture = new TeamFixtures();
|
||||
$fixture->setAmount(2);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/admin/teams/1/edit');
|
||||
$form = $client->getCrawler()->filter('form[name=team_edit_form]')->form();
|
||||
$this->assertNotEmpty($form->get('team_edit_form[name]')->getValue());
|
||||
|
||||
$client->submit($form, [
|
||||
'team_edit_form' => [
|
||||
'name' => 'Test Team 2'
|
||||
@@ -135,7 +134,6 @@ class TeamControllerTest extends ControllerBaseTest
|
||||
|
||||
$this->assertAccessIsGranted($client, '/admin/teams/1/edit_member');
|
||||
$form = $client->getCrawler()->filter('form[name=team_edit_form]')->form();
|
||||
$this->assertNotEmpty($form->get('team_edit_form[name]')->getValue());
|
||||
$client->submit($form, [
|
||||
'team_edit_form' => [
|
||||
'name' => 'Test Team 2'
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Tests\DataFixtures;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Faker\Factory;
|
||||
|
||||
/**
|
||||
* Defines the sample data to load in during controller tests.
|
||||
@@ -95,7 +95,6 @@ final class TeamFixtures implements TestFixture
|
||||
{
|
||||
$created = [];
|
||||
|
||||
$faker = Factory::create();
|
||||
$user = $this->getAllUsers($manager);
|
||||
$customer = $this->getAllCustomers($manager);
|
||||
|
||||
@@ -108,10 +107,14 @@ final class TeamFixtures implements TestFixture
|
||||
}
|
||||
}
|
||||
|
||||
$member = new TeamMember();
|
||||
$member->setUser($lead);
|
||||
$member->setTeamlead(true);
|
||||
|
||||
$team = new Team();
|
||||
$team
|
||||
->setName($faker->name)
|
||||
->setTeamLead($lead)
|
||||
->setName('Testing: ' . uniqid())
|
||||
->addMember($member)
|
||||
;
|
||||
|
||||
if ($this->addUser) {
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -28,9 +29,10 @@ class TeamTest extends TestCase
|
||||
$sut = new Team();
|
||||
self::assertNull($sut->getId());
|
||||
self::assertNull($sut->getName());
|
||||
self::assertNull($sut->getTeamLead());
|
||||
self::assertInstanceOf(Collection::class, $sut->getUsers());
|
||||
self::assertEquals(0, $sut->getUsers()->count());
|
||||
self::assertFalse($sut->hasUsers());
|
||||
self::assertFalse($sut->hasTeamleads());
|
||||
self::assertIsArray($sut->getTeamleads());
|
||||
self::assertEmpty($sut->getTeamleads());
|
||||
self::assertInstanceOf(Collection::class, $sut->getCustomers());
|
||||
self::assertEquals(0, $sut->getCustomers()->count());
|
||||
self::assertInstanceOf(Collection::class, $sut->getProjects());
|
||||
@@ -54,6 +56,63 @@ class TeamTest extends TestCase
|
||||
self::assertTrue($sut->hasColor());
|
||||
}
|
||||
|
||||
public function testTeamMemberships()
|
||||
{
|
||||
$user = new User();
|
||||
$user2 = new User();
|
||||
|
||||
$member = new TeamMember();
|
||||
$member->setUser($user);
|
||||
|
||||
$member2 = new TeamMember();
|
||||
$member2->setUser($user);
|
||||
$member2->setTeam(new Team());
|
||||
|
||||
$sut = new Team();
|
||||
self::assertFalse($sut->isTeamlead($user));
|
||||
self::assertCount(0, $sut->getTeamleads());
|
||||
self::assertCount(0, $sut->getMembers());
|
||||
self::assertFalse($sut->hasMember($member));
|
||||
$sut->removeMember($member);
|
||||
$sut->removeMember($member2);
|
||||
$sut->addMember($member);
|
||||
self::assertCount(1, $sut->getMembers());
|
||||
$sut->removeMember($member2);
|
||||
self::assertCount(1, $sut->getMembers());
|
||||
$sut->removeMember($member);
|
||||
self::assertCount(0, $sut->getMembers());
|
||||
self::assertFalse($sut->isTeamlead($user));
|
||||
|
||||
$sut->addMember($member);
|
||||
$member->setTeamlead(true);
|
||||
self::assertTrue($sut->isTeamlead($user));
|
||||
self::assertCount(1, $sut->getMembers());
|
||||
$sut->addTeamlead($user2);
|
||||
self::assertCount(2, $sut->getMembers());
|
||||
self::assertTrue($sut->isTeamlead($user2));
|
||||
$sut->removeTeamlead($user2);
|
||||
self::assertCount(2, $sut->getMembers());
|
||||
self::assertFalse($sut->isTeamlead($user2));
|
||||
|
||||
$member21 = new TeamMember();
|
||||
$member21->setUser($user);
|
||||
|
||||
self::assertNull($member21->getTeam());
|
||||
// this will not actually add it
|
||||
$sut->addMember($member21);
|
||||
self::assertSame($sut, $member21->getTeam());
|
||||
self::assertCount(2, $sut->getMembers());
|
||||
}
|
||||
|
||||
public function testTeamMembershipsException()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$sut = new Team();
|
||||
$member = new TeamMember();
|
||||
$member->setTeam(new Team());
|
||||
$sut->addMember($member);
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$sut = new Team();
|
||||
@@ -61,12 +120,51 @@ class TeamTest extends TestCase
|
||||
self::assertEquals('foo-bar', $sut->getName());
|
||||
self::assertEquals('foo-bar', (string) $sut);
|
||||
|
||||
$user = (new User())->setAlias('Foo!');
|
||||
self::assertInstanceOf(Team::class, $sut->setTeamLead($user));
|
||||
self::assertSame($user, $sut->getTeamLead());
|
||||
$user = $this->createMock(User::class);
|
||||
$user->method('getId')->willReturn(99);
|
||||
$user->method('getAlias')->willReturn('Foo!');
|
||||
$sut->addTeamlead($user);
|
||||
self::assertSame($user, $sut->getTeamleads()[0]);
|
||||
self::assertTrue($sut->hasTeamleads());
|
||||
|
||||
$user1 = $this->createMock(User::class);
|
||||
$user1->method('getId')->willReturn(1);
|
||||
$user2 = $this->createMock(User::class);
|
||||
$user2->method('getId')->willReturn(2);
|
||||
|
||||
self::assertFalse($sut->isTeamlead(new User()));
|
||||
self::assertTrue($sut->isTeamlead($user));
|
||||
self::assertCount(1, $sut->getTeamleads());
|
||||
$sut->addTeamlead($user1);
|
||||
self::assertCount(2, $sut->getTeamleads());
|
||||
self::assertCount(2, $sut->getMembers());
|
||||
$sut->addUser($user2);
|
||||
self::assertCount(3, $sut->getMembers());
|
||||
self::assertCount(2, $sut->getTeamleads());
|
||||
$sut->addUser($user2);
|
||||
self::assertCount(3, $sut->getMembers());
|
||||
self::assertCount(2, $sut->getTeamleads());
|
||||
}
|
||||
|
||||
/**
|
||||
* @group legacy
|
||||
*/
|
||||
public function testSetterAndGetterDeprecated()
|
||||
{
|
||||
$sut = new Team();
|
||||
|
||||
self::assertNull($sut->getTeamlead());
|
||||
self::assertIsArray($sut->getUsers());
|
||||
self::assertEmpty($sut->getUsers());
|
||||
|
||||
$user = (new User())->setAlias('Foo!');
|
||||
self::assertCount(0, $sut->getUsers());
|
||||
$sut->setTeamlead($user);
|
||||
self::assertSame($user, $sut->getTeamlead());
|
||||
self::assertTrue($sut->hasTeamleads());
|
||||
self::assertCount(1, $sut->getUsers());
|
||||
self::assertCount(1, $sut->getTeamleads());
|
||||
self::assertCount(1, $sut->getMembers());
|
||||
}
|
||||
|
||||
public function testCustomer()
|
||||
@@ -137,15 +235,69 @@ class TeamTest extends TestCase
|
||||
|
||||
$sut = new Team();
|
||||
$sut->addUser($user);
|
||||
self::assertEquals(1, $sut->getUsers()->count());
|
||||
$actual = $sut->getUsers()[0];
|
||||
self::assertCount(1, $sut->getUsers());
|
||||
|
||||
$users = $sut->getUsers();
|
||||
$actual = $users[0];
|
||||
self::assertSame($actual, $user);
|
||||
self::assertSame($sut, $user->getTeams()[0]);
|
||||
|
||||
$teams = $user->getTeams();
|
||||
self::assertSame($sut, $teams[0]);
|
||||
self::assertFalse($sut->hasUser(new User()));
|
||||
self::assertTrue($sut->hasUser($user));
|
||||
$sut->removeUser(new User());
|
||||
self::assertEquals(1, $sut->getUsers()->count());
|
||||
self::assertCount(1, $sut->getUsers());
|
||||
$sut->removeUser($user);
|
||||
self::assertEquals(0, $sut->getUsers()->count());
|
||||
self::assertCount(0, $sut->getUsers());
|
||||
$sut->addTeamlead(new User());
|
||||
self::assertCount(1, $sut->getUsers());
|
||||
}
|
||||
|
||||
public function testClone()
|
||||
{
|
||||
$c = new Customer();
|
||||
$c->setName('Foo');
|
||||
$p = new Project();
|
||||
$p->setName('Bar');
|
||||
$a = new Activity();
|
||||
$a->setName('Hello');
|
||||
$u = new User();
|
||||
$u->setAlias('World');
|
||||
$member = new TeamMember();
|
||||
$member->setUser(new User());
|
||||
|
||||
$team = new Team();
|
||||
$team->addCustomer($c);
|
||||
$team->addCustomer(new Customer());
|
||||
$team->addProject($p);
|
||||
$team->addProject(new Project());
|
||||
$team->addActivity($a);
|
||||
$team->addActivity(new Activity());
|
||||
|
||||
$team->addTeamlead($u);
|
||||
$team->addMember($member);
|
||||
$team->addUser(new User());
|
||||
|
||||
$reflection = new \ReflectionClass($team);
|
||||
$property = $reflection->getProperty('id');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($team, 99);
|
||||
$property->setAccessible(false);
|
||||
self::assertEquals(99, $team->getId());
|
||||
|
||||
$sut = clone $team;
|
||||
self::assertNull($sut->getId());
|
||||
self::assertCount(2, $sut->getCustomers());
|
||||
self::assertCount(2, $sut->getProjects());
|
||||
self::assertCount(2, $sut->getActivities());
|
||||
self::assertCount(1, $sut->getTeamleads());
|
||||
self::assertCount(3, $sut->getMembers());
|
||||
self::assertCount(3, $sut->getUsers());
|
||||
self::assertSame($c, $sut->getCustomers()[0]);
|
||||
self::assertSame($p, $sut->getProjects()[0]);
|
||||
self::assertSame($a, $sut->getActivities()[0]);
|
||||
self::assertSame($u, $sut->getTeamleads()[0]);
|
||||
self::assertSame($u, $sut->getMembers()[0]->getUser());
|
||||
self::assertSame($member->getUser(), $sut->getUsers()[1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Tests\Entity;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Export\Spreadsheet\ColumnDefinition;
|
||||
@@ -198,7 +199,11 @@ class UserTest extends TestCase
|
||||
self::assertEmpty($sut->getTeams());
|
||||
self::assertEmpty($team->getUsers());
|
||||
|
||||
$sut->addTeam($team);
|
||||
$member1 = new TeamMember();
|
||||
$member1->setUser($sut);
|
||||
$member1->setTeam($team);
|
||||
|
||||
$sut->addMembership($member1);
|
||||
self::assertCount(1, $sut->getTeams());
|
||||
self::assertSame($team, $sut->getTeams()[0]);
|
||||
self::assertSame($sut, $team->getUsers()[0]);
|
||||
@@ -218,12 +223,13 @@ class UserTest extends TestCase
|
||||
self::assertTrue($sut->isTeamleadOf($team2));
|
||||
self::assertTrue($sut->isInTeam($team2));
|
||||
|
||||
$sut->removeTeam(new Team());
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
$sut->removeTeam($team);
|
||||
$sut->removeMembership(new TeamMember());
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
$sut->removeMembership($member1);
|
||||
self::assertCount(1, $sut->getTeams());
|
||||
self::assertTrue($sut->hasTeamAssignment());
|
||||
$sut->removeTeam($team2);
|
||||
$team2->removeUser($sut);
|
||||
self::assertCount(0, $sut->getTeams());
|
||||
self::assertFalse($sut->hasTeamAssignment());
|
||||
}
|
||||
@@ -241,7 +247,7 @@ class UserTest extends TestCase
|
||||
self::assertFalse($sut->isTeamlead());
|
||||
|
||||
$sut->addRole(User::ROLE_TEAMLEAD);
|
||||
self::assertTrue($sut->isTeamlead());
|
||||
self::assertTrue($sut->hasTeamleadRole());
|
||||
self::assertFalse($sut->canSeeAllData());
|
||||
|
||||
$sut->removeRole(User::ROLE_ADMIN);
|
||||
@@ -256,7 +262,7 @@ class UserTest extends TestCase
|
||||
$sut->removeRole(User::ROLE_SUPER_ADMIN);
|
||||
self::assertFalse($sut->canSeeAllData());
|
||||
self::assertFalse($sut->isSuperAdmin());
|
||||
self::assertTrue($sut->isTeamlead());
|
||||
self::assertTrue($sut->hasTeamleadRole());
|
||||
|
||||
$sut->setSuperAdmin(true);
|
||||
self::assertTrue($sut->isSuperAdmin());
|
||||
@@ -405,12 +411,74 @@ class UserTest extends TestCase
|
||||
$unserialized = unserialize($data);
|
||||
|
||||
$actual = [
|
||||
$sut->getUsername(),
|
||||
$sut->isEnabled(),
|
||||
$sut->getId(),
|
||||
$sut->getEmail(),
|
||||
$unserialized->getUsername(),
|
||||
$unserialized->isEnabled(),
|
||||
$unserialized->getId(),
|
||||
$unserialized->getEmail(),
|
||||
];
|
||||
|
||||
self::assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public function testTeamMemberships()
|
||||
{
|
||||
$team = new Team();
|
||||
$team->setName('Foo');
|
||||
|
||||
$member = new TeamMember();
|
||||
$member->setTeam($team);
|
||||
|
||||
$member2 = new TeamMember();
|
||||
$member2->setUser(new User());
|
||||
$member2->setTeam($team);
|
||||
|
||||
$sut = new User();
|
||||
self::assertFalse($sut->isTeamleadOf($team));
|
||||
self::assertFalse($sut->isTeamlead());
|
||||
self::assertCount(0, $sut->getMemberships());
|
||||
self::assertFalse($sut->hasMembership($member));
|
||||
$sut->removeMembership($member);
|
||||
$sut->removeMembership($member2);
|
||||
$sut->addMembership($member);
|
||||
self::assertCount(1, $sut->getMemberships());
|
||||
$sut->removeMembership($member2);
|
||||
self::assertCount(1, $sut->getMemberships());
|
||||
$sut->removeMembership($member);
|
||||
self::assertCount(0, $sut->getMemberships());
|
||||
|
||||
self::assertFalse($sut->isTeamleadOf($team));
|
||||
|
||||
$sut->addMembership($member);
|
||||
self::assertCount(1, $sut->getMemberships());
|
||||
self::assertFalse($sut->isTeamleadOf($team));
|
||||
|
||||
$member->setTeamlead(true);
|
||||
self::assertTrue($sut->isTeamleadOf($team));
|
||||
|
||||
$member21 = new TeamMember();
|
||||
$member21->setTeam($team);
|
||||
|
||||
self::assertNull($member21->getUser());
|
||||
// this will not be added, because $team is already assigned
|
||||
$sut->addMembership($member21);
|
||||
|
||||
self::assertCount(1, $sut->getMemberships());
|
||||
self::assertSame($sut, $member21->getUser());
|
||||
|
||||
$sut->addTeam(new Team());
|
||||
self::assertCount(2, $sut->getTeams());
|
||||
self::assertCount(2, $sut->getMemberships());
|
||||
|
||||
$sut->removeTeam($team);
|
||||
self::assertCount(1, $sut->getMemberships());
|
||||
}
|
||||
|
||||
public function testTeamMembershipsException()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$sut = new User();
|
||||
$member = new TeamMember();
|
||||
$member->setUser(new User());
|
||||
$sut->addMembership($member);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,18 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
abstract class AbstractLoaderTest extends TestCase
|
||||
{
|
||||
protected function getEntityManagerMock(int $createQueryBuilderCount)
|
||||
protected function getEntityManagerMock(int $createQueryBuilderCount, $resultMock = null)
|
||||
{
|
||||
$em = $this->createMock(EntityManager::class);
|
||||
$qb = $this->createMock(QueryBuilder::class);
|
||||
|
||||
$query = $this->createMock(AbstractQuery::class);
|
||||
$expr = $this->createMock(Expr::class);
|
||||
|
||||
if ($resultMock !== null) {
|
||||
$query->expects($this->any())->method('execute')->willReturn($resultMock);
|
||||
}
|
||||
|
||||
$expr->expects($this->any())->method('isNotNull')->willReturn('');
|
||||
$expr->expects($this->any())->method('in')->willReturn('');
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\Repository\Loader;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Repository\Loader\ProjectLoader;
|
||||
|
||||
@@ -20,12 +21,18 @@ class ProjectLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(4);
|
||||
|
||||
$sut = new ProjectLoader($em);
|
||||
$customer = $this->createMock(Customer::class);
|
||||
$customer->expects($this->once())->method('getId')->willReturn(13);
|
||||
|
||||
$entity = $this->createMock(Project::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
$entity->expects($this->once())->method('getCustomer')->willReturn($customer);
|
||||
|
||||
$results = [$entity];
|
||||
|
||||
$em = $this->getEntityManagerMock(4, $results);
|
||||
|
||||
$sut = new ProjectLoader($em);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\Repository\Query;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
|
||||
@@ -23,6 +24,7 @@ class UserQueryTest extends BaseQueryTest
|
||||
$this->assertBaseQuery($sut, 'username');
|
||||
$this->assertInstanceOf(VisibilityInterface::class, $sut);
|
||||
$this->assertRole($sut);
|
||||
$this->assertSearchTeam($sut);
|
||||
|
||||
$this->assertResetByFormError(new UserQuery(), 'username');
|
||||
}
|
||||
@@ -33,4 +35,14 @@ class UserQueryTest extends BaseQueryTest
|
||||
$sut->setRole('ROLE_USER');
|
||||
$this->assertEquals('ROLE_USER', $sut->getRole());
|
||||
}
|
||||
|
||||
protected function assertSearchTeam(UserQuery $sut)
|
||||
{
|
||||
$team = new Team();
|
||||
$this->assertIsArray($sut->getSearchTeams());
|
||||
$this->assertEmpty($sut->getSearchTeams());
|
||||
$sut->setSearchTeams([$team, new Team()]);
|
||||
$this->assertCount(2, $sut->getSearchTeams());
|
||||
$this->assertSame($team, $sut->getSearchTeams()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
54
tests/Validator/Constraints/TeamValidatorTest.php
Normal file
54
tests/Validator/Constraints/TeamValidatorTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Validator\Constraints\Team as TeamConstraint;
|
||||
use App\Validator\Constraints\TeamValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TeamValidator
|
||||
*/
|
||||
class TeamValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return new TeamValidator();
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate('foo', new NotBlank());
|
||||
}
|
||||
|
||||
public function testMissingTeamlead()
|
||||
{
|
||||
$member = new TeamMember();
|
||||
$member->setTeamlead(false);
|
||||
$member->setUser(new User());
|
||||
|
||||
$team = new Team();
|
||||
$team->addMember($member);
|
||||
|
||||
$this->validator->validate($team, new TeamConstraint());
|
||||
|
||||
$this->buildViolation('At least one team leader must be assigned to the team.')
|
||||
->atPath('property.path.teamleads')
|
||||
->setCode(TeamConstraint::MISSING_TEAMLEAD)
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,10 @@ abstract class AbstractVoterTest extends TestCase
|
||||
$rateOther = ['view_rate_other_timesheet', 'edit_rate_other_timesheet'];
|
||||
$teams = ['view_team', 'create_team', 'edit_team', 'delete_team'];
|
||||
|
||||
$roleUser = ['edit_team_activity', 'edit_team_project', 'edit_team_customer'];
|
||||
$roleTeamlead = ['view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile'];
|
||||
$roleAdmin = ['hourly-rate_own_profile', 'edit_exported_timesheet'];
|
||||
$roleSuperAdmin = ['hourly-rate_own_profile', 'hourly-rate_other_profile', 'roles_own_profile', 'system_information', 'system_configuration', 'plugins', 'edit_exported_timesheet'];
|
||||
$roleUser = ['view_team_member', 'edit_team_activity', 'edit_team_project', 'edit_team_customer'];
|
||||
$roleTeamlead = ['view_team_member', 'view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile'];
|
||||
$roleAdmin = ['view_team_member', 'hourly-rate_own_profile', 'edit_exported_timesheet'];
|
||||
$roleSuperAdmin = ['view_team_member', 'hourly-rate_own_profile', 'hourly-rate_other_profile', 'roles_own_profile', 'system_information', 'system_configuration', 'plugins', 'edit_exported_timesheet'];
|
||||
|
||||
$permissions = [
|
||||
'ROLE_USER' => array_merge($timesheet, $profile, $roleUser),
|
||||
|
||||
@@ -90,7 +90,7 @@ class ActivityVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_TEAMLEAD);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$activity = new Activity();
|
||||
$project = new Project();
|
||||
@@ -129,7 +129,7 @@ class ActivityVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_USER);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$activity = new Activity();
|
||||
$project = new Project();
|
||||
|
||||
@@ -47,7 +47,7 @@ class CustomerVoterTest extends AbstractVoterTest
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setTeamLead($userTeamlead);
|
||||
$team->addTeamlead($userTeamlead);
|
||||
foreach ([$userTeamlead] as $user) {
|
||||
$customer = new Customer();
|
||||
$team->addCustomer($customer);
|
||||
@@ -88,7 +88,7 @@ class CustomerVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_TEAMLEAD);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->addTeam($team);
|
||||
@@ -101,7 +101,7 @@ class CustomerVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_USER);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->addTeam($team);
|
||||
|
||||
@@ -52,7 +52,7 @@ class ProjectVoterTest extends AbstractVoterTest
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setTeamLead($userTeamlead);
|
||||
$team->addTeamlead($userTeamlead);
|
||||
foreach ([$userTeamlead] as $user) {
|
||||
$project = new Project();
|
||||
$team->addProject($project);
|
||||
@@ -95,7 +95,7 @@ class ProjectVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_TEAMLEAD);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$project = new Project();
|
||||
$customer = new Customer();
|
||||
@@ -117,7 +117,7 @@ class ProjectVoterTest extends AbstractVoterTest
|
||||
$team = new Team();
|
||||
$user = new User();
|
||||
$user->addRole(User::ROLE_USER);
|
||||
$team->setTeamLead($user);
|
||||
$team->addTeamlead($user);
|
||||
|
||||
$project = new Project();
|
||||
$customer = new Customer();
|
||||
|
||||
@@ -111,4 +111,16 @@ class UserVoterTest extends AbstractVoterTest
|
||||
[User::AUTH_SAML, VoterInterface::ACCESS_DENIED],
|
||||
];
|
||||
}
|
||||
|
||||
public function testViewTeamMember()
|
||||
{
|
||||
$userMock = $this->createMock(User::class);
|
||||
$userMock->method('getId')->willReturn(1);
|
||||
$user = new User();
|
||||
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles());
|
||||
$sut = $this->getVoter(UserVoter::class);
|
||||
|
||||
$this->assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $user, ['view_team_member']));
|
||||
$this->assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $userMock, ['view_team_member']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,6 +1240,14 @@
|
||||
<source>delete_warning.short_stats</source>
|
||||
<target>Momentan existieren insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="add_user.label">
|
||||
<source>add_user.label</source>
|
||||
<target>Benutzer hinzufügen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="team.add_user.help">
|
||||
<source>team.add_user.help</source>
|
||||
<target>Durch Auswahl wird dem Team ein neuer Benutzer hinzugefügt. Sie können im Anschluss festlegen, ob der Benutzer ein Teamleiter sein soll.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1240,6 +1240,14 @@
|
||||
<source>delete_warning.short_stats</source>
|
||||
<target>Currently %records% time-records exists, which sum up to a total duration of %duration%.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="add_user.label">
|
||||
<source>add_user.label</source>
|
||||
<target>Add user</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="team.add_user.help">
|
||||
<source>team.add_user.help</source>
|
||||
<target>Add a new user to the team by selecting it from the list. Afterwards you can decide if the user should become a teamlead.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<file source-language="en" target-language="de" datatype="plaintext" original="validators.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="At least one team leader must be assigned to the team.">
|
||||
<source>At least one team leader must be assigned to the team.</source>
|
||||
<target>Dem Team muss mindestens ein Teamleiter zugeordnet werden.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="The entered passwords don't match.">
|
||||
<source>The entered passwords don't match.</source>
|
||||
<target>Die Passwörter stimmen nicht überein.</target>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user