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
}
}
}
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
/* @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();
// 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;
}
$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([
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 & 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;
+ }
+
}
--- /dev/null
+<?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;
+ }
+}
$this->fields = $fields;
}
+ /**
+ * Populate missing settings with defaults
+ * @param Field $field
+ * @param array $settings
+ * @return array
+ */
public function process_settings($field, $settings = []) {
$default_settings = [
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
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;
//== 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),
]);
}
--- /dev/null
+// 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;
// 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 || {};
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) {
margin-top: 1.5em
.spaced-lg > * + *
margin-top: 2em
+ .spaced-none > * + *
+ margin-top: 0
.spaced-horizontal > * + *
margin-left: 0.75em
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
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
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
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%
h2, .h2
@apply text-2xl
+ +below(500px)
+ @apply text-xl
// Pink dash
h1, .h1, h2, .h2, .decorated
{{-- 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">
<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>
-<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'))
'4e': '1em',
'5e': '1.25em',
'6e': '1.5em',
+ '22': '5.5rem',
},
padding: {
'0!': '0 !important', // Special overrides