4 * Copyright © 2003-2024 The Galette Team
6 * This file is part of Galette (https://galette.eu).
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
22 namespace Galette\DynamicFields
;
25 use Galette\Features\Permissions
;
29 use Galette\Entity\DynamicFieldsHandle
;
30 use Galette\Features\Translatable
;
31 use Galette\Features\I18n
;
32 use Laminas\Db\Sql\Expression
;
33 use Laminas\Db\Sql\Predicate\Expression
as PredicateExpression
;
36 * Abstract dynamic field
38 * @author Johan Cwiklinski <johan@x-tnd.be>
41 abstract class DynamicField
47 public const TABLE
= 'field_types';
48 public const PK
= 'field_id';
50 /** Separator field */
51 public const SEPARATOR
= 0;
52 /** Simple text field */
53 public const TEXT
= 1;
55 public const LINE
= 2;
56 /** Choice field (listbox) */
57 public const CHOICE
= 3;
59 public const DATE
= 4;
60 /** Boolean field (checkbox) */
61 public const BOOLEAN
= 5;
62 /** File field (upload) */
63 public const FILE
= 6;
65 public const MOVE_UP
= 'up';
66 public const MOVE_DOWN
= 'down';
68 public const DEFAULT_MAX_FILE_SIZE
= 1024;
69 public const VALUES_FIELD_LENGTH
= 100;
71 protected bool $has_data = false;
72 protected bool $has_width = false;
73 protected bool $has_height = false;
74 protected bool $has_size = false;
75 protected bool $has_min_size = false;
76 protected bool $multi_valued = false;
77 protected bool $fixed_values = false;
78 protected bool $has_permissions = true;
80 protected ?
int $id = null;
81 protected ?
int $index = null;
82 protected bool $required = false;
83 protected ?
int $width_in_forms = 1;
84 protected bool $information_above = false;
85 protected ?
int $width = null;
86 protected ?
int $height = null;
87 protected ?
int $repeat = null;
88 protected ?
int $min_size = null;
89 protected ?
int $size = null;
90 protected ?
int $old_size = null;
91 /** @var string|array<string>|false */
92 protected string|
array|
false $values = false;
93 protected string $form;
94 protected ?
string $information = null;
95 protected ?
string $name = null;
96 protected ?
string $old_name = null;
98 /** @var array<string> */
99 protected array $errors = [];
104 * Default constructor
106 * @param Db $zdb Database instance
107 * @param mixed $args Arguments
109 public function __construct(Db
$zdb, $args = null)
115 } elseif (is_object($args)) {
116 $this->loadFromRs($args);
121 * Load field from its id
123 * @param Db $zdb Database instance
124 * @param int $id Field id
126 * @return DynamicField|false
128 public static function loadFieldType(Db
$zdb, int $id): DynamicField|
false
131 $select = $zdb->select(self
::TABLE
);
132 $select->where(['field_id' => $id]);
134 $results = $zdb->execute($select);
135 if ($results->count() > 0) {
136 /** @var ArrayObject<string, int|string> $result */
137 $result = $results->current();
138 $field_type = $result->field_type
;
139 $field_type = self
::getFieldType($zdb, $field_type);
140 $field_type->loadFromRs($result);
143 } catch (Throwable
$e) {
145 __METHOD__
. ' | Unable to retrieve field `' . $id .
146 '` information | ' . $e->getMessage(),
155 * Get correct field type instance
157 * @param Db $zdb Database instance
158 * @param int $t Field type
159 * @param int|null $id Optional dynamic field id (to load data)
161 * @return DynamicField
163 public static function getFieldType(Db
$zdb, int $t, int $id = null): DynamicField
167 case self
::SEPARATOR
:
168 $df = new Separator($zdb, $id);
171 $df = new Text($zdb, $id);
174 $df = new Line($zdb, $id);
177 $df = new Choice($zdb, $id);
180 $df = new Date($zdb, $id);
183 $df = new Boolean($zdb, $id);
186 $df = new File($zdb, $id);
189 throw new \
Exception('Unknown field type ' . $t . '!');
197 * @param integer $id Id
201 public function load(int $id): void
204 $select = $this->zdb
->select(self
::TABLE
);
205 $select->where([self
::PK
=> $id]);
207 $results = $this->zdb
->execute($select);
208 if ($results->count() > 0) {
209 /** @var ArrayObject<string, int|string> $result */
210 $result = $results->current();
211 $this->loadFromRs($result);
213 } catch (Throwable
$e) {
215 'Unable to retrieve field type for field ' . $id . ' | ' .
223 * Load field type from a db ResultSet
225 * @param ArrayObject<string, int|string> $rs ResultSet
226 * @param bool $values Whether to load values. Defaults to true
230 public function loadFromRs(ArrayObject
$rs, bool $values = true): void
232 $this->id
= (int)$rs->field_id
;
233 $this->name
= $rs->field_name
;
234 $this->index
= (int)$rs->field_index
;
235 $this->permission
= (int)$rs->field_perm
;
236 $this->required
= $rs->field_required
== 1;
237 $this->min_size
= $rs->field_min_size
;
238 $this->width_in_forms
= (int)$rs->field_width_in_forms
;
239 $this->width
= $rs->field_width
;
240 $this->height
= $rs->field_height
;
241 $this->repeat
= (int)$rs->field_repeat
;
242 $this->size
= $rs->field_size
;
243 $this->form
= $rs->field_form
;
244 $this->information
= $rs->field_information
;
245 $this->information_above
= $rs->field_information_above
== 1;
246 if ($values && $this->hasFixedValues()) {
247 $this->loadFixedValues();
252 * Retrieve fixed values table name
254 * @param integer $id Field ID
255 * @param bool $prefixed Whether table name should be prefixed
259 public static function getFixedValuesTableName(int $id, bool $prefixed = false): string
261 $name = 'field_contents_' . $id;
262 if ($prefixed === true) {
263 $name = PREFIX_DB
. $name;
269 * Returns an array of fixed valued for a field of type 'choice'.
273 private function loadFixedValues(): void
276 $val_select = $this->zdb
->select(
277 self
::getFixedValuesTableName($this->id
)
280 $val_select->columns(
286 $results = $this->zdb
->execute($val_select);
287 $this->values
= array();
288 if ($results->count() > 0) {
289 foreach ($results as $val) {
290 $this->values
[] = $val->val
;
293 } catch (Throwable
$e) {
295 __METHOD__
. ' | ' . $e->getMessage(),
306 abstract public function getType(): int;
309 * Get field type name
313 public function getTypeName(): string
315 $types = $this->getFieldsTypesNames();
316 if (isset($types[$this->getType()])) {
317 return $types[$this->getType()];
319 throw new \
RuntimeException(
320 'Unknown type ' . $this->getType()
326 * Does the field handle data?
330 public function hasData(): bool
332 return $this->has_data
;
336 * Does the field has width?
340 public function hasWidth(): bool
342 return $this->has_width
;
346 * Does the field has height?
350 public function hasHeight(): bool
352 return $this->has_height
;
356 * Does the field has min size?
360 public function hasMinSize(): bool
362 return $this->has_min_size
;
366 * Does the field has a size?
370 public function hasSize(): bool
372 return $this->has_size
;
376 * Is the field multivalued?
380 public function isMultiValued(): bool
382 return $this->multi_valued
;
386 * Does the field has fixed values?
390 public function hasFixedValues(): bool
392 return $this->fixed_values
;
396 * Does the field require permissions?
400 public function hasPermissions(): bool
402 return $this->has_permissions
;
408 * @return integer|null
410 public function getId(): ?
int
420 public function isRequired(): bool
422 return $this->required
;
426 * Get field's width in forms
428 * @return integer|null
430 public function getWidthInForms(): ?
int
432 return $this->width_in_forms
;
438 * @return integer|null
440 public function getWidth(): ?
int
448 * @return integer|null
450 public function getHeight(): ?
int
452 return $this->height
;
456 * Is current field repeatable?
460 public function isRepeatable(): bool
462 return $this->repeat
!= null && $this->repeat
>= 0;
466 * Get fields repetitions
468 * @return integer|null
470 public function getRepeat(): ?
int
472 return $this->repeat
;
478 * @return integer|null
480 public function getMinSize(): ?
int
482 return $this->min_size
;
488 * @return integer|null
490 public function getSize(): ?
int
498 * @return integer|null
500 public function getIndex(): ?
int
506 * Get field information
510 public function getInformation(): string
512 return $this->information ??
'';
516 * Does the field information have to be displayed above input?
520 public function hasInformationAbove(): bool
522 return $this->information_above
;
526 * Retrieve forms names
528 * @return array<string,string>
530 public static function getFormsNames(): array
533 'adh' => _T("Members"),
534 'contrib' => _T("Contributions"),
535 'trans' => _T("Transactions")
542 * @param string $form_name Form name
546 public static function getFormTitle(string $form_name): string
548 $names = self
::getFormsNames();
549 return $names[$form_name];
557 public function getForm(): string
565 * @param bool $imploded Whether to implode values
567 * @return array<string>|string|false
569 public function getValues(bool $imploded = false): array|
string|
false
571 if (!is_array($this->values
)) {
574 if ($imploded === true) {
575 return implode("\n", $this->values
);
577 return $this->values
;
582 * Check posted values validity
584 * @param array<string,mixed> $values All values to check, basically the $_POST array
585 * after sending the form
589 public function check(array $values): bool
592 $this->warnings
= [];
595 (!isset($values['field_name']) ||
$values['field_name'] == '')
596 && !$this instanceof Separator
598 $this->errors
[] = _T('Missing required field name!');
600 if ($this->old_name
=== null && $this->name
!== null && $this->name
!= $values['field_name']) {
601 $this->old_name
= $this->name
;
603 $this->name
= $values['field_name'];
606 if (!isset($values['field_perm']) ||
$values['field_perm'] === '') {
607 $this->errors
[] = _T('Missing required field permissions!');
609 if (in_array($values['field_perm'], array_keys(self
::getPermissionsList()))) {
610 $this->permission
= $values['field_perm'];
612 $this->errors
[] = _T('Unknown permission!');
616 if (!isset($this->id
)) {
617 if (!isset($values['form_name']) ||
$values['form_name'] == '') {
618 $this->errors
[] = _T('Missing required form!');
620 if (in_array($values['form_name'], array_keys(self
::getFormsNames()))) {
621 $this->form
= $values['form_name'];
623 $this->errors
[] = _T('Unknown form!');
628 $this->required
= $values['field_required'] ??
false;
630 $this->width_in_forms
= $values['field_width_in_forms'] ??
1;
632 if (count($this->errors
) === 0 && $this->isDuplicate()) {
633 $this->errors
[] = _T("- Field name already used.");
636 if ($this->hasWidth() && isset($values['field_width']) && trim($values['field_width']) != '') {
637 if (!is_numeric($values['field_width']) ||
$values['field_width'] <= 0) {
638 $this->errors
[] = _T("- Width must be a positive integer!");
640 $this->width
= $values['field_width'];
644 if ($this->hasHeight() && isset($values['field_height']) && trim($values['field_height']) != '') {
645 if (!is_numeric($values['field_height']) ||
$values['field_height'] <= 0) {
646 $this->errors
[] = _T("- Height must be a positive integer!");
648 $this->height
= $values['field_height'];
652 if ($this->hasSize() && isset($values['field_size']) && trim($values['field_size']) != '') {
653 if (!is_numeric($values['field_size']) ||
$values['field_size'] <= 0) {
654 $this->errors
[] = _T("- Size must be a positive integer!");
656 $this->size
= $values['field_size'];
660 if ($this->hasMinSize() && isset($values['field_min_size']) && trim($values['field_min_size']) != '') {
661 if (!is_numeric($values['field_min_size']) ||
$values['field_min_size'] <= 0) {
662 $this->errors
[] = _T("- Min size must be a positive integer!");
664 $this->min_size
= $values['field_min_size'];
670 && $this->min_size
!== null
672 && $this->size
!== null
674 if ($this->min_size
> $this->size
) {
675 $this->errors
[] = _T("- Min size must be lower than size!");
679 if (isset($values['field_repeat']) && trim($values['field_repeat']) != '') {
680 if (!is_numeric($values['field_repeat'])) {
681 $this->errors
[] = _T("- Repeat must be an integer!");
683 $this->repeat
= $values['field_repeat'];
687 if (isset($values['field_information']) && trim($values['field_information']) != '') {
689 $this->information
= $preferences->cleanHtmlValue($values['field_information']);
692 $this->information_above
= $values['field_information_above'] ??
false;
694 if ($this->hasFixedValues() && isset($values['fixed_values'])) {
696 foreach (explode("\n", $values['fixed_values']) as $val) {
698 $len = mb_strlen($val);
700 $fixed_values[] = $val;
701 if ($len > $this->size
) {
702 if ($this->old_size
=== null) {
703 $this->old_size
= $this->size
;
710 $this->values
= $fixed_values;
713 if (!isset($this->id
)) {
714 $this->index
= $this->getNewIndex();
717 if (count($this->errors
) === 0) {
725 * Store the field type
727 * @param array<string,mixed> $values All values to check, basically the $_POST array
728 * after sending the form
732 public function store(array $values): bool
734 if (!$this->check($values)) {
738 $isnew = (!isset($this->id
));
739 if ($this->old_name
!== null) {
740 $this->deleteTranslation($this->old_name
);
741 $this->addTranslation($this->name
);
746 'field_name' => strip_tags($this->name
),
747 'field_perm' => $this->permission
,
748 'field_required' => $this->required
,
749 'field_width_in_forms' => $this->width_in_forms
,
750 'field_width' => ($this->width
=== null ?
new Expression('NULL') : $this->width
),
751 'field_height' => ($this->height
=== null ?
new Expression('NULL') : $this->height
),
752 'field_min_size' => ($this->min_size
=== null ?
new Expression('NULL') : $this->min_size
),
753 'field_size' => ($this->size
=== null ?
new Expression('NULL') : $this->size
),
754 'field_repeat' => ($this->repeat
=== null ?
new Expression('NULL') : $this->repeat
),
755 'field_form' => $this->form
,
756 'field_index' => $this->index
,
757 'field_information' => ($this->information
=== null ?
new Expression('NULL') : $this->information
),
758 'field_information_above' => $this->information_above
,
761 if ($this->required
=== false) {
762 //Handle booleans for postgres ; bugs #18899 and #19354
763 $values['field_required'] = $this->zdb
->isPostgres() ?
'false' : 0;
766 if ($this->information_above
=== false) {
767 //Handle booleans for postgres ; bugs #18899 and #19354
768 $values['field_information_above'] = $this->zdb
->isPostgres() ?
'false' : 0;
772 $update = $this->zdb
->update(self
::TABLE
);
773 $update->set($values)->where([self
::PK
=> $this->id
]);
774 $this->zdb
->execute($update);
776 $values['field_type'] = $this->getType();
777 $insert = $this->zdb
->insert(self
::TABLE
);
778 $insert->values($values);
779 $this->zdb
->execute($insert);
781 $this->id
= $this->zdb
->getLastGeneratedValue($this);
783 if ($this->name
!= '') {
784 $this->addTranslation($this->name
);
787 } catch (Throwable
$e) {
789 'An error occurred storing field | ' . $e->getMessage(),
792 $this->errors
[] = _T("An error occurred storing the field.");
795 if (count($this->errors
) === 0 && $this->hasFixedValues()) {
796 $contents_table = self
::getFixedValuesTableName($this->id
, true);
799 $this->zdb
->drop(str_replace(PREFIX_DB
, '', $contents_table), true);
800 $field_size = ((int)$this->size
> 0) ?
$this->size
: 1;
801 $this->zdb
->db
->query(
802 'CREATE TABLE ' . $contents_table .
803 ' (id INTEGER NOT NULL,val varchar(' . $field_size .
805 \Laminas\Db\Adapter\Adapter
::QUERY_MODE_EXECUTE
807 } catch (Throwable
$e) {
809 'Unable to manage fields values table ' .
810 $contents_table . ' | ' . $e->getMessage(),
813 $this->errors
[] = _T("An error occurred creating field values table");
816 if (count($this->errors
) == 0 && is_array($this->values
)) {
817 $contents_table = self
::getFixedValuesTableName($this->id
);
819 $this->zdb
->connection
->beginTransaction();
821 $insert = $this->zdb
->insert($contents_table);
828 $stmt = $this->zdb
->sql
->prepareStatementForSqlObject($insert);
830 $cnt_values = count($this->values
);
831 for ($i = 0; $i < $cnt_values; $i++
) {
835 'val' => $this->values
[$i]
839 $this->zdb
->connection
->commit();
840 } catch (Throwable
$e) {
841 $this->zdb
->connection
->rollBack();
843 'Unable to store field ' . $this->id
. ' values (' .
844 $e->getMessage() . ')',
847 $this->warnings
[] = _T('An error occurred storing dynamic field values :(');
852 if (count($this->errors
) === 0) {
864 protected function getNewIndex(): int
866 $select = $this->zdb
->select(self
::TABLE
);
869 'idx' => new \Laminas\Db\Sql\
Expression('COUNT(*) + 1')
872 $select->where(['field_form' => $this->form
]);
873 $results = $this->zdb
->execute($select);
874 $result = $results->current();
880 * Is field duplicated?
884 public function isDuplicate(): bool
886 //let's consider field is duplicated, in case of future errors
889 $select = $this->zdb
->select(self
::TABLE
);
892 'cnt' => new \Laminas\Db\Sql\
Expression('COUNT(' . self
::PK
. ')')
896 'field_form' => $this->form
,
897 'field_name' => $this->name
901 if (isset($this->id
)) {
902 $select->where
->addPredicate(
903 new PredicateExpression(
904 'field_id NOT IN (?)',
910 $results = $this->zdb
->execute($select);
911 $result = $results->current();
916 } catch (Throwable
$e) {
918 'An error occurred checking field duplicity' . $e->getMessage(),
927 * Move a dynamic field
929 * @param string $action What to do (one of self::MOVE_*)
933 public function move(string $action): bool
935 if ($action !== self
::MOVE_UP
&& $action !== self
::MOVE_DOWN
) {
936 throw new \
RuntimeException(('Unknown action ' . $action));
940 $this->zdb
->connection
->beginTransaction();
942 $old_rank = $this->index
;
944 $direction = $action == self
::MOVE_UP ?
-1 : 1;
945 $new_rank = $old_rank +
$direction;
946 $update = $this->zdb
->update(self
::TABLE
);
948 'field_index' => $old_rank
950 'field_index' => $new_rank,
951 'field_form' => $this->form
953 $this->zdb
->execute($update);
955 $update = $this->zdb
->update(self
::TABLE
);
958 'field_index' => $new_rank
962 self
::PK
=> $this->id
965 $this->zdb
->execute($update);
966 $this->zdb
->connection
->commit();
969 } catch (Throwable
$e) {
970 $this->zdb
->connection
->rollBack();
972 'Unable to change field ' . $this->id
. ' rank | ' .
981 * Delete a dynamic field
985 public function remove(): bool
988 if ($this->hasFixedValues()) {
989 $contents_table = self
::getFixedValuesTableName($this->id
);
990 $this->zdb
->drop($contents_table);
993 $this->zdb
->connection
->beginTransaction();
994 $old_rank = $this->index
;
996 $update = $this->zdb
->update(self
::TABLE
);
999 'field_index' => new \Laminas\Db\Sql\
Expression('field_index-1')
1002 ->greaterThan('field_index', $old_rank)
1003 ->equalTo('field_form', $this->form
);
1004 $this->zdb
->execute($update);
1006 //remove associated values
1008 $delete = $this->zdb
->delete(DynamicFieldsHandle
::TABLE
);
1011 'field_id' => $this->id
,
1012 'field_form' => $this->form
1015 $this->zdb
->execute($delete);
1016 } catch (Throwable
$e) {
1017 throw new \
RuntimeException('Unable to remove associated values for field ' . $this->id
. '!');
1022 $delete = $this->zdb
->delete(self
::TABLE
);
1025 'field_id' => $this->id
,
1026 'field_form' => $this->form
1029 $this->zdb
->execute($delete);
1030 } catch (Throwable
$e) {
1031 throw new \
RuntimeException('Unable to remove field type ' . $this->id
. '!');
1034 $this->deleteTranslation($this->name
);
1036 $this->zdb
->connection
->commit();
1039 } catch (Throwable
$e) {
1040 if ($this->zdb
->connection
->inTransaction()) {
1041 //because of DROP autocommit on mysql...
1042 $this->zdb
->connection
->rollBack();
1045 'An error occurred deleting field | ' . $e->getMessage(),
1053 * Retrieve fields types names
1055 * @return array<int, string>
1057 public static function getFieldsTypesNames(): array
1060 self
::SEPARATOR
=> _T("separator"),
1061 self
::TEXT
=> _T("free text"),
1062 self
::LINE
=> _T("single line"),
1063 self
::CHOICE
=> _T("choice"),
1064 self
::DATE
=> _T("date"),
1065 self
::BOOLEAN
=> _T("boolean"),
1066 self
::FILE
=> _T("file")
1074 * @return array<string>
1076 public function getErrors(): array
1078 return $this->errors
;
1084 * @return array<string>
1086 public function getWarnings(): array
1088 return $this->warnings
;