PluginProbe
The Innovative Form Builder – IvyForms / 0.5
The Innovative Form Builder – IvyForms v0.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
ivyforms / backend / src / Services / Field / FieldService.php

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

376 lines 13.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 // 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 foreach ($fields as $fieldData) {
187 $fieldData['formId'] = $fieldData['formId'] ?? $formId;
188 $field = FieldFactory::create($fieldData);
189 $fieldId = $this->addField($field);
190 if (
191 in_array(
192 $field->getType(),
193 ['radio', 'checkbox', 'select', 'multi-select'],
194 true
195 ) && !empty($fieldData['fieldOptions'])
196 ) {
197 foreach ($fieldData['fieldOptions'] as $optionData) {
198 // Always overwrite fieldId with the actual DB id of the field
199 $optionData['fieldId'] = $fieldId;
200 $option = FieldOptionsFactory::create($optionData);
201 $this->fieldOptionsRepository->add($option);
202 }
203 }
204 }
205 }
206
207 /**
208 * Update (and create/delete) fields and their options for a form.
209 * @param array<int, array<string,mixed>> $fields
210 * @param int $formId
211 * @return int[] Submitted field IDs
212 * @throws ValidationException|InvalidArgumentException
213 */
214 public function updateFieldsWithOptions(array $fields, int $formId): array
215 {
216 $submittedFieldIds = [];
217 $parentMap = [];
218 foreach ($fields as $fieldData) {
219 $fieldData['formId'] = $fieldData['formId'] ?? $formId;
220 FieldHelper::resolveParentId($fieldData, $parentMap);
221 $field = FieldFactory::create($fieldData);
222 $fieldId = $field->getId();
223
224 if ($fieldId === 0) { // new field
225 $newFieldId = $this->addField($field);
226 if ($field->getParentId() === null) {
227 $parentMap[$field->getIndex()] = $newFieldId;
228 }
229 $submittedFieldIds[] = $newFieldId;
230 if (empty($fieldData['fieldOptions'])) {
231 continue;
232 }
233 $newOptions = [];
234 foreach ($fieldData['fieldOptions'] as $optionData) {
235 $optionData['fieldId'] = $newFieldId;
236 $newOptions[] = FieldOptionsFactory::create($optionData);
237 }
238 $this->fieldOptionsRepository->addMany($newOptions);
239 continue;
240 }
241
242 // existing field
243 $this->updateField($fieldId, $field);
244 $submittedFieldIds[] = $fieldId;
245 if (empty($fieldData['fieldOptions'])) {
246 continue;
247 }
248 foreach ($fieldData['fieldOptions'] as &$optionData) {
249 $optionData['fieldId'] = $fieldId;
250 }
251 unset($optionData);
252 $this->batchProcessFieldOptions($fieldId, $fieldData);
253 }
254 return $submittedFieldIds;
255 }
256
257 /**
258 * Fetch all fields (with options for radio/checkbox/select types).
259 * @param int $formId
260 * @return array<int, array<string,mixed>>
261 * @throws ValidationException|QueryExecutionException
262 */
263 public function getAllFieldsWithOptions(int $formId): array
264 {
265 $fields = $this->fieldRepository->getFieldsById($formId);
266 $fieldsWithOptions = [];
267 foreach ($fields as $field) {
268 $fieldArr = $field->toArray();
269 if (in_array($field->getType(), ['radio', 'checkbox', 'select', 'multi-select'], true)) {
270 $options = $this->fieldOptionsRepository->getByFieldId($field->getId());
271 $fieldArr['fieldOptions'] = array_map(static fn($option) => $option->toArray(), $options);
272 }
273 $fieldsWithOptions[] = $fieldArr;
274 }
275 return $fieldsWithOptions;
276 }
277
278 /**
279 * Collect existing field IDs for a form.
280 * @param int $formId
281 * @return int[]
282 */
283 public function collectExistingFieldIds(int $formId): array
284 {
285 $existingFields = $this->getAllFields($formId);
286 return array_map(static fn($field) => $field->getId(), $existingFields ?? []);
287 }
288
289 /**
290 * Batch process field options: add, update, and delete as needed.
291 * @param int $fieldId
292 * @param array<string, mixed> $fieldData
293 * @throws ValidationException
294 */
295 public function batchProcessFieldOptions(
296 int $fieldId,
297 array $fieldData
298 ): void {
299 $existingOptions = $this->fieldOptionsRepository->getByFieldId($fieldId);
300 $existingOptionIds = array_map(static fn($opt) => $opt->getId(), $existingOptions);
301 $partition = FieldHelper::partitionFieldOptions($fieldData, $existingOptionIds);
302 // Add new options
303 $newOptionIds = $this->fieldOptionsRepository->addMany($partition['newOptions']);
304 // Update existing options
305 $this->fieldOptionsRepository->updateMany($partition['updateOptions']);
306 // Determine submitted option IDs (existing updated + newly created)
307 $submittedOptionIds = array_merge($partition['submittedOptionIds'], $newOptionIds);
308 // Delete orphaned (those that existed but were not resubmitted)
309 $orphanedOptionIds = array_diff($existingOptionIds, $submittedOptionIds);
310 if (!empty($orphanedOptionIds)) {
311 $this->fieldOptionsRepository->deleteByOptionIds($orphanedOptionIds);
312 }
313 }
314
315 /**
316 * Check for duplicate field values in submission.
317 * @param Field[] $formFields
318 * @param array<string,mixed> $submissionData
319 * @param int $formId
320 * @return array{0: bool, 1: array<int|string,bool>}
321 */
322 public function checkDuplicateFieldValues(
323 array $formFields,
324 array $submissionData,
325 int $formId
326 ): array {
327 $duplicateErrors = [];
328 $isDuplicate = false;
329 $fieldValues = [];
330
331 foreach ($formFields as $field) {
332 $fid = $field->getId();
333 $fidStr = (string)$fid;
334 if (isset($submissionData[$fidStr])) {
335 $fieldValues[$fidStr] = $submissionData[$fidStr];
336 }
337 if ($field->getFieldAdvancedSettings()->isNoDuplicates() && !empty($fieldValues[$fidStr])) {
338 $val = $fieldValues[$fidStr];
339 if ($this->entryFieldRepository->checkDuplicateValue($formId, $fid, $val)) {
340 $duplicateErrors[$fidStr] = true;
341 $isDuplicate = true;
342 }
343 }
344 }
345
346 return [$isDuplicate, $duplicateErrors];
347 }
348
349 /**
350 * Validate that all fields have allowed types.
351 * @param Field[]|mixed[] $formFields
352 * @throws InvalidArgumentException
353 */
354 public function validateFieldsType(array $formFields): void
355 {
356 // Validate field types
357 $allowedTypes = FieldType::getAllowedTypes();
358 foreach ($formFields as $field) {
359 // Check if field is an instance of Field or array
360 $fieldType = $field instanceof Field ? $field->getType() : ($field['type'] ?? null);
361
362 if ($fieldType === null) {
363 throw new InvalidArgumentException(
364 BackendStrings::getExceptionStrings()['invalid_field_type'] . ' (type not found)'
365 );
366 }
367
368 if (!in_array($fieldType, $allowedTypes, true)) {
369 throw new InvalidArgumentException(
370 BackendStrings::getExceptionStrings()['invalid_field_type'] . ' ' . $fieldType
371 );
372 }
373 }
374 }
375 }
376