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