Dc3e4 new matrix field (#484)
* fix password reset bug * wip: matrix input * wip: matrix input * wip: matrix input * Fixed matric input component logic * matrix input cleanup * fix lint errors * table border and radius * cleanup, linting * fix component methos * wip matrix input * matrix condition for contains and not contain * patch matrix input condition logic * linting * refactor and cleanup * fix syntax error * Polished the matrix input * Fix linting --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
fedc382594
commit
1adac8e00f
|
|
@ -4,6 +4,7 @@ namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\Forms\Form;
|
use App\Models\Forms\Form;
|
||||||
use App\Rules\CustomFieldValidationRule;
|
use App\Rules\CustomFieldValidationRule;
|
||||||
|
use App\Rules\MatrixValidationRule;
|
||||||
use App\Rules\StorageFile;
|
use App\Rules\StorageFile;
|
||||||
use App\Rules\ValidHCaptcha;
|
use App\Rules\ValidHCaptcha;
|
||||||
use App\Rules\ValidPhoneInputRule;
|
use App\Rules\ValidPhoneInputRule;
|
||||||
|
|
@ -82,9 +83,14 @@ class AnswerFormRequest extends FormRequest
|
||||||
} elseif ($property['type'] == 'rating') {
|
} elseif ($property['type'] == 'rating') {
|
||||||
// For star rating, needs a minimum of 1 star
|
// For star rating, needs a minimum of 1 star
|
||||||
$rules[] = 'min:1';
|
$rules[] = 'min:1';
|
||||||
|
} elseif ($property['type'] == 'matrix') {
|
||||||
|
$rules[] = new MatrixValidationRule($property, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$rules[] = 'nullable';
|
$rules[] = 'nullable';
|
||||||
|
if ($property['type'] == 'matrix') {
|
||||||
|
$rules[] = new MatrixValidationRule($property, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean id to escape "."
|
// Clean id to escape "."
|
||||||
|
|
@ -97,7 +103,7 @@ class AnswerFormRequest extends FormRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// User custom validation
|
// User custom validation
|
||||||
if(!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) {
|
if (!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) {
|
||||||
$rules[] = (new CustomFieldValidationRule($property['validation'], $data));
|
$rules[] = (new CustomFieldValidationRule($property['validation'], $data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,34 @@ class FormPropertyLogicRule implements DataAwareRule, ValidationRule
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'matrix' => [
|
||||||
|
'comparators' => [
|
||||||
|
'equals' => [
|
||||||
|
'expected_type' => 'object',
|
||||||
|
'format' => [
|
||||||
|
'type' => 'object',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'does_not_equal' => [
|
||||||
|
'expected_type' => 'object',
|
||||||
|
'format' => [
|
||||||
|
'type' => 'object',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'contains' => [
|
||||||
|
'expected_type' => 'object',
|
||||||
|
'format' => [
|
||||||
|
'type' => 'object',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'does_not_contain' => [
|
||||||
|
'expected_type' => 'object',
|
||||||
|
'format' => [
|
||||||
|
'type' => 'object',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
'url' => [
|
'url' => [
|
||||||
'comparators' => [
|
'comparators' => [
|
||||||
'equals' => [
|
'equals' => [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class MatrixValidationRule implements ValidationRule
|
||||||
|
{
|
||||||
|
protected $field;
|
||||||
|
protected $isRequired;
|
||||||
|
|
||||||
|
public function __construct(array $field, bool $isRequired)
|
||||||
|
{
|
||||||
|
$this->field = $field;
|
||||||
|
$this->isRequired = $isRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (!$this->isRequired && empty($value)) {
|
||||||
|
return; // If not required and empty, validation passes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($value)) {
|
||||||
|
$fail('The Matrix field must be an array.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->field['rows'];
|
||||||
|
$columns = $this->field['columns'];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!array_key_exists($row, $value)) {
|
||||||
|
if ($this->isRequired) {
|
||||||
|
$fail("Missing value for row '{$row}'.");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cellValue = $value[$row];
|
||||||
|
|
||||||
|
if ($cellValue === null) {
|
||||||
|
if ($this->isRequired) {
|
||||||
|
$fail("Value for row '{$row}' is required.");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($cellValue, $columns)) {
|
||||||
|
$fail("Invalid value '{$cellValue}' for row '{$row}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for extra rows that shouldn't be there
|
||||||
|
$extraRows = array_diff(array_keys($value), $rows);
|
||||||
|
foreach ($extraRows as $extraRow) {
|
||||||
|
$fail("Unexpected row '{$extraRow}' in the matrix.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,8 @@ class FormLogicConditionChecker
|
||||||
return $this->multiSelectConditionMet($propertyCondition, $value);
|
return $this->multiSelectConditionMet($propertyCondition, $value);
|
||||||
case 'files':
|
case 'files':
|
||||||
return $this->filesConditionMet($propertyCondition, $value);
|
return $this->filesConditionMet($propertyCondition, $value);
|
||||||
|
case 'matrix':
|
||||||
|
return $this->matrixConditionMet($propertyCondition, $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -90,6 +92,30 @@ class FormLogicConditionChecker
|
||||||
return \Str::contains($fieldValue, $condition['value']);
|
return \Str::contains($fieldValue, $condition['value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkMatrixContains($condition, $fieldValue): bool
|
||||||
|
{
|
||||||
|
|
||||||
|
foreach($condition['value'] as $key => $value) {
|
||||||
|
if(!(array_key_exists($key, $condition['value']) && array_key_exists($key, $fieldValue))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if($condition['value'][$key] == $fieldValue[$key]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkMatrixEquals($condition, $fieldValue): bool
|
||||||
|
{
|
||||||
|
foreach($condition['value'] as $key => $value) {
|
||||||
|
if($condition['value'][$key] !== $fieldValue[$key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private function checkListContains($condition, $fieldValue): bool
|
private function checkListContains($condition, $fieldValue): bool
|
||||||
{
|
{
|
||||||
if (is_null($fieldValue)) {
|
if (is_null($fieldValue)) {
|
||||||
|
|
@ -408,4 +434,20 @@ class FormLogicConditionChecker
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function matrixConditionMet(array $propertyCondition, $value): bool
|
||||||
|
{
|
||||||
|
switch ($propertyCondition['operator']) {
|
||||||
|
case 'equals':
|
||||||
|
return $this->checkMatrixEquals($propertyCondition, $value);
|
||||||
|
case 'does_not_equal':
|
||||||
|
return !$this->checkMatrixEquals($propertyCondition, $value);
|
||||||
|
case 'contains':
|
||||||
|
return $this->checkMatrixContains($propertyCondition, $value);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return !$this->checkMatrixContains($propertyCondition, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,17 @@ class FormSubmissionFormatter
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMatrixString(array $val): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
foreach ($val as $key => $value) {
|
||||||
|
if ($key !== null && $value !== null) {
|
||||||
|
$parts[] = "$key: $value";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode(' | ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a nice "FieldName": "Field Response" array
|
* Return a nice "FieldName": "Field Response" array
|
||||||
* - If createLink enabled, returns html link for emails and links
|
* - If createLink enabled, returns html link for emails and links
|
||||||
|
|
@ -145,7 +156,9 @@ class FormSubmissionFormatter
|
||||||
} else {
|
} else {
|
||||||
$returnArray[$field['name']] = $val;
|
$returnArray[$field['name']] = $val;
|
||||||
}
|
}
|
||||||
} elseif (in_array($field['type'], ['files', 'signature'])) {
|
} elseif ($field['type'] == 'matrix' && is_array($data[$field['id']])) {
|
||||||
|
$returnArray[$field['name']] = $this->getMatrixString($data[$field['id']]);
|
||||||
|
} elseif ($field['type'] == 'files') {
|
||||||
if ($this->outputStringsOnly) {
|
if ($this->outputStringsOnly) {
|
||||||
$formId = $this->form->id;
|
$formId = $this->form->id;
|
||||||
$returnArray[$field['name']] = implode(
|
$returnArray[$field['name']] = implode(
|
||||||
|
|
@ -219,7 +232,9 @@ class FormSubmissionFormatter
|
||||||
} else {
|
} else {
|
||||||
$field['value'] = $val;
|
$field['value'] = $val;
|
||||||
}
|
}
|
||||||
} elseif (in_array($field['type'], ['files', 'signature'])) {
|
} elseif ($field['type'] == 'matrix') {
|
||||||
|
$field['value'] = str_replace(' | ', "\n", $this->getMatrixString($data[$field['id']]));
|
||||||
|
} elseif ($field['type'] == 'files') {
|
||||||
if ($this->outputStringsOnly) {
|
if ($this->outputStringsOnly) {
|
||||||
$formId = $this->form->id;
|
$formId = $this->form->id;
|
||||||
$field['value'] = implode(
|
$field['value'] = implode(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
theme.default.input,
|
theme.default.input,
|
||||||
theme.default.borderRadius,
|
theme.default.borderRadius,
|
||||||
{
|
{
|
||||||
'mb-2': index !== options.length,
|
|
||||||
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||||
'!cursor-not-allowed !bg-gray-200': disabled,
|
'!cursor-not-allowed !bg-gray-200': disabled,
|
||||||
},
|
},
|
||||||
|
|
@ -38,31 +37,17 @@
|
||||||
@click="onSelect(option[optionKey])"
|
@click="onSelect(option[optionKey])"
|
||||||
>
|
>
|
||||||
<template v-if="multiple">
|
<template v-if="multiple">
|
||||||
<Icon
|
<CheckboxIcon
|
||||||
v-if="isSelected(option[optionKey])"
|
:is-checked="isSelected(option[optionKey])"
|
||||||
name="material-symbols:check-box"
|
|
||||||
class="text-inherit"
|
|
||||||
:color="color"
|
:color="color"
|
||||||
:class="[theme.FlatSelectInput.icon]"
|
:theme="theme"
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
v-else
|
|
||||||
name="material-symbols:check-box-outline-blank"
|
|
||||||
:class="[theme.FlatSelectInput.icon,theme.FlatSelectInput.unselectedIcon]"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Icon
|
<RadioButtonIcon
|
||||||
v-if="isSelected(option[optionKey])"
|
:is-checked="isSelected(option[optionKey])"
|
||||||
name="material-symbols:radio-button-checked-outline"
|
|
||||||
class="text-inherit"
|
|
||||||
:color="color"
|
:color="color"
|
||||||
:class="[theme.FlatSelectInput.icon]"
|
:theme="theme"
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
v-else
|
|
||||||
name="material-symbols:radio-button-unchecked"
|
|
||||||
:class="[theme.FlatSelectInput.icon,theme.FlatSelectInput.unselectedIcon]"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<p class="flex-grow">
|
<p class="flex-grow">
|
||||||
|
|
@ -96,13 +81,15 @@
|
||||||
<script>
|
<script>
|
||||||
import {inputProps, useFormInput} from "./useFormInput.js"
|
import {inputProps, useFormInput} from "./useFormInput.js"
|
||||||
import InputWrapper from "./components/InputWrapper.vue"
|
import InputWrapper from "./components/InputWrapper.vue"
|
||||||
|
import RadioButtonIcon from "./components/RadioButtonIcon.vue"
|
||||||
|
import CheckboxIcon from "./components/CheckboxIcon.vue"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options: {name,value} objects
|
* Options: {name,value} objects
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
name: "FlatSelectInput",
|
name: "FlatSelectInput",
|
||||||
components: {InputWrapper},
|
components: {InputWrapper, RadioButtonIcon, CheckboxIcon},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
...inputProps,
|
...inputProps,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
<template>
|
||||||
|
<input-wrapper v-bind="inputWrapperProps">
|
||||||
|
<template #label>
|
||||||
|
<slot name="label" />
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="border border-gray-300"
|
||||||
|
:class="[
|
||||||
|
theme.default.borderRadius,
|
||||||
|
{
|
||||||
|
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||||
|
'!cursor-not-allowed !bg-gray-300': disabled,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
class="w-full table-fixed overflow-hidden"
|
||||||
|
>
|
||||||
|
<thead class="">
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column"
|
||||||
|
class="border-l border-gray-300"
|
||||||
|
>
|
||||||
|
<div class="p-2 w-full flex items-center justify-center capitalize text-sm truncate">
|
||||||
|
{{ column }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row, rowIndex in rows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="border-t border-gray-300"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="w-full flex-grow p-2 text-sm truncate">
|
||||||
|
{{ row }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="row + column"
|
||||||
|
class="border-l border-gray-300 hover:!bg-gray-100 dark:hover:!bg-gray-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="compVal"
|
||||||
|
class="w-full flex items-center justify-center"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="compVal[row] === column"
|
||||||
|
:class="[
|
||||||
|
theme.FlatSelectInput.spacing.vertical,
|
||||||
|
theme.FlatSelectInput.fontSize,
|
||||||
|
theme.FlatSelectInput.option,
|
||||||
|
]"
|
||||||
|
@click="onSelect(row, column)"
|
||||||
|
>
|
||||||
|
<RadioButtonIcon
|
||||||
|
:key="row+column"
|
||||||
|
:is-checked="compVal[row] === column"
|
||||||
|
:color="color"
|
||||||
|
:theme="theme"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template #help>
|
||||||
|
<slot name="help" />
|
||||||
|
</template>
|
||||||
|
<template #error>
|
||||||
|
<slot name="error" />
|
||||||
|
</template>
|
||||||
|
</input-wrapper>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import {inputProps, useFormInput} from "./useFormInput.js"
|
||||||
|
import InputWrapper from "./components/InputWrapper.vue"
|
||||||
|
import RadioButtonIcon from "./components/RadioButtonIcon.vue"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "MatrixInput",
|
||||||
|
components: {InputWrapper, RadioButtonIcon},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
...inputProps,
|
||||||
|
rows: {type: Array, required: true},
|
||||||
|
columns: {type: Array, required: true},
|
||||||
|
},
|
||||||
|
setup(props, context) {
|
||||||
|
return {
|
||||||
|
...useFormInput(props, context),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
beforeMount() {
|
||||||
|
if (!this.compVal || typeof this.compVal !== 'object') {
|
||||||
|
this.compVal = {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSelect(row, column) {
|
||||||
|
if (this.compVal[row] === column && !this.required) {
|
||||||
|
this.compVal[row] = null
|
||||||
|
} else {
|
||||||
|
this.compVal[row] = column
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<Icon
|
||||||
|
:name="isChecked ? 'material-symbols:check-box' : 'material-symbols:check-box-outline-blank'"
|
||||||
|
:class="[
|
||||||
|
theme.FlatSelectInput.icon,
|
||||||
|
isChecked ? '' : theme.FlatSelectInput.unselectedIcon,
|
||||||
|
]"
|
||||||
|
:color="isChecked ? color : undefined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
isChecked: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<Icon
|
||||||
|
:name="isChecked ? 'ic:round-radio-button-checked' : 'ic:round-radio-button-unchecked'"
|
||||||
|
:class="[
|
||||||
|
theme.FlatSelectInput.icon,
|
||||||
|
isChecked ? '' : theme.FlatSelectInput.unselectedIcon,
|
||||||
|
]"
|
||||||
|
:color="isChecked ? color : undefined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isChecked: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -448,6 +448,8 @@ export default {
|
||||||
formData[field.id] = urlPrefill.getAll(field.id + '[]')
|
formData[field.id] = urlPrefill.getAll(field.id + '[]')
|
||||||
} else if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'
|
} else if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today'
|
||||||
formData[field.id] = new Date().toISOString()
|
formData[field.id] = new Date().toISOString()
|
||||||
|
} else if (field.type === 'matrix') {
|
||||||
|
formData[field.id] = {...field.prefill}
|
||||||
} else { // Default prefill if any
|
} else { // Default prefill if any
|
||||||
formData[field.id] = field.prefill
|
formData[field.id] = field.prefill
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
getFieldWidthClasses(field),
|
getFieldWidthClasses(field),
|
||||||
{
|
{
|
||||||
'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md':adminPreview,
|
'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:!bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md':adminPreview,
|
||||||
'bg-blue-50 hover:!bg-blue-50 dark:bg-gray-800 rounded-md': beingEdited,
|
'bg-blue-50 hover:!bg-blue-50 dark:bg-gray-800 rounded-md': beingEdited,
|
||||||
}]"
|
}]"
|
||||||
>
|
>
|
||||||
|
|
@ -95,8 +95,12 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="hidden group-hover/nffield:flex translate-x-full absolute right-0 top-0 h-full w-5 flex-col justify-center pl-1 pt-3">
|
<div
|
||||||
<div class="flex items-center bg-gray-100 dark:bg-gray-800 border rounded-md h-12 text-gray-500 dark:text-gray-400 dark:border-gray-500 cursor-grab handle">
|
class="hidden group-hover/nffield:flex translate-x-full absolute right-0 top-0 h-full w-5 flex-col justify-center pl-1 pt-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center bg-gray-100 dark:bg-gray-800 border rounded-md h-12 text-gray-500 dark:text-gray-400 dark:border-gray-500 cursor-grab handle"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="clarity:drag-handle-line"
|
name="clarity:drag-handle-line"
|
||||||
class="h-6 w-6 -ml-1 block shrink-0"
|
class="h-6 w-6 -ml-1 block shrink-0"
|
||||||
|
|
@ -200,7 +204,8 @@ export default {
|
||||||
checkbox: 'CheckboxInput',
|
checkbox: 'CheckboxInput',
|
||||||
url: 'TextInput',
|
url: 'TextInput',
|
||||||
email: 'TextInput',
|
email: 'TextInput',
|
||||||
phone_number: 'TextInput'
|
phone_number: 'TextInput',
|
||||||
|
matrix: 'MatrixInput'
|
||||||
}[field.type]
|
}[field.type]
|
||||||
},
|
},
|
||||||
isPublicFormPage() {
|
isPublicFormPage() {
|
||||||
|
|
@ -299,6 +304,11 @@ export default {
|
||||||
isDark: this.darkMode
|
isDark: this.darkMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'matrix') {
|
||||||
|
inputProperties.rows = field.rows
|
||||||
|
inputProperties.columns = field.columns
|
||||||
|
}
|
||||||
|
|
||||||
if (['select', 'multi_select'].includes(field.type)) {
|
if (['select', 'multi_select'].includes(field.type)) {
|
||||||
inputProperties.options = (_has(field, field.type))
|
inputProperties.options = (_has(field, field.type))
|
||||||
? field[field.type].options.map(option => {
|
? field[field.type].options.map(option => {
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,11 @@ export default {
|
||||||
title: "Signature Input",
|
title: "Signature Input",
|
||||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />',
|
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "matrix",
|
||||||
|
title: "Matrix Input",
|
||||||
|
icon: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"></path>',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
layoutBlocks: [
|
layoutBlocks: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export default {
|
||||||
url: "TextInput",
|
url: "TextInput",
|
||||||
email: "TextInput",
|
email: "TextInput",
|
||||||
phone_number: "TextInput",
|
phone_number: "TextInput",
|
||||||
|
matrix: "MatrixInput",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -93,6 +94,10 @@ export default {
|
||||||
} else if (this.property.type === "checkbox") {
|
} else if (this.property.type === "checkbox") {
|
||||||
componentData.label = this.property.name
|
componentData.label = this.property.name
|
||||||
}
|
}
|
||||||
|
else if (this.property.type === "matrix"){
|
||||||
|
componentData.rows = this.property.rows
|
||||||
|
componentData.columns = this.property.columns
|
||||||
|
}
|
||||||
|
|
||||||
return componentData
|
return componentData
|
||||||
},
|
},
|
||||||
|
|
@ -184,8 +189,9 @@ export default {
|
||||||
) {
|
) {
|
||||||
this.content.value = {}
|
this.content.value = {}
|
||||||
} else if (
|
} else if (
|
||||||
typeof this.content.value === "boolean" ||
|
this.property.type !== 'matrix' &&
|
||||||
typeof this.content.value === "object"
|
(typeof this.content.value === 'boolean' ||
|
||||||
|
typeof this.content.value === 'object')
|
||||||
) {
|
) {
|
||||||
this.content.value = null
|
this.content.value = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,11 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MatrixFieldOptions
|
||||||
|
:model-value="field"
|
||||||
|
@update:model-value="field = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Text Options -->
|
<!-- Text Options -->
|
||||||
<div
|
<div
|
||||||
v-if="field.type === 'text' && displayBasedOnAdvanced"
|
v-if="field.type === 'text' && displayBasedOnAdvanced"
|
||||||
|
|
@ -290,7 +295,6 @@
|
||||||
Advanced options for your select/multiselect fields.
|
Advanced options for your select/multiselect fields.
|
||||||
</p>
|
</p>
|
||||||
<text-area-input
|
<text-area-input
|
||||||
v-model="optionsText"
|
|
||||||
:name="field.id + '_options_text'"
|
:name="field.id + '_options_text'"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
label="Set selection options"
|
label="Set selection options"
|
||||||
|
|
@ -423,6 +427,15 @@
|
||||||
label="Pre-filled value"
|
label="Pre-filled value"
|
||||||
:multiple="field.type === 'multi_select'"
|
:multiple="field.type === 'multi_select'"
|
||||||
/>
|
/>
|
||||||
|
<template v-else-if="field.type === 'matrix'">
|
||||||
|
<MatrixInput
|
||||||
|
:form="field"
|
||||||
|
:rows="field.rows"
|
||||||
|
:columns="field.columns"
|
||||||
|
name="prefill"
|
||||||
|
label="Pre-filled value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<date-input
|
<date-input
|
||||||
v-else-if="field.type === 'date' && field.prefill_today !== true"
|
v-else-if="field.type === 'date' && field.prefill_today !== true"
|
||||||
name="prefill"
|
name="prefill"
|
||||||
|
|
@ -608,12 +621,13 @@ import countryCodes from '~/data/country_codes.json'
|
||||||
import CountryFlag from 'vue-country-flag-next'
|
import CountryFlag from 'vue-country-flag-next'
|
||||||
import FormBlockLogicEditor from '../../components/form-logic-components/FormBlockLogicEditor.vue'
|
import FormBlockLogicEditor from '../../components/form-logic-components/FormBlockLogicEditor.vue'
|
||||||
import CustomFieldValidation from '../../components/CustomFieldValidation.vue'
|
import CustomFieldValidation from '../../components/CustomFieldValidation.vue'
|
||||||
|
import MatrixFieldOptions from './MatrixFieldOptions.vue'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { default as _has } from 'lodash/has'
|
import { default as _has } from 'lodash/has'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FieldOptions',
|
name: 'FieldOptions',
|
||||||
components: { CountryFlag, FormBlockLogicEditor, CustomFieldValidation },
|
components: { CountryFlag, FormBlockLogicEditor, CustomFieldValidation, MatrixFieldOptions },
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|
@ -682,12 +696,6 @@ export default {
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
optionsText() {
|
|
||||||
if (!this.field[this.field.type]) return ''
|
|
||||||
return this.field[this.field.type].options.map(option => {
|
|
||||||
return option.name
|
|
||||||
}).join('\n')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -868,6 +876,13 @@ export default {
|
||||||
date: {
|
date: {
|
||||||
date_format: this.dateFormatOptions[0].value,
|
date_format: this.dateFormatOptions[0].value,
|
||||||
time_format: this.timeFormatOptions[0].value
|
time_format: this.timeFormatOptions[0].value
|
||||||
|
},
|
||||||
|
matrix: {
|
||||||
|
rows:['Row 1'],
|
||||||
|
columns: [1 ,2 ,3],
|
||||||
|
selection_data:{
|
||||||
|
'Row 1': null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.field.type in defaultFieldValues) {
|
if (this.field.type in defaultFieldValues) {
|
||||||
|
|
@ -877,6 +892,9 @@ export default {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updateMatrixField(newField) {
|
||||||
|
this.field = newField
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="localField.type === 'matrix'"
|
||||||
|
class="border-b py-2 px-4"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold block text-lg">
|
||||||
|
Matrix
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-400 mb-3 text-xs">
|
||||||
|
Advanced options for matrix.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="(row, i) in localField.rows"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<text-input
|
||||||
|
v-model="localField.rows[i]"
|
||||||
|
name="rows"
|
||||||
|
wrapper-class="mb-1"
|
||||||
|
@update:model-value="updateField"
|
||||||
|
/>
|
||||||
|
<button @click="removeMatrixRow(i)">
|
||||||
|
<Icon
|
||||||
|
name="heroicons:trash"
|
||||||
|
class="text-gray-300 w-4 h-4 mb-2"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
@click="addMatrixRow"
|
||||||
|
>
|
||||||
|
Add row
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="(column, i) in localField.columns"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<text-input
|
||||||
|
v-model="localField.columns[i]"
|
||||||
|
wrapper-class="mb-1"
|
||||||
|
@update:model-value="updateField"
|
||||||
|
/>
|
||||||
|
<button @click="removeMatrixColumn(i)">
|
||||||
|
<Icon
|
||||||
|
name="heroicons:trash"
|
||||||
|
class="text-gray-300 w-4 h-4 mb-2"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
@click="addMatrixColumn"
|
||||||
|
>
|
||||||
|
Add column
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const localField = ref({ ...props.modelValue })
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newField) => {
|
||||||
|
localField.value = { ...newField }
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const selectionData = computed(() => {
|
||||||
|
return Object.fromEntries(localField.value.rows?.map(row => [row, '']))
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateField() {
|
||||||
|
emit('update:modelValue', { ...localField.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMatrixRow() {
|
||||||
|
localField.value.rows.push(generateUniqueLabel(localField.value.rows, 'Row'))
|
||||||
|
localField.value.selection_data = selectionData.value
|
||||||
|
updateField()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMatrixRow(index) {
|
||||||
|
localField.value.rows.splice(index, 1)
|
||||||
|
localField.value.selection_data = selectionData.value
|
||||||
|
updateField()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMatrixColumn() {
|
||||||
|
localField.value.columns.push(generateUniqueLabel(localField.value.columns, null))
|
||||||
|
localField.value.selection_data = selectionData.value
|
||||||
|
updateField()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMatrixColumn(index) {
|
||||||
|
localField.value.columns.splice(index, 1)
|
||||||
|
localField.value.selection_data = selectionData.value
|
||||||
|
updateField()
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniqueLabel(array, prefix = null) {
|
||||||
|
let uniqueNumber = 1 // Start checking from 1
|
||||||
|
let label = prefix ? `${prefix} ${uniqueNumber}` : uniqueNumber
|
||||||
|
while (array.includes(label)) {
|
||||||
|
uniqueNumber++ // Increment if the number is found in the array
|
||||||
|
label = prefix ? `${prefix} ${uniqueNumber}` : uniqueNumber
|
||||||
|
}
|
||||||
|
return label // Return the first unique number found
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<p class="font-semibold">Prefilled values</p>
|
||||||
|
<select-input
|
||||||
|
v-for="row in matrixData"
|
||||||
|
:key="row.label"
|
||||||
|
name="prefill"
|
||||||
|
class="mt-3"
|
||||||
|
:options="row.options"
|
||||||
|
:label="row.label"
|
||||||
|
v-model="selection[row.label]"
|
||||||
|
@update:model-value="onSelection"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'MatrixPrefilledValues',
|
||||||
|
props: {
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
type: Object,
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selection: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
matrixData() {
|
||||||
|
const options = this.field.columns || []
|
||||||
|
return (this.field.row || []).map(row => {
|
||||||
|
return {
|
||||||
|
label: row,
|
||||||
|
options: options?.map(option => ({ name: option, value: option }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.selection = this.field.prefill ?? this.field.selection_data ?? {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSelection() {
|
||||||
|
this.field.prefill = this.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -140,6 +140,7 @@
|
||||||
import OpenText from "./components/OpenText.vue"
|
import OpenText from "./components/OpenText.vue"
|
||||||
import OpenUrl from "./components/OpenUrl.vue"
|
import OpenUrl from "./components/OpenUrl.vue"
|
||||||
import OpenSelect from "./components/OpenSelect.vue"
|
import OpenSelect from "./components/OpenSelect.vue"
|
||||||
|
import OpenMatrix from "./components/OpenMatrix.vue"
|
||||||
import OpenDate from "./components/OpenDate.vue"
|
import OpenDate from "./components/OpenDate.vue"
|
||||||
import OpenFile from "./components/OpenFile.vue"
|
import OpenFile from "./components/OpenFile.vue"
|
||||||
import OpenCheckbox from "./components/OpenCheckbox.vue"
|
import OpenCheckbox from "./components/OpenCheckbox.vue"
|
||||||
|
|
@ -197,6 +198,7 @@ export default {
|
||||||
scale: shallowRef(OpenText),
|
scale: shallowRef(OpenText),
|
||||||
slider: shallowRef(OpenText),
|
slider: shallowRef(OpenText),
|
||||||
select: shallowRef(OpenSelect),
|
select: shallowRef(OpenSelect),
|
||||||
|
matrix: shallowRef(OpenMatrix),
|
||||||
multi_select: shallowRef(OpenSelect),
|
multi_select: shallowRef(OpenSelect),
|
||||||
date: shallowRef(OpenDate),
|
date: shallowRef(OpenDate),
|
||||||
files: shallowRef(OpenFile),
|
files: shallowRef(OpenFile),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="value"
|
||||||
|
class="-mb-2"
|
||||||
|
>
|
||||||
|
<template v-if="matrixData">
|
||||||
|
<div
|
||||||
|
v-for="(data) in matrixData"
|
||||||
|
:key="data.label+data.value"
|
||||||
|
class="mr-2 mb-1 text-gray-700 bg-gray-100 dark:text-gray-300 rounded-md flex px-2 text-sm w-max"
|
||||||
|
>
|
||||||
|
<span class="py-0.5 pr-1 border-r border-gray-300">{{ data.label }}</span>
|
||||||
|
<span class="py-0.5 pl-1">{{ data.value }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<open-tag
|
||||||
|
v-else
|
||||||
|
:opt="value"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import OpenTag from "./OpenTag.vue"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { OpenTag },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
matrixData() {
|
||||||
|
if (typeof this.value === "object" && this.value !== null) {
|
||||||
|
return Object.entries(this.value).map(([label, value]) => {
|
||||||
|
return { label, value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -231,6 +231,34 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"matrix": {
|
||||||
|
"comparators": {
|
||||||
|
"equals": {
|
||||||
|
"expected_type": "object",
|
||||||
|
"format": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"does_not_equal": {
|
||||||
|
"expected_type": "object",
|
||||||
|
"format": {
|
||||||
|
"type":"object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"expected_type": "object",
|
||||||
|
"format": {
|
||||||
|
"type":"object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"does_not_contain": {
|
||||||
|
"expected_type": "object",
|
||||||
|
"format": {
|
||||||
|
"type":"object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"number": {
|
"number": {
|
||||||
"comparators": {
|
"comparators": {
|
||||||
"equals": {
|
"equals": {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { default as _isEqual } from "lodash/isEqual"
|
||||||
|
|
||||||
export function conditionsMet(conditions, formData) {
|
export function conditionsMet(conditions, formData) {
|
||||||
if (conditions === undefined || conditions === null) {
|
if (conditions === undefined || conditions === null) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -59,6 +61,8 @@ function propertyConditionMet(propertyCondition, value) {
|
||||||
return multiSelectConditionMet(propertyCondition, value)
|
return multiSelectConditionMet(propertyCondition, value)
|
||||||
case "files":
|
case "files":
|
||||||
return filesConditionMet(propertyCondition, value)
|
return filesConditionMet(propertyCondition, value)
|
||||||
|
case "matrix":
|
||||||
|
return matrixConditionMet(propertyCondition, value)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +71,24 @@ function checkEquals(condition, fieldValue) {
|
||||||
return condition.value === fieldValue
|
return condition.value === fieldValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkObjectEquals(condition, fieldValue) {
|
||||||
|
return _isEqual(condition.value, fieldValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMatrixContains(condition, fieldValue)
|
||||||
|
{
|
||||||
|
if (typeof fieldValue === "undefined" || typeof fieldValue !== "object") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const conditionValue = condition.value
|
||||||
|
for (const key in conditionValue) {
|
||||||
|
if(conditionValue[key] == fieldValue[key]){
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function checkContains(condition, fieldValue) {
|
function checkContains(condition, fieldValue) {
|
||||||
return fieldValue ? fieldValue.includes(condition.value) : false
|
return fieldValue ? fieldValue.includes(condition.value) : false
|
||||||
}
|
}
|
||||||
|
|
@ -371,3 +393,17 @@ function filesConditionMet(propertyCondition, value) {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matrixConditionMet(propertyCondition, value) {
|
||||||
|
switch (propertyCondition.operator) {
|
||||||
|
case "equals":
|
||||||
|
return checkObjectEquals(propertyCondition, value)
|
||||||
|
case "does_not_equal":
|
||||||
|
return !checkObjectEquals(propertyCondition, value)
|
||||||
|
case "contains":
|
||||||
|
return checkMatrixContains(propertyCondition, value)
|
||||||
|
case "does_not_contain":
|
||||||
|
return !checkMatrixContains(propertyCondition, value)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ class FormPropertyLogicRule {
|
||||||
(type === "string" && typeof value !== "string") ||
|
(type === "string" && typeof value !== "string") ||
|
||||||
(type === "boolean" && typeof value !== "boolean") ||
|
(type === "boolean" && typeof value !== "boolean") ||
|
||||||
(type === "number" && typeof value !== "number") ||
|
(type === "number" && typeof value !== "number") ||
|
||||||
(type === "object" && !Array.isArray(value))
|
(type === "object" && !(Array.isArray(value) || typeof value === 'object'))
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const defaultBlockNames = {
|
||||||
multi_select: "Multi Select",
|
multi_select: "Multi Select",
|
||||||
files: "Files",
|
files: "Files",
|
||||||
signature: "Signature",
|
signature: "Signature",
|
||||||
|
matrix: "Matrix",
|
||||||
"nf-text": "Text Block",
|
"nf-text": "Text Block",
|
||||||
"nf-page-break": "Page Break",
|
"nf-page-break": "Page Break",
|
||||||
"nf-divider": "Divider",
|
"nf-divider": "Divider",
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,6 @@ class FormFactory extends Factory
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forDatabase(string $databaseId)
|
|
||||||
{
|
|
||||||
return $this->state(function (array $attributes) use ($databaseId) {
|
|
||||||
return [
|
|
||||||
'database_id' => $databaseId,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withProperties(array $properties)
|
public function withProperties(array $properties)
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) use ($properties) {
|
return $this->state(function (array $attributes) use ($properties) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Your form "{{$form->title}}" has a new submission.
|
||||||
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br/>
|
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br/>
|
||||||
@endforeach
|
@endforeach
|
||||||
@else
|
@else
|
||||||
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
|
{!! is_array($field['value'])?implode(',',$field['value']):nl2br(e($field['value']))!!}
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Tests\Helpers\FormSubmissionDataFactory;
|
||||||
|
|
||||||
|
it('can submit form with valid matrix input', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->createForm($user, $workspace);
|
||||||
|
|
||||||
|
$matrixProperty = [
|
||||||
|
'id' => 'matrix_field',
|
||||||
|
'name' => 'Matrix Question',
|
||||||
|
'type' => 'matrix',
|
||||||
|
'rows' => ['Row 1', 'Row 2', 'Row 3'],
|
||||||
|
'columns' => ['Column A', 'Column B', 'Column C'],
|
||||||
|
'required' => true
|
||||||
|
];
|
||||||
|
|
||||||
|
$form->properties = array_merge($form->properties, [$matrixProperty]);
|
||||||
|
$form->update();
|
||||||
|
|
||||||
|
$submissionData = [
|
||||||
|
'matrix_field' => [
|
||||||
|
'Row 1' => 'Column A',
|
||||||
|
'Row 2' => 'Column B',
|
||||||
|
'Row 3' => 'Column C'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData);
|
||||||
|
|
||||||
|
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJson([
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => 'Form submission saved.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot submit form with invalid matrix input', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->createForm($user, $workspace);
|
||||||
|
|
||||||
|
$matrixProperty = [
|
||||||
|
'id' => 'matrix_field',
|
||||||
|
'name' => 'Matrix Question',
|
||||||
|
'type' => 'matrix',
|
||||||
|
'rows' => ['Row 1', 'Row 2', 'Row 3'],
|
||||||
|
'columns' => ['Column A', 'Column B', 'Column C'],
|
||||||
|
'required' => true
|
||||||
|
];
|
||||||
|
|
||||||
|
$form->properties = array_merge($form->properties, [$matrixProperty]);
|
||||||
|
$form->update();
|
||||||
|
|
||||||
|
$submissionData = [
|
||||||
|
'matrix_field' => [
|
||||||
|
'Row 1' => 'Column A',
|
||||||
|
'Row 2' => 'Invalid Column',
|
||||||
|
'Row 3' => 'Column C'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData);
|
||||||
|
|
||||||
|
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJson([
|
||||||
|
'message' => "Invalid value 'Invalid Column' for row 'Row 2'.",
|
||||||
|
'errors' => [
|
||||||
|
'matrix_field' => [
|
||||||
|
"Invalid value 'Invalid Column' for row 'Row 2'."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit form with optional matrix input left empty', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->createForm($user, $workspace);
|
||||||
|
|
||||||
|
$matrixProperty = [
|
||||||
|
'id' => 'matrix_field',
|
||||||
|
'name' => 'Matrix Question',
|
||||||
|
'type' => 'matrix',
|
||||||
|
'rows' => ['Row 1', 'Row 2', 'Row 3'],
|
||||||
|
'columns' => ['Column A', 'Column B', 'Column C'],
|
||||||
|
'required' => false
|
||||||
|
];
|
||||||
|
|
||||||
|
$form->properties = array_merge($form->properties, [$matrixProperty]);
|
||||||
|
$form->update();
|
||||||
|
|
||||||
|
$submissionData = [
|
||||||
|
'matrix_field' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
$formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData);
|
||||||
|
|
||||||
|
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJson([
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => 'Form submission saved.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot submit form with required matrix input left empty', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->createForm($user, $workspace);
|
||||||
|
|
||||||
|
$matrixProperty = [
|
||||||
|
'id' => 'matrix_field',
|
||||||
|
'name' => 'Matrix Question',
|
||||||
|
'type' => 'matrix',
|
||||||
|
'rows' => ['Row 1', 'Row 2', 'Row 3'],
|
||||||
|
'columns' => ['Column A', 'Column B', 'Column C'],
|
||||||
|
'required' => true
|
||||||
|
];
|
||||||
|
|
||||||
|
$form->properties = array_merge($form->properties, [$matrixProperty]);
|
||||||
|
$form->update();
|
||||||
|
|
||||||
|
$submissionData = [
|
||||||
|
'matrix_field' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
$formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData);
|
||||||
|
|
||||||
|
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJson([
|
||||||
|
'message' => 'The Matrix Question field is required.',
|
||||||
|
'errors' => [
|
||||||
|
'matrix_field' => [
|
||||||
|
'The Matrix Question field is required.'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can validate matrix input with precognition', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->createForm($user, $workspace);
|
||||||
|
|
||||||
|
$matrixProperty = [
|
||||||
|
'id' => 'matrix_field',
|
||||||
|
'name' => 'Matrix Question',
|
||||||
|
'type' => 'matrix',
|
||||||
|
'rows' => ['Row 1', 'Row 2', 'Row 3'],
|
||||||
|
'columns' => ['Column A', 'Column B', 'Column C'],
|
||||||
|
'required' => true
|
||||||
|
];
|
||||||
|
|
||||||
|
$form->properties = array_merge($form->properties, [$matrixProperty]);
|
||||||
|
$form->update();
|
||||||
|
|
||||||
|
$submissionData = [
|
||||||
|
'matrix_field' => [
|
||||||
|
'Row 1' => 'Column A',
|
||||||
|
'Row 2' => 'Invalid Column',
|
||||||
|
'Row 3' => 'Column C'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData);
|
||||||
|
|
||||||
|
$response = $this->withPrecognition()->withHeaders([
|
||||||
|
'Precognition-Validate-Only' => 'matrix_field'
|
||||||
|
])
|
||||||
|
->postJson(route('forms.answer', $form->slug), $formData);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJson([
|
||||||
|
'errors' => [
|
||||||
|
'matrix_field' => [
|
||||||
|
'Invalid value \'Invalid Column\' for row \'Row 2\'.'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue