diff --git a/Classes/Runtime/Domain/FormState.php b/Classes/Runtime/Domain/FormState.php index 6b5b7e0..559ddef 100644 --- a/Classes/Runtime/Domain/FormState.php +++ b/Classes/Runtime/Domain/FormState.php @@ -13,43 +13,43 @@ * source code. */ -class FormState +use Neos\Utility\Arrays; + +class FormState implements \JsonSerializable { /** - * @var mixed[][] + * @var FormStatePart[] */ protected $parts = []; /** - * FormState constructor. - * @param mixed[][] $parts + * @param string $partName + * @param null|bool $finished + * @return bool */ - public function __construct(array $parts = []) + public function hasPart(string $partName, ?bool $finished = null): bool { - foreach ($parts as $key => $value) { - // ensure only strings are used as partNames - if (is_string($key)) { - $this->parts[$key] = $value; - } - } + return array_key_exists($partName, $this->parts); } /** * @param string $partName * @return bool */ - public function hasPart(string $partName): bool + public function isPartFinished(string $partName): bool { - return array_key_exists($partName, $this->parts); + return array_key_exists($partName, $this->parts) && ($this->parts[$partName]->isFinished()); } /** * @param string $partName * @param mixed[] $partData + * @param bool $finished */ - public function commitPart(string $partName, array $partData = []): void + public function commitPart(string $partName, array $partData = [], bool $finished = false): void { - $this->parts[$partName] = $partData; + $part = new FormStatePart($partData, $finished); + $this->parts[$partName] = $part; } /** @@ -58,7 +58,19 @@ public function commitPart(string $partName, array $partData = []): void */ public function getPart(string $partName): ?array { - return $this->parts[$partName] ?? null; + return $this->getPartData($partName); + } + + /** + * @param string $partName + * @return mixed[]|null + */ + public function getPartData(string $partName): ?array + { + if (array_key_exists($partName, $this->parts)) { + return $this->parts[$partName]->getData(); + } + return null; } /** @@ -77,10 +89,29 @@ function ($item) { } /** - * @return mixed[][] + * @return mixed[] */ - public function getAllParts(): array + public function getData(): array { - return $this->parts; + $data = []; + foreach ($this->parts as $part) { + $data = Arrays::arrayMergeRecursiveOverrule( + $data, + $part->getData() + ); + } + return $data; + } + + public function jsonSerialize() + { + $json = []; + foreach ($this->parts as $name => $part) { + $json[$name] = [ + 'data' => $part->getData(), + 'finished' => $part->isFinished() + ]; + } + return $json; } } diff --git a/Classes/Runtime/Domain/FormStatePart.php b/Classes/Runtime/Domain/FormStatePart.php new file mode 100644 index 0000000..fe740a6 --- /dev/null +++ b/Classes/Runtime/Domain/FormStatePart.php @@ -0,0 +1,55 @@ +data = $data; + $this->finished = $finished; + } + + /** + * @return mixed[] + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return bool + */ + public function isFinished(): bool + { + return $this->finished; + } +} diff --git a/Classes/Runtime/Domain/FormStateService.php b/Classes/Runtime/Domain/FormStateService.php index fbce7af..482142d 100644 --- a/Classes/Runtime/Domain/FormStateService.php +++ b/Classes/Runtime/Domain/FormStateService.php @@ -33,7 +33,7 @@ class FormStateService public function unserializeState(string $string): FormState { $validatedState = $this->hashService->validateAndStripHmac($string); - return unserialize(base64_decode($validatedState), ['allowed_classes' => [FormState::class]]); + return unserialize(base64_decode($validatedState), ['allowed_classes' => [FormState::class, FormStatePart::class]]); } /** diff --git a/Classes/Runtime/FusionObjects/MultiStepProcessImplementation.php b/Classes/Runtime/FusionObjects/MultiStepProcessImplementation.php index 97ff690..8ca93a7 100644 --- a/Classes/Runtime/FusionObjects/MultiStepProcessImplementation.php +++ b/Classes/Runtime/FusionObjects/MultiStepProcessImplementation.php @@ -51,6 +51,11 @@ class MultiStepProcessImplementation extends AbstractFusionObject implements Pro */ protected $targetSubProcessKey; + /** + * @var bool + */ + protected $attemptFinishing = false; + /** * Return reference to self during fusion evaluation * @return $this @@ -69,12 +74,14 @@ public function handle(ActionRequest $request, array $data = []): void $this->data = $data; $internalArguments = $request->getInternalArguments(); + $stateArgument = $internalArguments['__state'] ?? null; + $currentStep = $internalArguments['__current'] ?? null; + $targetStep = $internalArguments['__target'] ?? null; + $finishProcess = $internalArguments['__finish'] ?? null; // restore state - if (array_key_exists('__state', $internalArguments) - && $internalArguments['__state'] - ) { - $this->state = $this->formStateService->unserializeState($internalArguments['__state']); + if ($stateArgument) { + $this->state = $this->formStateService->unserializeState($stateArgument); } // make the current `data` available to the context before sub processes are evaluated @@ -84,46 +91,73 @@ public function handle(ActionRequest $request, array $data = []): void // evaluate the subprocesses this has to be done after the state was restored // as the current data may affect @if conditions $subProcesses = $this->getSubProcesses(); + $subProcessKeys = array_keys($subProcesses); + $firstSubProcessKey = (string)reset($subProcessKeys); - // select current subprocess - if (array_key_exists('__current', $internalArguments) - && $internalArguments['__current'] - ) { - $this->currentSubProcessKey = (string)$internalArguments['__current']; - } else { - $subProcessKeys = array_keys($subProcesses); - $this->currentSubProcessKey = (string)reset($subProcessKeys); - } - - // store target subprocess, but only if it already was submitted - if (array_key_exists('__target', $internalArguments) - && $internalArguments['__target'] - && $this->state - && $this->state->hasPart($internalArguments['__target']) - ) { - $this->targetSubProcessKey = (string)$internalArguments['__target']; - } - - // find current and handle - $currentSubProcess = $subProcesses[$this->currentSubProcessKey]; - $currentSubProcess->handle($request, $this->data); - if ($currentSubProcess->isFinished()) { + // find current subprocess and handle the request + if ($currentStep) { + $this->currentSubProcessKey = $currentStep; + $currentSubProcess = $subProcesses[$this->currentSubProcessKey]; + $currentSubProcess->handle($request, $this->data); if (!$this->state) { $this->state = new FormState(); } - $this->state->commitPart( $this->currentSubProcessKey, - $currentSubProcess->getData() + $currentSubProcess->getData(), + $currentSubProcess->isFinished() ); } else { - if ($this->targetSubProcessKey) { - $request->setArgument('__submittedArguments', []); - $request->setArgument('__submittedArgumentValidationResults', new Result()); + $this->currentSubProcessKey = $firstSubProcessKey; + $currentSubProcess = $subProcesses[$this->currentSubProcessKey]; + } + + // find target subprocess, but only if it already was submitted + if ($targetStep && $this->state) { + if ($currentSubProcess->isFinished()) { + $this->targetSubProcessKey = $targetStep; + } else { + if ($this->state->hasPart($targetStep)) { + $this->targetSubProcessKey = $targetStep; + } } } + // ensure no unfinished subprocesses are before the target + if ($this->state && $this->targetSubProcessKey) { + foreach ($subProcesses as $subProcessKey => $subProcesses) { + if ($subProcessKey === $this->targetSubProcessKey) { + break; + } + if (!$this->state->isPartFinished($subProcessKey)) { + $this->targetSubProcessKey = $subProcessKey; + } + if ($subProcessKey === $this->currentSubProcessKey) { + break; + } + } + } + + // determine target if none was defined yet + if (!$this->targetSubProcessKey) { + if (!$this->state) { + $this->targetSubProcessKey = $firstSubProcessKey; + } else { + foreach ($subProcesses as $subProcessKey => $subProcess) { + if (!$this->state->isPartFinished($subProcessKey)) { + $this->targetSubProcessKey = $subProcessKey; + break; + } + } + } + } + + // is this an attempt to finish the process + if ($finishProcess) { + $this->attemptFinishing = true; + } + // restore fusion context to the state before data was pushed $this->runtime->popContext(); } @@ -137,14 +171,13 @@ public function isFinished(): bool return false; } - if ($this->targetSubProcessKey) { + if (!$this->attemptFinishing) { return false; } $subProcesses = $this->getSubProcesses(); - foreach ($subProcesses as $subProcessKey => $subProcess) { - if ($this->state->hasPart($subProcessKey) == false) { + if ($this->state->isPartFinished($subProcessKey) == false) { return false; } } @@ -157,22 +190,12 @@ public function isFinished(): bool public function render(): string { $subProcesses = $this->getSubProcesses(); - if ($this->targetSubProcessKey) { - $renderSubProcessKey = $this->targetSubProcessKey; - } else { - foreach ($subProcesses as $subProcessKey => $subProcess) { - if (!$this->state || !$this->state->hasPart($subProcessKey)) { - $renderSubProcessKey = $subProcessKey; - break; - } - } - } - if (isset($renderSubProcessKey) && $renderSubProcessKey && array_key_exists($renderSubProcessKey, $subProcesses)) { - $this->getRuntime()->pushContext('process', $this->prepareProcessInformation($renderSubProcessKey, $subProcesses)); + if (isset($this->targetSubProcessKey) && $this->targetSubProcessKey && array_key_exists($this->targetSubProcessKey, $subProcesses)) { + $this->getRuntime()->pushContext('process', $this->prepareProcessInformation($this->targetSubProcessKey, $subProcesses)); $hiddenFields = $this->runtime->evaluate($this->path . '/hiddenFields') ?? ''; $header = $this->runtime->evaluate($this->path . '/header') ?? ''; - $body = $subProcesses[$renderSubProcessKey]->render(); + $body = $subProcesses[$this->targetSubProcessKey]->render(); $footer = $this->runtime->evaluate($this->path . '/footer') ?? ''; $this->getRuntime()->popContext(); return $hiddenFields . $header . $body . $footer; @@ -191,12 +214,7 @@ public function getData(): array // add subprocess data from state if ($this->state) { - foreach ($this->state->getAllParts() as $subProcessKey => $subProcessData) { - $data = Arrays::arrayMergeRecursiveOverrule( - $data, - $subProcessData - ); - } + $data = Arrays::arrayMergeRecursiveOverrule($data, $this->state->getData()); } return $data; diff --git a/Classes/Runtime/FusionObjects/SingleStepProcessImplementation.php b/Classes/Runtime/FusionObjects/SingleStepProcessImplementation.php index 1ef7448..9c063ee 100644 --- a/Classes/Runtime/FusionObjects/SingleStepProcessImplementation.php +++ b/Classes/Runtime/FusionObjects/SingleStepProcessImplementation.php @@ -21,6 +21,7 @@ use Neos\Fusion\Form\Runtime\Domain\ProcessInterface; use Neos\Fusion\Form\Runtime\Domain\SchemaInterface; use Neos\Fusion\FusionObjects\AbstractFusionObject; +use Neos\Utility\Arrays; class SingleStepProcessImplementation extends AbstractFusionObject implements ProcessInterface { @@ -62,9 +63,15 @@ public function handle(ActionRequest $request, array $data = []): void if ($arguments || $internalArguments) { $data = $schema->convert($arguments); $result = $schema->validate($data); - $request->setArgument('__submittedArguments', $data); + $request->setArgument('__submittedArguments', $arguments); $request->setArgument('__submittedArgumentValidationResults', $result); - if (!$result->hasErrors()) { + if ($result->hasErrors()) { + foreach ($result->getFlattenedErrors() as $path => $error) { + $data = Arrays::unsetValueByPath($data, $path); + } + $this->data = $data; + $this->isFinished = false; + } else { $this->data = $data; $this->isFinished = true; } diff --git a/Documentation/Examples/MultiStepForm.md b/Documentation/Examples/MultiStepForm.md index 6dc8e43..a57b987 100644 --- a/Documentation/Examples/MultiStepForm.md +++ b/Documentation/Examples/MultiStepForm.md @@ -65,7 +65,13 @@ prototype(Vendor.Site:Content.MultiStepFormExample) < prototype(Neos.Fusion.Form confirmation { content = afx`

Confirm to submit {data.firstName} {first.data.lastName} from {data.city}, {data.street}

+ + Agree to data storage + ` + schema { + gdprAgreed = ${Form.Schema.boolean().validator('BooleanValue', {expectedValue:true})} + } } } } diff --git a/Resources/Private/Fusion/Prototypes/Runtime/MultiStepProcess.fusion b/Resources/Private/Fusion/Prototypes/Runtime/MultiStepProcess.fusion index 9d85dc0..8200813 100644 --- a/Resources/Private/Fusion/Prototypes/Runtime/MultiStepProcess.fusion +++ b/Resources/Private/Fusion/Prototypes/Runtime/MultiStepProcess.fusion @@ -8,7 +8,7 @@ prototype(Neos.Fusion.Form:Runtime.MultiStepProcess) { footer = afx` {Translation.id('forms.navigation.previousPage').package('Neos.Fusion.Form').translate()} {Translation.id('forms.navigation.nextPage').package('Neos.Fusion.Form').translate()} - {Translation.id('forms.navigation.submit').package('Neos.Fusion.Form').translate()} + {Translation.id('forms.navigation.submit').package('Neos.Fusion.Form').translate()} ` hiddenFields = afx` diff --git a/Tests/Unit/FormStateServiceTest.php b/Tests/Unit/FormStateServiceTest.php index ae83048..644e2c0 100644 --- a/Tests/Unit/FormStateServiceTest.php +++ b/Tests/Unit/FormStateServiceTest.php @@ -47,11 +47,9 @@ public function setUp(): void */ public function formStateCanBeSerializedAndUnserialized() { - $stateParts = [ - 'first' => ['value1' => 'foo', 'value2' => 'bar'], - 'second' => ['value2' => 'foo', 'value4' => 'bar'] - ]; - $state = new FormState($stateParts); + $state = new FormState(); + $state->commitPart('first', ['value1' => 'foo', 'value2' => 'bar'], true); + $state->commitPart('second', ['value2' => 'foo', 'value4' => 'bar'], false); $statePartsSerialized = base64_encode(serialize($state)); @@ -68,7 +66,11 @@ public function formStateCanBeSerializedAndUnserialized() $serializedState = $this->formStateService->serializeState($state); $unserializedState = $this->formStateService->unserializeState($serializedState); - $this->assertEquals($stateParts, $unserializedState->getAllParts()); + $this->assertEquals(['first', 'second'], $unserializedState->getCommittedPartNames()); + $this->assertTrue($unserializedState->isPartFinished('first')); + $this->assertFalse($unserializedState->isPartFinished('second')); + $this->assertEquals(['value1' => 'foo', 'value2' => 'bar'], $unserializedState->getPartData('first')); + $this->assertEquals(['value2' => 'foo', 'value4' => 'bar'], $unserializedState->getPartData('second')); } /** diff --git a/Tests/Unit/FormStateTest.php b/Tests/Unit/FormStateTest.php index 4a5eb0a..5055185 100644 --- a/Tests/Unit/FormStateTest.php +++ b/Tests/Unit/FormStateTest.php @@ -34,48 +34,57 @@ public function setUp(): void public function emptyStateHasNoParts() { $state = new FormState(); - $this->assertEmpty($state->getAllParts()); $this->assertFalse($state->hasPart('example')); - $this->assertNull($state->getPart('example')); + $this->assertNull($state->getPartData('example')); $this->assertEquals([], $state->getCommittedPartNames()); } + public function partsCanBeAcessedAfterBeingComittedDataProvider(): array + { + return [ + ['example1', ['value1' => 'exampleValue1'], true], + ['example2', ['value2' => 'exampleValue2'], false], + ]; + } + /** * @test + * @dataProvider partsCanBeAcessedAfterBeingComittedDataProvider */ - public function partsCanBeAcessedAfterBeingComitted() + public function partsCanBeAcessedAfterBeingComitted($name, $data, $finished) { $state = new FormState(); - $state->commitPart('example', ['value' => 'exampleValue']); - $this->assertTrue($state->hasPart('example')); - $this->assertEquals(['value' => 'exampleValue'], $state->getPart('example')); - $this->assertEquals(['example' => ['value' => 'exampleValue']], $state->getAllParts()); - $this->assertEquals(['example'], $state->getCommittedPartNames()); + $state->commitPart($name, $data, $finished); + $this->assertTrue($state->hasPart($name)); + $this->assertEquals($data, $state->getPartData($name)); + $this->assertEquals($finished, $state->isPartFinished($name)); + $this->assertEquals([$name], $state->getCommittedPartNames()); } - /** - * @test - */ - public function initialPartsCanBeAcessed() + public function comittedPartsOverwriteExistingPartsWithSameNameDataProvider(): array { - $state = new FormState(['example' => ['value' => 'exampleValue']]); - $this->assertTrue($state->hasPart('example')); - $this->assertEquals(['value' => 'exampleValue'], $state->getPart('example')); - $this->assertEquals(['example' => ['value' => 'exampleValue']], $state->getAllParts()); - $this->assertEquals(['example'], $state->getCommittedPartNames()); + return [ + [['value1' => 'exampleValue1'], true, ['value1' => 'exampleValue1'], false], + [['value2' => 'exampleValue2'], false, ['value2' => 'exampleValue2'], true ], + [['value1' => 'exampleValue2'], true, [], false ], + [[], false, ['value1' => 'exampleValue2'], true ], + ]; } + /** * @test + * @dataProvider comittedPartsOverwriteExistingPartsWithSameNameDataProvider */ - public function comittedPartsOverwriteExistingPartsWithSameName() + public function comittedPartsOverwriteExistingPartsWithSameName($dataOriginal, $finishedOriginal, $dataAfter, $finishedAfter) { - $state = new FormState(['example' => ['value' => 'exampleValue']]); - $state->commitPart('example', ['another' => 'exampleValue']); + $state = new FormState(); + $state->commitPart('example', $dataOriginal, $finishedOriginal); + $state->commitPart('example', $dataAfter, $finishedAfter); + $this->assertTrue($state->hasPart('example')); - $this->assertEquals(['another' => 'exampleValue'], $state->getPart('example')); - $this->assertEquals(['example' => ['another' => 'exampleValue']], $state->getAllParts()); - $this->assertEquals(['example'], $state->getCommittedPartNames()); + $this->assertEquals($dataAfter, $state->getPartData('example')); + $this->assertEquals($finishedAfter, $state->isPartFinished('example')); } /** @@ -83,12 +92,17 @@ public function comittedPartsOverwriteExistingPartsWithSameName() */ public function comittedPartsAreAddedIfNamesAreDifferent() { - $state = new FormState(['example1' => ['value' => 'exampleValue']]); + $state = new FormState(); + $state->commitPart('example1', ['value' => 'exampleValue']); + $this->assertEquals(['example1'], $state->getCommittedPartNames()); + $this->assertTrue($state->hasPart('example1')); + $this->assertFalse($state->hasPart('example2')); + $state->commitPart('example2', ['another' => 'exampleValue']); $this->assertTrue($state->hasPart('example1')); $this->assertTrue($state->hasPart('example2')); - $this->assertEquals(['value' => 'exampleValue'], $state->getPart('example1')); - $this->assertEquals(['another' => 'exampleValue'], $state->getPart('example2')); + $this->assertEquals(['value' => 'exampleValue'], $state->getPartData('example1')); + $this->assertEquals(['another' => 'exampleValue'], $state->getPartData('example2')); $this->assertEquals(['example1', 'example2'], $state->getCommittedPartNames()); } }