From ed55f97862d58c5619a81fe859aa6f82138969c1 Mon Sep 17 00:00:00 2001 From: Xavier Lacot Date: Tue, 19 Sep 2023 14:06:45 +0200 Subject: [PATCH] allow to configure standup meeting reminders that are associated to all the projects for one or more clients, not only one or more projects --- migrations/Version20230918170924.php | 35 +++++ src/Controller/SlackCommandController.php | 4 + src/Entity/StandupMeetingReminder.php | 24 ++++ src/StandupMeetingReminder/Handler.php | 160 ++++++++++++++++++++-- src/StandupMeetingReminder/Sender.php | 8 +- 5 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 migrations/Version20230918170924.php diff --git a/migrations/Version20230918170924.php b/migrations/Version20230918170924.php new file mode 100644 index 0000000..b0e00cc --- /dev/null +++ b/migrations/Version20230918170924.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace DoctrineMigrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20230918170924 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add a way to attach standup reminders to a client, not only to projects'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE standup_meeting_reminder ADD forecast_clients LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE standup_meeting_reminder DROP forecast_clients'); + } +} diff --git a/src/Controller/SlackCommandController.php b/src/Controller/SlackCommandController.php index 0077b1d..9372c52 100644 --- a/src/Controller/SlackCommandController.php +++ b/src/Controller/SlackCommandController.php @@ -37,6 +37,10 @@ public function dataSource(Request $request, StandupMeetingReminderHandler $stan return new JsonResponse($standupMeetingReminderHandler->listProjects($payload)); } + if ('selected_clients' === $payload['action_id']) { + return new JsonResponse($standupMeetingReminderHandler->listClients($payload)); + } + return new JsonResponse('<3 you, Slack'); } diff --git a/src/Entity/StandupMeetingReminder.php b/src/Entity/StandupMeetingReminder.php index 2829272..d178fa0 100644 --- a/src/Entity/StandupMeetingReminder.php +++ b/src/Entity/StandupMeetingReminder.php @@ -34,6 +34,12 @@ class StandupMeetingReminder #[ORM\Column(type: 'string', length: 15)] private string $channelId; + /** + * @var array + */ + #[ORM\Column(type: 'array')] + private array $forecastClients = []; + /** * @var array */ @@ -91,6 +97,24 @@ public function setChannelId(string $channelId): self return $this; } + /** + * @return array + */ + public function getForecastClients(): ?array + { + return $this->forecastClients; + } + + /** + * @param array $forecastClients + */ + public function setForecastClients(array $forecastClients): self + { + $this->forecastClients = $forecastClients; + + return $this; + } + /** * @return array */ diff --git a/src/StandupMeetingReminder/Handler.php b/src/StandupMeetingReminder/Handler.php index f8b34d0..9e2a1e6 100644 --- a/src/StandupMeetingReminder/Handler.php +++ b/src/StandupMeetingReminder/Handler.php @@ -23,6 +23,8 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use function Symfony\Component\String\u; + class Handler { final public const ACTION_PREFIX = 'standup-reminder'; @@ -51,7 +53,7 @@ public function handleRequest(Request $request): void case self::SLACK_COMMAND_OPTION_LIST: $this->listReminders($request); - // try to preload available projects so that they're in cache + // try to preload available clients and projects so that they're in cache $this->loadProjects($request->request->get('team_id')); break; case '': @@ -130,8 +132,24 @@ public function handleBlockAction(array $payload): void */ public function handleSubmission(array $payload): JsonResponse { + if ( + 0 === (is_countable($payload['view']['state']['values']['clients']['selected_clients']['selected_options']) ? \count($payload['view']['state']['values']['clients']['selected_clients']['selected_options']) : 0) + + (is_countable($payload['view']['state']['values']['projects']['selected_projects']['selected_options']) ? \count($payload['view']['state']['values']['projects']['selected_projects']['selected_options']) : 0) + ) { + return new JsonResponse([ + 'response_action' => 'errors', + 'errors' => [ + 'clients' => 'Please choose at least one client or project.', + 'projects' => 'Please choose at least one client or project.', + ], + ]); + } + + $selectedClientsForDisplay = []; + $selectedClientIds = []; $selectedProjectsForDisplay = []; $selectedProjectIds = []; + $restrictionDescription = ''; $slackTeam = $this->slackTeamRepository->findOneBy([ 'teamId' => $payload['team']['id'], ]); @@ -143,18 +161,43 @@ public function handleSubmission(array $payload): JsonResponse $channelId = $privateMetadata['channel_id']; } + foreach ($payload['view']['state']['values']['clients']['selected_clients']['selected_options'] as $client) { + $selectedClientsForDisplay[] = sprintf('"%s"', $client['text']['text']); + $selectedClientIds[] = $client['value']; + } + foreach ($payload['view']['state']['values']['projects']['selected_projects']['selected_options'] as $project) { $selectedProjectsForDisplay[] = sprintf('"%s"', $project['text']['text']); $selectedProjectIds[] = $project['value']; } - if (\count($selectedProjectsForDisplay) > 1) { - $lastProject = ' and ' . array_pop($selectedProjectsForDisplay); - } else { - $lastProject = ''; + $projectsByAccount = $this->loadProjects($payload['team']['id']); + + foreach ($projectsByAccount as $data) { + foreach ($data['projects'] as $project) { + if ( + \count($selectedClientIds) > 0 + && \in_array($project->getId(), $selectedProjectIds, true) + && !\in_array($project->getClientId(), $selectedClientIds, true) + ) { + return new JsonResponse([ + 'response_action' => 'errors', + 'errors' => [ + 'projects' => 'Please choose projects that match the selected client(s).', + ], + ]); + } + } + } + + if (\count($selectedClientsForDisplay) > 0) { + $restrictionDescription .= ' for the client(s) ' . u(', ')->join($selectedClientsForDisplay, ' and '); + } + + if (\count($selectedProjectsForDisplay) > 0) { + $restrictionDescription .= ' on the project(s) ' . u(', ')->join($selectedProjectsForDisplay, ' and '); } - $selectedProjectsForDisplay = implode(', ', $selectedProjectsForDisplay) . $lastProject; $selectedTime = $payload['view']['state']['values']['time']['selected_time']['selected_option']['value']; $standupMeetingReminder = $this->standupMeetingReminderRepository->findOneBy([ 'channelId' => $channelId, @@ -171,6 +214,7 @@ public function handleSubmission(array $payload): JsonResponse $standupMeetingReminder->setUpdatedBy('@' . $payload['user']['username']); $standupMeetingReminder->setIsEnabled(true); + $standupMeetingReminder->setForecastClients($selectedClientIds); $standupMeetingReminder->setForecastProjects($selectedProjectIds); $standupMeetingReminder->setTime($selectedTime); $this->em->persist($standupMeetingReminder); @@ -178,12 +222,11 @@ public function handleSubmission(array $payload): JsonResponse $client = \JoliCode\Slack\ClientFactory::create($slackTeam->getAccessToken()); $message = sprintf( - '<@%s> %s a stand-up reminder in this channel. It will run each day at `%s` and ping people working on the project%s %s.', + '<@%s> %s a stand-up reminder in this channel. It will run each day at `%s` and ping people working%s.', $payload['user']['username'], $actionName, $selectedTime, - ('' !== $lastProject) ? 's' : '', - $selectedProjectsForDisplay + $restrictionDescription ); $client->chatPostMessage([ 'channel' => $channelId, @@ -214,6 +257,56 @@ public function handleSubmission(array $payload): JsonResponse return new JsonResponse(['response_action' => 'clear']); } + /** + * @param array $payload + * + * @return array>>> + */ + public function listClients(array $payload): array + { + $availableClients = []; + $searched = mb_strtolower((string) $payload['value']); + $projectsByAccount = $this->loadProjects($payload['team']['id']); + + foreach ($projectsByAccount as $data) { + $accountClients = []; + + foreach ($data['clients'] as $client) { + if (false !== mb_strpos(mb_strtolower((string) $client->getName()), $searched)) { + $accountClients[] = [ + 'text' => [ + 'type' => 'plain_text', + 'text' => mb_substr((string) $client->getName(), 0, 75), + ], + 'value' => (string) $client->getId(), + ]; + } + } + + if (\count($accountClients) > 0) { + usort($accountClients, fn ($a, $b) => strcmp($a['text']['text'], $b['text']['text'])); + + $availableClients[] = [ + 'label' => [ + 'type' => 'plain_text', + 'text' => $data['forecastAccount']->getName(), + ], + 'options' => $accountClients, + ]; + } + } + + if (1 === \count($availableClients)) { + return [ + 'options' => $availableClients[0]['options'], + ]; + } + + return [ + 'option_groups' => $availableClients, + ]; + } + /** * @param array $payload * @@ -289,7 +382,7 @@ public function loadProjects(string $teamId): array $this->forecastDataSelector->setForecastAccount($forecastAccount); $projectsByAccount[] = [ 'forecastAccount' => $forecastAccount, - 'clients' => $this->forecastDataSelector->getClientsById(), + 'clients' => $this->forecastDataSelector->getClientsById(true), 'projects' => $this->forecastDataSelector->getProjects(true), ]; } @@ -347,6 +440,7 @@ private function displayModalForm(string $teamId, ?string $channelId, string $tr $slackTeam = $this->slackTeamRepository->findOneByTeamId($teamId); $availableTimes = []; $initialProjects = []; + $initialClients = []; $initialTime = null; $initialHour = 10; $initialMinute = 0; @@ -377,11 +471,24 @@ private function displayModalForm(string $teamId, ?string $channelId, string $tr foreach ($forecastAccounts as $forecastAccount) { $this->forecastDataSelector->setForecastAccount($forecastAccount); + $clients = $this->forecastDataSelector->getClients(true); $projects = $this->forecastDataSelector->getProjects(true); + foreach ($clients as $client) { + if (\in_array((string) $client->getId(), $standupMeetingReminder->getForecastClients(), true)) { + $initialClients[] = [ + 'text' => [ + 'type' => 'plain_text', + 'text' => mb_substr($client->getName(), 0, 75), + ], + 'value' => (string) $client->getId(), + ]; + } + } + foreach ($projects as $project) { if (\in_array((string) $project->getId(), $standupMeetingReminder->getForecastProjects(), true)) { - $projectCode = (null !== $project->getCode()) ? '[' . $project->getCode() . '] ' : ''; + $projectCode = null !== $project->getCode() && '' !== $project->getCode() ? '[' . $project->getCode() . '] ' : ''; $initialProjects[] = [ 'text' => [ 'type' => 'plain_text', @@ -433,12 +540,35 @@ private function displayModalForm(string $teamId, ?string $channelId, string $tr } } + $clientsBlock = [ + 'type' => 'input', + 'block_id' => 'clients', + 'label' => [ + 'type' => 'plain_text', + 'text' => 'Choose clients', + ], + 'element' => [ + 'type' => 'multi_external_select', + 'action_id' => 'selected_clients', + 'placeholder' => [ + 'type' => 'plain_text', + 'text' => 'Select clients', + ], + 'min_query_length' => 3, + ], + 'optional' => true, + ]; + + if (\count($initialClients) > 0) { + $clientsBlock['element']['initial_options'] = $initialClients; + } + $projectsBlock = [ 'type' => 'input', 'block_id' => 'projects', 'label' => [ 'type' => 'plain_text', - 'text' => 'Select one or more Forecast projects', + 'text' => 'Choose Forecast projects', ], 'element' => [ 'type' => 'multi_external_select', @@ -449,12 +579,18 @@ private function displayModalForm(string $teamId, ?string $channelId, string $tr ], 'min_query_length' => 3, ], + 'optional' => true, + 'hint' => [ + 'type' => 'plain_text', + 'text' => 'Choose one or more projects to restrict the notification to these projects only. If no specific project is choosen, all the projects attached to the selected client(s) will be used.', + ], ]; if (\count($initialProjects) > 0) { $projectsBlock['element']['initial_options'] = $initialProjects; } + $blocks[] = $clientsBlock; $blocks[] = $projectsBlock; $blocks[] = [ 'type' => 'input', diff --git a/src/StandupMeetingReminder/Sender.php b/src/StandupMeetingReminder/Sender.php index 63550b0..0fa9ca0 100644 --- a/src/StandupMeetingReminder/Sender.php +++ b/src/StandupMeetingReminder/Sender.php @@ -100,9 +100,15 @@ private function findParticipants(StandupMeetingReminder $standupMeetingReminder $today = new \DateTime('today'); $assignments = $this->forecastDataSelector->getAssignments($today, new \DateTime('tomorrow')); $assignments = array_values(array_filter($assignments, fn ($assignment): bool => $assignment->getStartDate()->format('Y-m-d') <= $today->format('Y-m-d') && $assignment->getEndDate()->format('Y-m-d') >= $today->format('Y-m-d'))); + $projects = $this->forecastDataSelector->getProjectsById(enabled: true); foreach ($assignments as $assignment) { - if (\in_array((string) $assignment->getProjectId(), $standupMeetingReminder->getForecastProjects(), true)) { + $projectId = $assignment->getProjectId(); + + if ( + (0 === \count((array) $standupMeetingReminder->getForecastClients()) || \in_array((string) $projects[$projectId]->getClientId(), $standupMeetingReminder->getForecastClients(), true)) + && (0 === \count((array) $standupMeetingReminder->getForecastProjects()) || \in_array((string) $projectId, $standupMeetingReminder->getForecastProjects(), true)) + ) { if (null !== $assignment->getPersonId()) { $members[$people[$assignment->getPersonId()]->getEmail()] = $memberName = sprintf( '%s %s',