PluginProbe
The Innovative Form Builder – IvyForms / 0.9
The Innovative Form Builder – IvyForms v0.9
1.5.1 1.5 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 All 27 releases
ivyforms / backend / src / Services / Field / FieldService.php

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

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