PluginProbe
The Innovative Form Builder – IvyForms / 1.0
The Innovative Form Builder – IvyForms v1.0
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 1.0, at backend/src/Services/Field/FieldService.php

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