*/ public function fieldsFor(ClientFieldContext $context): Collection { return ClientCustomField::query() ->where('client_editability', '!=', ClientFieldEditability::Hidden->value) ->whereJsonContains('client_contexts', $context->value) ->orderBy('sort_order') ->orderBy('id') ->get(); } /** * @return list> */ public function rows(ClientFieldContext $context, ?User $client): array { $values = $this->storedValues($client); $rows = []; foreach ($this->fieldsFor($context) as $field) { $rows[] = [ 'id' => $field->id, 'label' => $field->label, 'type' => $field->type->value, 'options' => $field->options, 'required' => $field->required, 'locked' => $this->isLocked($field, $values), ]; } return $rows; } /** * Keyed by field id — PHP normalizes the numeric string key back to an * int, same as every other numeric-keyed array in this class. * * @return array */ public function values(ClientFieldContext $context, ?User $client): array { if ($client === null) { return []; } $values = $this->storedValues($client); $rows = []; foreach ($this->fieldsFor($context) as $field) { $rows[$field->id] = $values->get($field->id, ''); } return $rows; } /** * @return array> */ public function rules(ClientFieldContext $context, ?User $client): array { $values = $this->storedValues($client); $rules = []; foreach ($this->fieldsFor($context) as $field) { if ($this->isLocked($field, $values)) { continue; } $key = "custom_field_values.{$field->id}"; if ($field->type === ClientCustomFieldType::Checkbox) { // Unlike the admin-side rules, a required checkbox here // must actually be checked — "required" alone doesn't // enforce that (a '0' string satisfies it). $rules[$key] = $field->required ? ['accepted'] : ['nullable', 'boolean']; continue; } $rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000']; if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) { $rules[$key][] = Rule::in($field->options); } } return $rules; } /** * @param array $submitted field id => submitted value */ public function save(User $client, ClientFieldContext $context, array $submitted): void { $values = $this->storedValues($client); foreach ($this->fieldsFor($context) as $field) { if ($this->isLocked($field, $values)) { continue; } $value = $submitted[$field->id] ?? null; $stored = $field->type === ClientCustomFieldType::Checkbox ? ($value ? '1' : '0') : (is_string($value) ? $value : null); ClientCustomFieldValue::query()->updateOrCreate( ['client_custom_field_id' => $field->id, 'user_id' => $client->id], ['value' => $stored === '' ? null : $stored], ); } } /** * @return BaseCollection */ private function storedValues(?User $client): BaseCollection { if ($client === null) { return collect(); } return ClientCustomFieldValue::query() ->where('user_id', $client->id) ->pluck('value', 'client_custom_field_id'); } /** * @param BaseCollection $values */ private function isLocked(ClientCustomField $field, BaseCollection $values): bool { if ($field->client_editability !== ClientFieldEditability::EditableOnce) { return false; } $stored = $values->get($field->id); // A checkbox has a stored value from the first save onwards: an // unticked box is written as '0', and filled('0') is true. Asking // "is anything stored" therefore locked the field on the first save // of the form it sits on, whatever the client had chosen — and a // box they never ticked can then never be ticked. '0' is the // absence of a decision, which is the state the other types express // as null, so it is what an unlocked checkbox looks like. if ($field->type === ClientCustomFieldType::Checkbox) { return $stored === '1'; } return filled($stored); } }