]> _ Git - ccv-wordpress.git/commitdiff
File uploads / e-mail attachments, responsive tweaks and UX improvements. WIP #3383...
authorStephen Cameron <stephen@cubedesigners.com>
Tue, 5 May 2020 19:16:51 +0000 (21:16 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Tue, 5 May 2020 19:16:51 +0000 (21:16 +0200)
13 files changed:
wp-content/mu-plugins/cube/src/Forms/Base.php
wp-content/mu-plugins/cube/src/Forms/Builder/Fields/File.php [new file with mode: 0644]
wp-content/mu-plugins/cube/src/Forms/Builder/Form.php
wp-content/mu-plugins/cube/src/Forms/Training.php
wp-content/themes/CCV/resources/assets/scripts/forms/file-upload.js [new file with mode: 0644]
wp-content/themes/CCV/resources/assets/scripts/forms/forms.js
wp-content/themes/CCV/resources/assets/styles/common/spacing.styl
wp-content/themes/CCV/resources/assets/styles/components/forms.styl
wp-content/themes/CCV/resources/assets/styles/components/headings.styl
wp-content/themes/CCV/resources/views/forms/consultation.blade.php
wp-content/themes/CCV/resources/views/forms/training.blade.php
wp-content/themes/CCV/resources/views/partials/footer.blade.php
wp-content/themes/CCV/tailwind.config.js

index c8671e49b62842451b8b18c6267ef9a64398cbd1..69a13a40b9fa3ab80de76444ed869119f8add152 100644 (file)
@@ -120,14 +120,15 @@ class Base
         return $this->forms;
     }
 
-    public function get_form_data() {
-        $data = $_POST;
+    public function get_request_data() {
+        $data = $this->sanitize($_POST);
 
-        // Include successful file uploads in data
-        if (!empty($_FILES)) {
-            foreach ($_FILES as $field_name => $file) {
-                if ($file['error'] === UPLOAD_ERR_OK) {
-                    $data[$field_name] = $file;
+        $files = $this->normalise_files($_FILES);
+
+        foreach ($files as $field_name => $uploads) {
+            foreach ($uploads as $upload) {
+                if ($upload['error'] === UPLOAD_ERR_OK) {
+                    $data[$field_name][] = $upload['name']; // Record the filenames of successful uploads
                 }
             }
         }
@@ -135,6 +136,54 @@ class Base
         return $data;
     }
 
+    // Normalise PHP's $_FILES array so the structure is consistent between single and multi-file uploads
+    // This also gives a more sensible structure than PHP's default because each upload is grouped
+    // Ref: https://www.php.net/manual/en/features.file-upload.post-method.php#118858
+    protected function normalise_files($files = []) {
+
+        if (empty($files)) return [];
+
+        $normalised_array = [];
+
+        foreach($files as $index => $file) {
+
+            // Single upload only
+            if (!is_array($file['name'])) {
+                $normalised_array[$index][] = $file;
+                continue;
+            }
+
+            // Multi-file upload
+            foreach($file['name'] as $idx => $name) {
+                $normalised_array[$index][$idx] = [
+                    'name' => $name,
+                    'type' => $file['type'][$idx],
+                    'tmp_name' => $file['tmp_name'][$idx],
+                    'error' => $file['error'][$idx],
+                    'size' => $file['size'][$idx]
+                ];
+            }
+
+        }
+        return $normalised_array;
+    }
+
+    // Temporarily hook directly into PHPMailer so we can attach files AND set their names
+    // Ref: https://stackoverflow.com/a/57778561
+    public function attach_files(\PHPMailer $PHPMailer) {
+
+        $files = $this->normalise_files($_FILES);
+
+        foreach ($files as $upload) {
+            foreach ($upload as $file) {
+                if ($file['error'] === UPLOAD_ERR_OK) {
+                    $PHPMailer->addAttachment($file['tmp_name'], $file['name']);
+                }
+            }
+        }
+
+    }
+
     public function handle_ajax_request() {
 
         // Here we handle the form ajax requests, ensuring they're valid and loading the appropriate sub-class
@@ -170,7 +219,7 @@ class Base
         /* @var $form Base */
         $form = new $this->forms[$form_name];
         $form->init();
-        $form->data = $this->get_form_data();
+        $form->data = $form->get_request_data();
         $form->config = $config;
         $form->set_destination($config['destination']);
         $form->process();
@@ -198,7 +247,6 @@ class Base
 
         // Gather filled fields into label/value pairs
         foreach ($this->fields as $field_name => $field) {
-            // TODO: handle file upload fields - should it be removed from HTML and just attached? Or include the filename reference?
             if ($value = $this->get_data($field_name)) {
                 $data[$field->get_title()] = $value;
             }
@@ -206,7 +254,10 @@ class Base
 
         $message = view('forms.common.email', compact('data', 'subject'));
 
+        // Hook directly into WPMailer temporarily so we can add attachments and set their proper filenames
+        add_action('phpmailer_init', [$this, 'attach_files']);
         $success = wp_mail($to, $subject, $message, $headers);
+        remove_action('phpmailer_init', [$this, 'attach_files']);
 
         if ($success) {
             wp_send_json([
@@ -285,4 +336,48 @@ class Base
         return $this->fields;
     }
 
+    /**
+     * Sanitize array with values before sending. Can be called recursively.
+     *
+     * @param mixed $value
+     * @return mixed
+     */
+    public function sanitize( $value ) {
+        if ( is_string( $value ) ) {
+            // do nothing if empty string
+            if ( $value === '' ) {
+                return $value;
+            }
+
+            // strip slashes
+            $value = stripslashes( $value );
+
+            // strip all whitespace
+            $value = trim( $value );
+
+            // convert &amp; back to &
+            $value = html_entity_decode( $value, ENT_NOQUOTES );
+        } elseif ( is_array( $value ) || is_object( $value ) ) {
+            $new_value = array();
+            $vars      = is_array( $value ) ? $value : get_object_vars( $value );
+
+            // do nothing if empty array or object
+            if ( count( $vars ) === 0 ) {
+                return $value;
+            }
+
+            foreach ( $vars as $key => $sub_value ) {
+                // strip all whitespace & HTML from keys (!)
+                $key = trim( strip_tags( $key ) );
+
+                // sanitize sub value
+                $new_value[ $key ] = $this->sanitize( $sub_value );
+            }
+
+            $value = is_object( $value ) ? (object) $new_value : $new_value;
+        }
+
+        return $value;
+    }
+
 }
diff --git a/wp-content/mu-plugins/cube/src/Forms/Builder/Fields/File.php b/wp-content/mu-plugins/cube/src/Forms/Builder/Fields/File.php
new file mode 100644 (file)
index 0000000..f5d03f4
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+namespace Cube\Forms\Builder\Fields;
+
+use Cube\Forms\Builder\Field;
+
+class File extends Field
+{
+    protected $multiple = false;
+
+    public function multiple($is_multiple) {
+        $this->multiple = $is_multiple;
+        return $this;
+    }
+
+    public function is_multiple() {
+        return $this->multiple;
+    }
+
+    public function render($settings) {
+
+        // Handle multiple uploads if needed
+        $multiple_attribute = $this->is_multiple() ? 'multiple' : '';
+        $field_name = $this->is_multiple() ? $this->get_name() .'[]' : $this->get_name(); // Give PHP array for multiple files
+
+        $res  = '<input type="file" name="'. $field_name .'" id="'. $this->get_name() .'" ';
+        $res .= 'class="file-input" ';
+        $res .= $multiple_attribute .' ';
+        $res .= 'data-multiple-caption="'. __('{count} fichiers sélectionnés', 'cube') .'" ';
+        $res .= $settings['validation'];
+        $res .= '>';
+        $res .= '<label for="'. $this->get_name() .'"><span class="file-input-label-text">'. $this->get_title() .'</span></label>';
+
+        return $res;
+    }
+}
index d26a4c401e690c53d97f8a7fdb6fe9de505d46c3..74e47b2d7199598ed791405c272a4c845f925f7e 100644 (file)
@@ -11,6 +11,12 @@ class Form extends Base
         $this->fields = $fields;
     }
 
+    /**
+     * Populate missing settings with defaults
+     * @param Field $field
+     * @param array $settings
+     * @return array
+     */
     public function process_settings($field, $settings = []) {
 
         $default_settings = [
@@ -68,6 +74,23 @@ class Form extends Base
         return '<div class="form-field '. $settings['class'] .'">'. $res .'</div>';
     }
 
+    /**
+     * Get the bare field input without the wrappers
+     * @param $name
+     * @param array $settings
+     * @return bool|string
+     */
+    public function input($name, $settings = []) {
+
+        /* @var $field Field */
+        $field = $this->get_field($name);
+        if (!$field) return false;
+
+        $settings = $this->process_settings($field, $settings);
+
+        return $field->render($settings);
+    }
+
     /**
      * Generate field title
      * @param $name
index 167f9af193f0a2c48e6139d9cd3b8c611df3c822..ac7fa69c532ab88703b84e26f1b5678ce85d3d07 100644 (file)
@@ -4,6 +4,7 @@ namespace Cube\Forms;
 
 use Cube\Forms\Builder\Fields\Date;
 use Cube\Forms\Builder\Fields\Email;
+use Cube\Forms\Builder\Fields\File;
 use Cube\Forms\Builder\Fields\Radio;
 use Cube\Forms\Builder\Fields\Text;
 
@@ -57,7 +58,10 @@ class Training extends Base
 
             //== TYPE OF TRAINING
             // Since this question has a complex layout, the options are set in the template (formation.blade.php)
-            Radio::field('training-type', __('Par quel type de formation au CCV Montpellier êtes-vous intéressé ?', 'ccv'))
+            Radio::field('training-type', __('Par quel type de formation au CCV Montpellier êtes-vous intéressé ?', 'ccv')),
+            File::field('attachments', __('Joindre vos documents', 'ccv'))
+                ->multiple(true)
+                ->required(false),
 
         ]);
     }
diff --git a/wp-content/themes/CCV/resources/assets/scripts/forms/file-upload.js b/wp-content/themes/CCV/resources/assets/scripts/forms/file-upload.js
new file mode 100644 (file)
index 0000000..089543d
--- /dev/null
@@ -0,0 +1,26 @@
+// Based on https://tympanus.net/codrops/2015/09/15/styling-customizing-file-inputs-smart-way/
+let inputs = document.querySelectorAll('.file-input');
+inputs.forEach(function(input) {
+
+  let label     = input.nextElementSibling;
+  let labelVal = label.innerHTML;
+
+  input.addEventListener('change', function(e) {
+    let fileName = '';
+    if (this.files && this.files.length > 1)
+      fileName = (this.getAttribute('data-multiple-caption') || '').replace('{count}', this.files.length);
+    else
+      fileName = e.target.value.split('\\').pop();
+
+    if (fileName)
+      label.querySelector('span').innerHTML = fileName;
+    else
+      label.innerHTML = labelVal;
+  });
+
+  // Firefox bug fix
+  input.addEventListener('focus', function() { input.classList.add('has-focus'); });
+  input.addEventListener('blur', function() { input.classList.remove('has-focus'); });
+});
+
+module.exports = inputs;
index dcb7a0fe0d96c3b3ea9ffbafc0f406c427fc77c3..055a38e6866b6d31f9b62669b42868084b5964d3 100644 (file)
@@ -2,6 +2,7 @@
 // Inspired by HTMLForms: https://github.com/ibericode/html-forms/blob/master/assets/browserify/public.js
 
 import Loader from './form-loading-indicator';
+import './file-upload';
 
 const config = window.cube_forms_config || {};
 
@@ -34,6 +35,12 @@ function addFormMessage (formEl, message, clear) {
 
   wrapperElement.appendChild(msgElement);
   wrapperElement.classList.remove('hidden'); // Unhide messages
+
+  // Finally, scroll to the message in case it isn't in view
+  setTimeout(function() {
+    let wrapperOffsetTop = Math.max(0, wrapperElement.getBoundingClientRect().top + window.scrollY - 100); // Scroll just above it to give some space
+    window.scrollTo({...{top: wrapperOffsetTop}, ...{behavior: 'smooth'}});
+  }, 50);
 }
 
 function handleSubmitEvents (e) {
index b6aa4ab34c06698d22270430e4dd600303ade893..1c4ed699886f8e0902d506792819ae0f900ce112 100644 (file)
@@ -5,6 +5,8 @@
     margin-top: 1.5em
   .spaced-lg > * + *
     margin-top: 2em
+  .spaced-none > * + *
+    margin-top: 0
 
   .spaced-horizontal > * + *
     margin-left: 0.75em
index 2a5693aa38d4a2daa86f04a81722fa985fd74c47..c58c9869078efb8be45f33979b45dcf923beaa9b 100644 (file)
@@ -85,10 +85,16 @@ input[type="submit"]
     box-shadow: inset 0 0 0 4px #fff // Inner border for checkbox
     cursor: pointer
 
-  .form-label:before
-    margin-right: 0.5em
-  .form-label-reversed:after
-    margin-left: 0.5em
+  .form-label
+    display: flex
+    align-items: center
+
+    &:before
+      flex-shrink: 0
+      margin-right: 0.5rem
+
+    &-reversed:after
+      margin-left: 0.5rem
 
   input:checked ~ .form-label:before,
   input:checked ~ .form-label-reversed:after
@@ -101,6 +107,13 @@ input[type="submit"]
     transform: translateY(-50%)
     visibility: hidden
 
+// File upload input label
+.file-input-label-text
+  display: block
+  overflow: hidden
+  white-space: nowrap
+  text-overflow: ellipsis
+  cursor: pointer
 
 // Common form styling
 .form-field-title
@@ -108,11 +121,15 @@ input[type="submit"]
   margin-bottom: 0.75em
 
 .form-field-input
-  > *:not(:first-child)
-    margin-left: 1.5em
+  > label:not(:last-child)
+    margin-right: 1.5em
+    margin-bottom: 0.25em
 
   label
+    display: inline-block
     white-space: nowrap // Ensure text stays beside input control
+    +below(500px)
+      white-space: normal
 
 .form-field-date
   &.date-field-compact input
@@ -127,20 +144,26 @@ input[type="submit"]
   justify-content: space-between
 
 .form-cols-2
+  // Offset the margins on children to avoid bigger gap at bottom
+  margin-bottom: -2em
+
   > *
     flex-basis: 47%
+    margin-bottom: 2em
 
     +below(1024px)
       flex-basis: 100%
-      margin-bottom: 2em
 
 .form-cols-4
+  // Offset the margins on children to avoid bigger gap at bottom
+  margin-bottom: -2em
+
   > *
     flex-basis: 23%
+    margin-bottom: 2em
 
     +below(1400px)
       flex-basis: 47%
-      margin-bottom: 2em
     +below(600px)
       flex-basis: 100%
 
index e47277949fdc238bf709d5c7ae010b6b33d95c70..77dc684b40bbd5255b9005c5f074c1cf48f256fd 100644 (file)
@@ -11,6 +11,8 @@ h1, .h1
 
 h2, .h2
   @apply text-2xl
+  +below(500px)
+    @apply text-xl
 
 // Pink dash
 h1, .h1, h2, .h2, .decorated
index a81b6e2a5fa2b98d8498d042a3a1f4dbe66621b8..a251eeb4dae6f431d1179530305c8ad793d9b9a8 100644 (file)
     {{-- IMAGES FROM CD --}}
     <div class="imagery-type-wrapper flex mb-8">
       <input type="radio" id="imagery_cd" name="imagery-type" value="{{ __('') }}">
-      <label for="imagery_cd" class="imagery-icon">@svg('imagery-cd')</label>
+      <label for="imagery_cd" class="imagery-icon">@svg('imagery-cd', 'w-22 md:w-20 sm:w-16')</label>
       <div class="ml-4">
-        <div class="text-lg font-normal leading-tight mb-2">
+        <div class="text-lg sm:text-base font-normal leading-tight mb-1">
           {{ __('Vos images sont sur un CD ?', 'ccv') }}
         </div>
-        <p>
+        <p class="mt-0 sm:text-sm">
           {{ __("Envoyez-nous l'ensemble des fichiers contenus sur votre CD :", "ccv") }}
         </p>
 
     {{-- IMAGES ONLINE --}}
     <div class="imagery-type-wrapper flex mb-8">
       <input type="radio" id="imagery_web" name="imagery-type" value="{{ __('Images en ligne', 'ccv') }}">
-      <label for="imagery_web" class="imagery-icon">@svg('imagery-web')</label>
+      <label for="imagery_web" class="imagery-icon">@svg('imagery-web', 'w-22 md:w-20 sm:w-16')</label>
       <div class="ml-4">
-        <div class="text-lg font-normal leading-tight mb-2">
+        <div class="text-lg sm:text-base font-normal leading-tight mb-1">
           {{ __('Vous avez reçu un lien pour consulter vos images en ligne ?', 'ccv') }}
         </div>
-        <p class="mb-0! pb-2">
+        <p class="mt-0 mb-0! pb-2 sm:text-sm">
           {{ __('Collez votre lien ci-dessous ainsi que vos identifiant et mot de passe :', 'ccv') }}
         </p>
         <textarea name="imagery-online" class="min-h-0 h-20" data-update-imagery-type></textarea>
     {{-- IMAGES FROM PHONE --}}
     <div class="imagery-type-wrapper flex mb-8">
       <input type="radio" id="imagery_phone" name="imagery-type" value="{{ __('Images téléversées depuis portable') }}">
-      <label for="imagery_phone" class="imagery-icon">@svg('imagery-phone')</label>
+      <label for="imagery_phone" class="imagery-icon">@svg('imagery-phone', 'w-22 md:w-20 sm:w-16')</label>
       <div class="ml-4">
-        <div class="text-lg font-normal leading-tight mb-2">
+        <div class="text-lg sm:text-base font-normal leading-tight mb-1">
           {{ __('Vous remplissez cette demande depuis votre téléphone ?', 'ccv') }}
         </div>
-        <p>
+        <p class="mt-0 sm:text-sm">
           {{ __('Prenez vos images en photo et envoyez-les directement depuis votre téléphone :', 'ccv') }}
         </p>
         <a href="javascript://" class="btn mt-6" data-update-imagery-type>{{ __('Parcourir') }}</a>
     {{-- IMAGES SENT BY POST --}}
     <div class="imagery-type-wrapper flex mb-8">
       <input type="radio" id="imagery_post" name="imagery-type" value="{{ __('Images envoyé par courrier', 'ccv') }}">
-      <label for="imagery_post" class="imagery-icon">@svg('imagery-post')</label>
+      <label for="imagery_post" class="imagery-icon">@svg('imagery-post', 'w-22 md:w-20 sm:w-16')</label>
       <div class="ml-4">
-        <div class="text-lg font-normal leading-tight mb-2">
+        <div class="text-lg sm:text-base font-normal leading-tight mb-1">
           {{ __('Vous pouvez aussi nous envoyer vos images par courrier :', 'ccv') }}
         </div>
-        <p>
+        <p class="mt-4 sm:text-sm">
           CCV MONTPELLIER<br>
           AVIS MEDICAL<br>
           Clinique du parc - 50 Rue Emile Combes,<br>
         'placeholder' => '',
         'field_after' => __('ans', 'ccv')
       ]) !!}
-      {!! $form->field('message', ['title_class' => 'font-light']) !!}
+      {!! $form->field('message', ['class' => 'mt-6', 'title_class' => 'font-light']) !!}
 
-      <div class="mt-3">
+      <div class="mt-4">
         <div class="custom-checkbox mb-1">
           <label>
             <input type="checkbox" name="send-to-team" value="{{ __('Oui') }}" checked class="ml-2">
index eb0ee713e25332c685e6edc0f79826e5366edf8f..0bed94e766567989b0854f7aa493352ce7f68015 100644 (file)
@@ -5,10 +5,14 @@
 <div class="bg-light text-block-body py-2v pl-4v pr-3v xs:px-2v">
   <h2>{{ __('1. Votre identité', 'ccv') }}</h2>
 
-  <div class="form-cols-2 mt-1v">
+  <div class="spaced-lg">
 
-    <div class="spaced-lg">
+    <div class="form-cols-2">
       {!! $form->field('last-name', ['show_title' => false]) !!}
+      {!! $form->field('first-name', ['show_title' => false]) !!}
+    </div>
+
+    <div class="form-cols-2">
       {!!
         $form->field('birth-date', [
           'show_title' => false,
           'show_icon' => false,
         ])
       !!}
-      {!! $form->field('phone', ['show_title' => false]) !!}
-      {!! $form->field('country-residence', ['show_title' => false]) !!}
+      {!! $form->field('email', ['show_title' => false]) !!}
     </div>
 
-    <div class="spaced-lg">
-      {!! $form->field('first-name', ['show_title' => false]) !!}
-      {!! $form->field('email', ['show_title' => false]) !!}
+    <div class="form-cols-2">
+      {!! $form->field('phone', ['show_title' => false]) !!}
       {!! $form->field('country-training', ['show_title' => false]) !!}
     </div>
 
+    <div class="form-cols-2">
+      {!! $form->field('country-residence', ['show_title' => false]) !!}
+    </div>
   </div>
 
   <div class="my-2v spaced-lg">
 
   {!! $form->title($training_field, ['title_class' => 'text-lg font-normal']) !!}
 
-  <div class="spaced mt-1v">
+  <div class="training-type-wrapper relative spaced mt-1v">
 
     {{-- OPTION 1 --}}
     <div class="bg-white px-8 py-4 text-lg font-normal custom-checkbox">
       <label>
-        <input type="radio" name="{{ $training_field }}" value="{{ $training[0] }}">
+
+        {{-- Set up custom Parsley config for this group: required + errors put in the main wrapper --}}
+        <input type="radio" name="{{ $training_field }}" value="{{ $training[0] }}"
+               required
+               data-parsley-errors-container=".training-type-wrapper"
+               data-parsley-class-handler=".training-type-wrapper">
+
         <span class="form-label flex items-center">
         <span class="flex-1 ml-4">
           {{ $training[0] }}
     </div>
 
     {{-- OPTION 3 --}}
-    <div class="bg-white px-8 py-4 text-lg font-normal custom-checkbox">
+    <div class="bg-white px-8 py-4 text-lg font-normal custom-checkbox relative">
       <label>
         <input type="radio" name="{{ $training_field }}" value="{{ $training[2] }}">
         <span class="form-label flex items-center">
-        <span class="flex-1 ml-4">
-          {{ $training[2] }}
-          <span class="font-light text-base block">
-            {{ __('CV et lettre de motivation exigés.', 'ccv') }}
+          <span class="flex-1 ml-4 pb-16">
+            {{ $training[2] }}
+            <span class="font-light text-base block">
+              {{ __('CV et lettre de motivation exigés.', 'ccv') }}
+            </span>
           </span>
-          <a href="#" class="btn mt-6">{{ __('Joindre vos documents', 'ccv') }}</a>
         </span>
-      </span>
       </label>
+      <div class="btn absolute left-0 bottom-0 mb-6 cursor-pointer" style="margin-left: calc(24px + 3.5rem); max-width: calc(100% - 24px - 5rem);">
+        {!! $form->input('attachments') !!}
+      </div>
     </div>
 
   </div>
index 7804bb05220c53fd1c5c9892671062a2b4797d9b..090853a4ee775674917dbd3039ab682e189e804b 100644 (file)
@@ -1,4 +1,4 @@
-<footer class="site-footer container bg-purple-dark text-white antialiased pt-2v pb-1v">
+<footer class="site-footer container w-full bg-purple-dark text-white antialiased pt-2v pb-1v">
   <div class="site-footer-cols">
     <div class="site-footer-col">
       @php(dynamic_sidebar('sidebar-footer-1'))
index dcd11588422e6584a9abdcc1959f9c0e382774e6..19d97ec03560d526a9f8af54f9ded4e4931d3d06 100644 (file)
@@ -53,6 +53,7 @@ module.exports = {
         '4e': '1em',
         '5e': '1.25em',
         '6e': '1.5em',
+        '22': '5.5rem',
       },
       padding: {
         '0!': '0 !important', // Special overrides