From 26e5e44d12ad5c51172b070e93f865fe9d8bbb92 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 17 Aug 2023 14:55:07 +0200 Subject: [PATCH] FORSLAG-67: Handled survey webforms --- CHANGELOG.md | 2 + .../hoeringsportal_citizen_proposal/README.md | 15 ++ ...eringsportal_citizen_proposal.services.yml | 23 +++ .../src/Form/ProposalAdminForm.php | 68 +++++++- .../src/Form/ProposalFormAdd.php | 51 ++++++ .../src/Form/ProposalFormApprove.php | 10 ++ .../src/Form/ProposalFormBase.php | 17 ++ .../src/Helper/WebformHelper.php | 159 ++++++++++++++++++ 8 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 web/modules/custom/hoeringsportal_citizen_proposal/src/Helper/WebformHelper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e138caa1..73d22dc8 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +* [PR-347](https://github.com/itk-dev/hoeringsportal/pull/347) + Added citizen proposal survey * [PR-342](https://github.com/itk-dev/hoeringsportal/pull/342) Added email and storage consent checkbox on citizen proposal support form. Added supporters view. diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/README.md b/web/modules/custom/hoeringsportal_citizen_proposal/README.md index 4b88cd6a..5540a7b5 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/README.md +++ b/web/modules/custom/hoeringsportal_citizen_proposal/README.md @@ -126,3 +126,18 @@ something like docker compose exec phpfpm vendor/bin/drush sql:query "SELECT nid, title FROM node_field_data WHERE type = 'citizen_proposal'" docker compose exec phpfpm vendor/bin/drush hoeringsportal-citizen-proposal:test-mail:send 87 create test@example.com ``` + +## Surveys + +We use the [Webform module](https://www.drupal.org/project/webform) to render +surveys when creating a citizen proposal, and create webform submission to store +the survey responses. + +To keep things simple we should allow only very few element types in webforms +(cf. `/admin/structure/webform/config/elements#edit-types`). + +When rendering a webform survey, we skip rendering “Entity autocomplete” +elements and all actions (e.g. “Submit”). However, if a survey webform contains +an “Entity autocomplete” element allowing references to “Citizen proposal” +nodes, we set a reference to the proposal on the survey response when saving the +response (creating a submission). diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/hoeringsportal_citizen_proposal.services.yml b/web/modules/custom/hoeringsportal_citizen_proposal/hoeringsportal_citizen_proposal.services.yml index d9752e42..edd03edb 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/hoeringsportal_citizen_proposal.services.yml +++ b/web/modules/custom/hoeringsportal_citizen_proposal/hoeringsportal_citizen_proposal.services.yml @@ -22,3 +22,26 @@ services: - '@logger.channel.hoeringsportal_citizen_proposal' tags: - {name: event_subscriber} + + # @see https://www.drupal.org/project/drupal/issues/2376347 + # @see https://www.drupal.org/docs/drupal-apis/services-and-dependency-injection/structure-of-a-service-file#s-properties-of-a-service + hoeringsportal_citizen_proposal.storage.webform: + class: Drupal\webform\WebformEntityStorageInterface + factory: ['@entity_type.manager', 'getStorage'] + arguments: ['webform'] + + hoeringsportal_citizen_proposal.storage.webform_survey_temp_store: + class: Drupal\Core\TempStore\PrivateTempStore + factory: ['@tempstore.private', 'get'] + arguments: ['hoeringsportal_citizen_proposal_survey'] + + hoeringsportal_citizen_proposal.storage.webform_config: + class: Drupal\Core\Config\ImmutableConfig + factory: ['@config.factory', 'get'] + arguments: ['webform.settings'] + + Drupal\hoeringsportal_citizen_proposal\Helper\WebformHelper: + arguments: + - '@hoeringsportal_citizen_proposal.storage.webform' + - '@hoeringsportal_citizen_proposal.storage.webform_config' + - '@hoeringsportal_citizen_proposal.storage.webform_survey_temp_store' diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalAdminForm.php b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalAdminForm.php index eda6e873..60106d84 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalAdminForm.php +++ b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalAdminForm.php @@ -6,6 +6,8 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\hoeringsportal_citizen_proposal\Helper\Helper; use Drupal\hoeringsportal_citizen_proposal\Helper\MailHelper; +use Drupal\hoeringsportal_citizen_proposal\Helper\WebformHelper; +use Drupal\webform\WebformInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -17,7 +19,8 @@ final class ProposalAdminForm extends FormBase { * Constructor for the proposal add form. */ public function __construct( - readonly private Helper $helper + readonly private Helper $helper, + readonly private WebformHelper $webformHelper ) { } @@ -27,6 +30,7 @@ public function __construct( public static function create(ContainerInterface $container) { return new static( $container->get(Helper::class), + $container->get(WebformHelper::class), ); } @@ -237,6 +241,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#default_value' => $adminFormStateValues['sidebar_text']['value'] ?? '', ]; + $this->buildSurveyForm($form, $adminFormStateValues ?? []); $this->buildEmailsForm($form, $adminFormStateValues ?? []); $form['actions']['#type'] = 'actions'; @@ -249,6 +254,57 @@ public function buildForm(array $form, FormStateInterface $form_state) { return $form; } + /** + * Build survey form. + * + * @param array $form + * The form. + * @param array $adminFormStateValues + * The admin form state values. + * + * @return array + * The form. + */ + private function buildSurveyForm(array &$form, array $adminFormStateValues): array { + $form['survey'] = [ + '#type' => 'details', + '#tree' => TRUE, + '#open' => TRUE, + '#title' => $this + ->t('Survey'), + ]; + + $form['survey']['webform'] = [ + '#type' => 'select', + '#title' => $this->t('Survey webform'), + '#options' => array_map( + static fn (WebformInterface $webform) => $webform->label(), + $this->webformHelper->loadSurveyWebforms() + ), + '#empty_option' => $this->t('Select survey webform'), + '#default_value' => $adminFormStateValues['survey']['webform'] ?? '', + '#description' => $this->t('Select a survey to show as part of the citizen proposal creation form.'), + ]; + + $form['survey']['description'] = [ + '#type' => 'text_format', + '#title' => $this->t('Survey description'), + '#format' => $adminFormStateValues['survey']['description']['format'] ?? 'filtered_html', + '#default_value' => $adminFormStateValues['survey']['description']['value'] ?? '', + '#description' => $this->t('Tell a little about why the survey is shown.'), + '#states' => [ + 'visible' => [ + ':input[name="survey[webform]"]' => ['filled' => TRUE], + ], + 'required' => [ + ':input[name="survey[webform]"]' => ['filled' => TRUE], + ], + ], + ]; + + return $form; + } + /** * Build emails form. * @@ -315,4 +371,14 @@ public function submitForm(array &$form, FormStateInterface $formState): void { $this->helper->setAdminValues($formState->getValues()); } + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $formState) { + if (!empty($formState->getValue(['survey', 'webform'])) + && empty($formState->getValue(['survey', 'description', 'value']))) { + $formState->setError($form['survey']['description']['value'], $this->t('Please enter a survey description.')); + } + } + } diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormAdd.php b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormAdd.php index a5b72178..98da0b77 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormAdd.php +++ b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormAdd.php @@ -146,6 +146,8 @@ public function buildProposalForm(array $form, FormStateInterface $formState): a ], ]; + $this->buildSurveyForm($form); + $form['consent'] = [ '#type' => 'checkbox', '#title' => $this @@ -167,6 +169,45 @@ public function buildProposalForm(array $form, FormStateInterface $formState): a return $form; } + /** + * Build survey form. + * + * @param array $form + * The form. + * + * @return array + * The form. + */ + private function buildSurveyForm(array &$form): array { + $form['survey'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['survey', 'citizen-proposal-survey'], + ], + '#tree' => TRUE, + ]; + + try { + $description = $this->getAdminFormStateValue(['survey', 'description']); + if (($webform = $this->loadSurvey()) && isset($description['value'])) { + // We use a numeric index (implicit 0) here to prevent webform fields + // accidentally overwriting the description element. + $form['survey'][] = [ + '#type' => 'processed_text', + '#text' => $description['value'], + '#format' => $description['format'] ?? 'filtered_html', + ]; + + $this->webformHelper->renderWebformElements($webform, $form['survey']); + } + } + catch (\Exception $exception) { + throw $exception; + } + + return $form; + } + /** * {@inheritdoc} */ @@ -208,6 +249,16 @@ public function submitForm(array &$form, FormStateInterface $formState): void { $this->helper->setDraftProposal($entity); $formState ->setRedirect('hoeringsportal_citizen_proposal.citizen_proposal.proposal_approve'); + + // Handle survey. + try { + if ($webform = $this->loadSurvey()) { + $surveyData = (array) $formState->getValue('survey'); + $this->webformHelper->setSurveyResponse($webform, $surveyData); + } + } + catch (\Exception) { + } } /** diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormApprove.php b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormApprove.php index 16aa25f3..77766114 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormApprove.php +++ b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormApprove.php @@ -157,8 +157,18 @@ public function submitForm(array &$form, FormStateInterface $formState) { $this->messenger()->addStatus($this->getAdminFormStateValue('approve_submission_text', $this->t('Thank you for your submission.'))); $entity->save(); + $this->helper->deleteDraftProposal(); + // Handle survey. + try { + if ($webform = $this->loadSurvey()) { + $this->webformHelper->saveSurveyResponse($webform, $entity); + } + } + catch (\Exception) { + } + $formState->setRedirectUrl( $this->deAuthenticateUser( $this->getAdminFormStateValueUrl('approve_goto_url', '/') diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormBase.php b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormBase.php index 3525547d..a4cc790e 100644 --- a/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormBase.php +++ b/web/modules/custom/hoeringsportal_citizen_proposal/src/Form/ProposalFormBase.php @@ -10,8 +10,10 @@ use Drupal\Core\Url; use Drupal\hoeringsportal_citizen_proposal\Exception\RuntimeException; use Drupal\hoeringsportal_citizen_proposal\Helper\Helper; +use Drupal\hoeringsportal_citizen_proposal\Helper\WebformHelper; use Drupal\hoeringsportal_openid_connect\Controller\OpenIDConnectController; use Drupal\hoeringsportal_openid_connect\Helper as AuthenticationHelper; +use Drupal\webform\WebformInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -25,6 +27,7 @@ abstract class ProposalFormBase extends FormBase { */ final public function __construct( readonly protected Helper $helper, + readonly protected WebformHelper $webformHelper, readonly private AuthenticationHelper $authenticationHelper, readonly private ImmutableConfig $config ) { @@ -36,6 +39,7 @@ final public function __construct( public static function create(ContainerInterface $container) { return new static( $container->get(Helper::class), + $container->get(WebformHelper::class), $container->get(AuthenticationHelper::class), $container->get('config.factory')->get('hoeringsportal_citizen_proposal.settings') ); @@ -234,4 +238,17 @@ protected function getUserUuid($allowEditor = TRUE): string { return sha1($userId); } + /** + * Load survey webform. + * + * @return \Drupal\webform\WebformInterface|null + * The webform if any. + */ + protected function loadSurvey(): ?WebformInterface { + return $this->webformHelper->loadWebform((string) $this->getAdminFormStateValue([ + 'survey', + 'webform', + ])); + } + } diff --git a/web/modules/custom/hoeringsportal_citizen_proposal/src/Helper/WebformHelper.php b/web/modules/custom/hoeringsportal_citizen_proposal/src/Helper/WebformHelper.php new file mode 100644 index 00000000..ec478d2e --- /dev/null +++ b/web/modules/custom/hoeringsportal_citizen_proposal/src/Helper/WebformHelper.php @@ -0,0 +1,159 @@ +webformStorage->loadMultiple(); + } + + /** + * Load webform. + * + * @param string $id + * The webform id. + * + * @return \Drupal\webform\WebformInterface|null + * The webform if found. + */ + public function loadWebform(string $id): ?WebformInterface { + return $this->webformStorage->load($id) ?: NULL; + } + + /** + * Render webform elements into a form. + * + * @param \Drupal\webform\WebformInterface $webform + * The webform. + * @param array $form + * The target for. + */ + public function renderWebformElements(WebformInterface $webform, array &$form): void { + $response = $this->getSurveyResponse($webform); + foreach ($webform->getElementsDecoded() as $key => $element) { + if ($this->isRenderableElement($element)) { + $form[$key] = $element; + if (isset($response[$key])) { + $form[$key]['#default_value'] = $response[$key]; + } + } + } + } + + /** + * Set survey response. + */ + public function setSurveyResponse(WebformInterface $webform, array $response) { + $this->tempStore->set($this->createTempStoreKey($webform), $response); + } + + /** + * Get survey response. + */ + public function getSurveyResponse(WebformInterface $webform): ?array { + return $this->tempStore->get($this->createTempStoreKey($webform)); + } + + /** + * Delete survey response. + */ + private function deleteSurveyResponse(WebformInterface $webform): bool { + return $this->tempStore->delete($this->createTempStoreKey($webform)); + } + + /** + * Save survey response by creating a webform submission. + */ + public function saveSurveyResponse(WebformInterface $webform, ContentEntityInterface $entity): WebformSubmissionInterface { + $response = $this->getSurveyResponse($webform); + + // Add entity reference to response. + foreach ($webform->getElementsDecodedAndFlattened() as $key => $element) { + if ('entity_autocomplete' === ($element['#type'] ?? NULL) + && $entity->getEntityTypeId() === ($element['#target_type'] ?? NULL) + && isset($element['#selection_settings']['target_bundles'][$entity->bundle()])) { + $response[$key] = $entity->id(); + } + } + + $submission = WebformSubmission::create([ + 'data' => $response, + 'webform_id' => $webform->id(), + ]); + $submission->save(); + + $this->deleteSurveyResponse($webform); + + return $submission; + } + + /** + * Create temp store key. + */ + private function createTempStoreKey(WebformInterface $webform): string { + return self::class . '_' . $webform->id(); + } + + /** + * Check if an element is renderable. + * + * @param array $element + * The elements. + * + * @return bool + * Whether the element is renderable. + */ + private function isRenderableElement(array $element): bool { + $disallowedElements = [ + 'entity_autocomplete', + 'webform_actions', + ]; + + return !in_array($element['#type'] ?? NULL, $disallowedElements, TRUE) + && !$this->isExcludedWebformElement($element); + } + + /** + * Check if an element is excluded from webforms. + * + * @param array $element + * The element. + * + * @return bool + * Whether the element is excluded. + */ + private function isExcludedWebformElement(array $element): bool { + $excludedElements = (array) ($this->webformConfig->get('element.excluded_elements') ?? NULL); + + return isset($excludedElements[$element['#type'] ?? NULL]); + } + +}