PluginProbe
The Innovative Form Builder – IvyForms / 0.8
The Innovative Form Builder – IvyForms v0.8
1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 1.2 1.3
ivyforms / backend / src / Services / Field / FieldService.php

FieldService.php in The Innovative Form Builder – IvyForms 0.8, at backend/src/Services/Field/FieldService.php

383 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace IvyForms\Services\Field;
4
5 // phpcs:disable PSR1.Files.SideEffects
6 if (!defined('ABSPATH')) {
7 exit; // Exit if accessed directly
8 }
9
10 use IvyForms\Common\Exceptions\QueryExecutionException;
11 use IvyForms\Common\Exceptions\ValidationException;
12 use IvyForms\Entity\Field\Field;
13 use IvyForms\Common\Exceptions\InvalidArgumentException;
14 use IvyForms\Factory\Field\FieldFactory;
15 use IvyForms\Factory\FieldOptions\FieldOptionsFactory;
16 use IvyForms\Repository\Field\FieldRepositoryInterface;
17 use IvyForms\Repository\FieldOptions\FieldOptionsRepositoryInterface;
18 use IvyForms\Repository\EntryField\EntryFieldRepositoryInterface;
19 use IvyForms\Services\Translations\BackendStrings;
20 use IvyForms\Common\Helpers\FieldHelper;
21
22 /**
23 * @SuppressWarnings("TooManyPublicMethods")
24 */
25 class FieldService
26 {
27 private FieldRepositoryInterface $fieldRepository;
28 private FieldOptionsRepositoryInterface $fieldOptionsRepository;
29 private EntryFieldRepositoryInterface $entryFieldRepository;
30
31 // Constructor injection for repositories only via PHP-DI
32 public function __construct(
33 FieldRepositoryInterface $fieldRepository,
34 FieldOptionsRepositoryInterface $fieldOptionsRepository,
35 EntryFieldRepositoryInterface $entryFieldRepository
36 ) {
37 $this->fieldRepository = $fieldRepository;
38 $this->fieldOptionsRepository = $fieldOptionsRepository;
39 $this->entryFieldRepository = $entryFieldRepository;
40 }
41
42 /**
43 * Create a new field.
44 *
45 * @param Field $fieldData
46 * @return int Field ID
47 * @throws InvalidArgumentException If the field data is invalid
48 */
49 public function addField(Field $fieldData): int
50 {
51 if (empty($fieldData->getFormId())) {
52 throw new InvalidArgumentException(
53 BackendStrings::getExceptionStrings()['form_id_required']
54 );
55 }
56 return $this->fieldRepository->add($fieldData);
57 }
58
59 /**
60 * Update an existing field.
61 *
62 * @param int $fieldId
63 * @param Field $fieldData
64 * @return bool
65 * @throws InvalidArgumentException If the field data is invalid
66 */
67 public function updateField(int $fieldId, Field $fieldData): bool
68 {
69 if (empty($fieldData->getId())) {
70 throw new InvalidArgumentException(
71 BackendStrings::getExceptionStrings()['field_id_required']
72 );
73 }
74 return $this->fieldRepository->update($fieldId, $fieldData);
75 }
76
77 /**
78 * Delete a field by its ID (removes options first).
79 *
80 * @param int $fieldId
81 * @return int
82 * @throws QueryExecutionException
83 */
84 public function deleteField(int $fieldId): int
85 {
86 $this->fieldOptionsRepository->deleteByFieldIds([$fieldId]);
87 return $this->fieldRepository->delete($fieldId);
88 }
89
90 /**
91 * Batch delete fields by IDs.
92 *
93 * @param int[] $fieldIds
94 * @return int Number of deleted rows
95 * @throws QueryExecutionException
96 */
97 public function deleteFields(array $fieldIds): int
98 {
99 $ids = array_values(array_filter(array_map('intval', $fieldIds), static fn($val) => $val > 0));
100 if (empty($ids)) {
101 return 0;
102 }
103 $this->fieldOptionsRepository->deleteByFieldIds($ids);
104 return $this->fieldRepository->deleteMany($ids);
105 }
106
107 /**
108 * Get all fields by form ID.
109 * @param int $id
110 * @return Field[]|null
111 */
112 public function getAllFields(int $id): ?array
113 {
114 return $this->fieldRepository->getFieldsById($id);
115 }
116
117 /**
118 * Get multiple fields by their IDs in a single query.
119 * @param int[] $fieldIds
120 * @return array<int, object> Indexed by field ID
121 */
122 public function getFieldsByIds(array $fieldIds): array
123 {
124 if (empty($fieldIds)) {
125 return [];
126 }
127 return $this->fieldRepository->getFieldsByIds($fieldIds);
128 }
129
130 /**
131 * Delete all fields for multiple form IDs.
132 * @param int[] $formIds
133 * @return int
134 * @throws InvalidArgumentException|QueryExecutionException
135 */
136 public function deleteFieldsByFormIds(array $formIds): int
137 {
138 if (empty($formIds)) {
139 throw new InvalidArgumentException(
140 BackendStrings::getExceptionStrings()['no_form_ids_provided']
141 );
142 }
143 $this->fieldOptionsRepository->deleteByFieldIdsByColumnValues('formId', $formIds);
144 return $this->fieldRepository->deleteManyByForeignKeyValues($formIds, 'formId');
145 }
146
147 /**
148 * Delete fields by single form ID.
149 * @param int $formId
150 * @return int
151 * @throws QueryExecutionException
152 */
153 public function deleteFieldsByFormId(int $formId): int
154 {
155 $this->fieldOptionsRepository->deleteByFieldIdsByColumnValues('formId', [$formId]);
156 return $this->fieldRepository->deleteOneByForeignKeyValue($formId, 'formId');
157 }
158
159 /**
160 * Duplicate all fields from one form to another.
161 * @param int $originalFormId
162 * @param int $newFormId
163 * @return void
164 * @throws InvalidArgumentException|ValidationException
165 */
166 public function duplicateFields(int $originalFormId, int $newFormId): void
167 {
168 $fields = $this->getAllFields($originalFormId);
169 foreach ($fields as $field) {
170 $fieldData = $field->toArray();
171 unset($fieldData['id']);
172 $fieldData['formId'] = $newFormId;
173 $this->addField(FieldFactory::create($fieldData));
174 }
175 }
176
177 /**
178 * Save fields with their options (radio/checkbox/select) for a form.
179 * @param array<int, array<string,mixed>> $fields
180 * @param int $formId
181 * @return void
182 * @throws ValidationException|InvalidArgumentException
183 */
184 public function saveFieldsWithOptions(array $fields, int $formId): void
185 {
186 $parentMap = [];
187 foreach ($fields as $fieldData) {
188 $fieldData['formId'] = $fieldData['formId'] ?? $formId;
189 // Resolve parentId using fieldIndex if available
190 FieldHelper::resolveParentId($fieldData, $parentMap);
191 $field = FieldFactory::create($fieldData);
192 $fieldId = $this->addField($field);
193 // If this is a parent field (no parentId), store its DB id in the map
194 if ($field->getParentId() === null) {
195 $parentMap[$field->getIndex()] = $fieldId;
196 }
197 if (
198 in_array(
199 $field->getType(),
200 ['radio', 'checkbox', 'select', 'multi-select'],
201 true
202 ) && !empty($fieldData['fieldOptions'])
203 ) {
204 foreach ($fieldData['fieldOptions'] as $optionData) {
205 // Always overwrite fieldId with the actual DB id of the field
206 $optionData['fieldId'] = $fieldId;
207 $option = FieldOptionsFactory::create($optionData);
208 $this->fieldOptionsRepository->add($option);
209 }
210 }
211 }
212 }
213
214 /**
215 * Update (and create/delete) fields and their options for a form.
216 * @param array<int, array<string,mixed>> $fields
217 * @param int $formId
218 * @return int[] Submitted field IDs
219 * @throws ValidationException|InvalidArgumentException
220 */
221 public function updateFieldsWithOptions(array $fields, int $formId): array
222 {
223 $submittedFieldIds = [];
224 $parentMap = [];
225 foreach ($fields as $fieldData) {
226 $fieldData['formId'] = $fieldData['formId'] ?? $formId;
227 FieldHelper::resolveParentId($fieldData, $parentMap);
228 $field = FieldFactory::create($fieldData);
229 $fieldId = $field->getId();
230
231 if ($fieldId === 0) { // new field
232 $newFieldId = $this->addField($field);
233 if ($field->getParentId() === null) {
234 $parentMap[$field->getIndex()] = $newFieldId;
235 }
236 $submittedFieldIds[] = $newFieldId;
237 if (empty($fieldData['fieldOptions'])) {
238 continue;
239 }
240 $newOptions = [];
241 foreach ($fieldData['fieldOptions'] as $optionData) {
242 $optionData['fieldId'] = $newFieldId;
243 $newOptions[] = FieldOptionsFactory::create($optionData);
244 }
245 $this->fieldOptionsRepository->addMany($newOptions);
246 continue;
247 }
248
249 // existing field
250 $this->updateField($fieldId, $field);
251 $submittedFieldIds[] = $fieldId;
252 if (empty($fieldData['fieldOptions'])) {
253 continue;
254 }
255 foreach ($fieldData['fieldOptions'] as &$optionData) {
256 $optionData['fieldId'] = $fieldId;
257 }
258 unset($optionData);
259 $this->batchProcessFieldOptions($fieldId, $fieldData);
260 }
261 return $submittedFieldIds;
262 }
263
264 /**
265 * Fetch all fields (with options for radio/checkbox/select types).
266 * @param int $formId
267 * @return array<int, array<string,mixed>>
268 * @throws ValidationException|QueryExecutionException
269 */
270 public function getAllFieldsWithOptions(int $formId): array
271 {
272 $fields = $this->fieldRepository->getFieldsById($formId);
273 $fieldsWithOptions = [];
274 foreach ($fields as $field) {
275 $fieldArr = $field->toArray();
276 if (in_array($field->getType(), ['radio', 'checkbox', 'select', 'multi-select'], true)) {
277 $options = $this->fieldOptionsRepository->getByFieldId($field->getId());
278 $fieldArr['fieldOptions'] = array_map(static fn($option) => $option->toArray(), $options);
279 }
280 $fieldsWithOptions[] = $fieldArr;
281 }
282 return $fieldsWithOptions;
283 }
284
285 /**
286 * Collect existing field IDs for a form.
287 * @param int $formId
288 * @return int[]
289 */
290 public function collectExistingFieldIds(int $formId): array
291 {
292 $existingFields = $this->getAllFields($formId);
293 return array_map(static fn($field) => $field->getId(), $existingFields ?? []);
294 }
295
296 /**
297 * Batch process field options: add, update, and delete as needed.
298 * @param int $fieldId
299 * @param array<string, mixed> $fieldData
300 * @throws ValidationException
301 */
302 public function batchProcessFieldOptions(
303 int $fieldId,
304 array $fieldData
305 ): void {
306 $existingOptions = $this->fieldOptionsRepository->getByFieldId($fieldId);
307 $existingOptionIds = array_map(static fn($opt) => $opt->getId(), $existingOptions);
308 $partition = FieldHelper::partitionFieldOptions($fieldData, $existingOptionIds);
309 // Add new options
310 $newOptionIds = $this->fieldOptionsRepository->addMany($partition['newOptions']);
311 // Update existing options
312 $this->fieldOptionsRepository->updateMany($partition['updateOptions']);
313 // Determine submitted option IDs (existing updated + newly created)
314 $submittedOptionIds = array_merge($partition['submittedOptionIds'], $newOptionIds);
315 // Delete orphaned (those that existed but were not resubmitted)
316 $orphanedOptionIds = array_diff($existingOptionIds, $submittedOptionIds);
317 if (!empty($orphanedOptionIds)) {
318 $this->fieldOptionsRepository->deleteByOptionIds($orphanedOptionIds);
319 }
320 }
321
322 /**
323 * Check for duplicate field values in submission.
324 * @param Field[] $formFields
325 * @param array<string,mixed> $submissionData
326 * @param int $formId
327 * @return array{0: bool, 1: array<int|string,bool>}
328 */
329 public function checkDuplicateFieldValues(
330 array $formFields,
331 array $submissionData,
332 int $formId
333 ): array {
334 $duplicateErrors = [];
335 $isDuplicate = false;
336 $fieldValues = [];
337
338 foreach ($formFields as $field) {
339 $fid = $field->getId();
340 $fidStr = (string)$fid;
341 if (isset($submissionData[$fidStr])) {
342 $fieldValues[$fidStr] = $submissionData[$fidStr];
343 }
344 if ($field->getFieldAdvancedSettings()->isNoDuplicates() && !empty($fieldValues[$fidStr])) {
345 $val = $fieldValues[$fidStr];
346 if ($this->entryFieldRepository->checkDuplicateValue($formId, $fid, $val)) {
347 $duplicateErrors[$fidStr] = true;
348 $isDuplicate = true;
349 }
350 }
351 }
352
353 return [$isDuplicate, $duplicateErrors];
354 }
355
356 /**
357 * Validate that all fields have allowed types.
358 * @param Field[]|mixed[] $formFields
359 * @throws InvalidArgumentException
360 */
361 public function validateFieldsType(array $formFields): void
362 {
363 // Validate field types
364 $allowedTypes = FieldType::getAllowedTypes();
365 foreach ($formFields as $field) {
366 // Check if field is an instance of Field or array
367 $fieldType = $field instanceof Field ? $field->getType() : ($field['type'] ?? null);
368
369 if ($fieldType === null) {
370 throw new InvalidArgumentException(
371 BackendStrings::getExceptionStrings()['invalid_field_type'] . ' (type not found)'
372 );
373 }
374
375 if (!in_array($fieldType, $allowedTypes, true)) {
376 throw new InvalidArgumentException(
377 BackendStrings::getExceptionStrings()['invalid_field_type'] . ' ' . $fieldType
378 );
379 }
380 }
381 }
382 }
383