support multiple teamleads in each team (#2702)

* fix jumping avatars
* fix line-break after color dot for long names
This commit is contained in:
Kevin Papst
2021-08-07 18:05:41 +02:00
committed by GitHub
parent e9986c92d6
commit b2d3272151
101 changed files with 1952 additions and 649 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -115,6 +115,7 @@ final class CalendarService
'timeframeEnd' => $this->configuration->getCalendarTimeframeEnd(),
'dragDropAmount' => $this->configuration->getCalendarDragAndDropMaxEntries(),
];
$event = new CalendarConfigurationEvent($config);
$this->dispatcher->dispatch($event);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

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

View File

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

View File

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

View File

@@ -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 = [];

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

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

View File

@@ -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 %}

View File

@@ -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) }}&nbsp;
{% 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') }}">

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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