diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b4f24..ab84d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +0.4.1 +----- + * Add `form_flow_*` Twig helper functions to access values from `FormFlowCursor` + * Add `form_flow_*` Twig helper functions to work with nested steps + 0.4.0 ----- * [Breaking Changes] Renamed the Form Flow classes to follow Symfony's native naming convention (`Flow` instead of `Flow`). The `Yceruto\FormFlowBundle\Form\Flow` namespace is unchanged; only class names changed: diff --git a/config/services.php b/config/services.php index ee3fc2a..e23c8a6 100644 --- a/config/services.php +++ b/config/services.php @@ -1,18 +1,25 @@ services() - ->set('form.type_extension.form.flow.session_data_storage', FormFlowTypeSessionDataStorageExtension::class) - ->args([service('request_stack')->ignoreOnInvalid()]) - ->tag('form.type_extension') + $services = $container->services(); + + $services + ->set('form.type_extension.form.flow.session_data_storage', FormFlowTypeSessionDataStorageExtension::class) + ->args([service('request_stack')->ignoreOnInvalid()]) + ->tag('form.type_extension') ; + + if (class_exists(AbstractExtension::class)) { + $services + ->set('form.flow.twig.extension', FormFlowExtension::class) + ->tag('twig.extension') + ; + } }; diff --git a/docs/index.md b/docs/index.md index 12be490..fc1b318 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,98 @@ -# FormflowBundle +# FormFlowBundle + +## Rendering a form flow + +A form flow only ever builds the **current step** plus its navigator, so the form itself +is rendered like any other Symfony form: + +```twig +{{ form(form) }} +``` + +Everything else — progress indicators, step lists, breadcrumbs, sidebars — is *navigation +chrome* that you render around the form using the `form_flow_*` Twig functions. Pass the +flow view (the variable you render with `form()`) to every function. + +### Cursor helpers (flat / position information) + +| Function | Returns | Notes | +|----------|---------|-------| +| `form_flow_total_steps(form)` | `int` | Number of steps | +| `form_flow_steps(form)` | `string[]` | Flat, ordered step names | +| `form_flow_step_index(form)` | `int` | 0-based index of the current step | +| `form_flow_current_step(form)` | `string` | | +| `form_flow_next_step(form)` / `form_flow_previous_step(form)` | `?string` | `null` at the last/first step | +| `form_flow_first_step(form)` / `form_flow_last_step(form)` | `string` | | +| `form_flow_is_first_step(form)` / `form_flow_is_last_step(form)` | `bool` | | +| `form_flow_can_move_next(form)` / `form_flow_can_move_back(form)` | `bool` | | + +```twig +{# "Step 2 of 3" #} +Step {{ form_flow_step_index(form) + 1 }} of {{ form_flow_total_steps(form) }} + +{# Navigation buttons #} +{% if form_flow_can_move_back(form) %}{{ form_widget(form.navigator.previous) }}{% endif %} +{% if form_flow_can_move_next(form) %} + {{ form_widget(form.navigator.next) }} +{% else %} + {{ form_widget(form.navigator.finish) }} +{% endif %} +``` + +### Nested-step helpers + +| Function | Returns | Notes | +|----------|---------|-------| +| `form_flow_root_steps(form)` | `string[]` | Top-level step names | +| `form_flow_parent_step(form, step?)` | `?string` | Defaults to the current step | +| `form_flow_child_steps(form, step?)` | `string[]` | Direct children | +| `form_flow_ancestor_steps(form, step?)` | `string[]` | Root → direct parent (breadcrumb) | +| `form_flow_step_depth(form, step?)` | `int` | 0 at the top level | +| `form_flow_is_group(form, step?)` | `bool` | A group is a non-visitable container | +| `form_flow_step_info(form, step?)` | `array` | Full per-step metadata (see below) | + +The optional `step` argument defaults to the current step. An unknown step name throws. + +## Which approach should I use? + +**Position / progress / a single breadcrumb → use the helper functions.** They are the most +direct way to get a value: + +```twig +{# Breadcrumb of the current (possibly nested) step #} +{% for step in form_flow_ancestor_steps(form) %}{{ step }} / {% endfor %}{{ form_flow_current_step(form) }} +``` + +**Rendering the whole tree (a stepper / sidebar) → iterate the `steps` view variable**, not the +helpers. `FormFlowType` exposes a ready-made nested tree in `form.vars.steps` (and a +skip-filtered `form.vars.visible_steps`). Each node already carries the state you need, so a +single recursive macro renders the entire hierarchy: + +```twig +{% macro steps(nodes) %} + {% import _self as self %} + +{% endmacro %} + +{% import _self as self %} +{{ self.steps(form.vars.visible_steps) }} +``` + +Each node in `steps` / `visible_steps` provides: +`name`, `level`, `index`, `position`, `is_current_step`, `is_before_current_step`, +`is_after_current_step`, `has_current_step_descendant`, `can_be_skipped`, `is_skipped`, +`is_group`, `children`, `visible_children`. + +Why prefer the tree variable for full rendering? Walking it is a single pass that preserves +hierarchy and visibility. Rebuilding the same tree from `form_flow_root_steps()` + +`form_flow_child_steps()` works (and produces identical output), but it performs repeated +name-based lookups and forces you to fetch each node's state separately via +`form_flow_step_info()`. Reserve the helpers for targeted questions ("what is the parent of +this step?", "is this a group?", "how deep is it?") and use `form.vars.steps` to draw the map. diff --git a/src/Twig/FormFlowExtension.php b/src/Twig/FormFlowExtension.php new file mode 100644 index 0000000..d3fa7c4 --- /dev/null +++ b/src/Twig/FormFlowExtension.php @@ -0,0 +1,258 @@ +getFormFlowTotalSteps(...)), + new TwigFunction('form_flow_steps', $this->getFormFlowSteps(...)), + new TwigFunction('form_flow_step_index', $this->getFormFlowStepIndex(...)), + new TwigFunction('form_flow_current_step', $this->getFormFlowCurrentStep(...)), + new TwigFunction('form_flow_next_step', $this->getFormFlowNextStep(...)), + new TwigFunction('form_flow_previous_step', $this->getFormFlowPreviousStep(...)), + new TwigFunction('form_flow_first_step', $this->getFormFlowFirstStep(...)), + new TwigFunction('form_flow_last_step', $this->getFormFlowLastStep(...)), + new TwigFunction('form_flow_is_first_step', $this->isFormFlowFirstStep(...)), + new TwigFunction('form_flow_is_last_step', $this->isFormFlowLastStep(...)), + new TwigFunction('form_flow_can_move_next', $this->canFormFlowMoveNext(...)), + new TwigFunction('form_flow_can_move_back', $this->canFormFlowMoveBack(...)), + new TwigFunction('form_flow_root_steps', $this->getFormFlowRootSteps(...)), + new TwigFunction('form_flow_parent_step', $this->getFormFlowParentStep(...)), + new TwigFunction('form_flow_child_steps', $this->getFormFlowChildSteps(...)), + new TwigFunction('form_flow_ancestor_steps', $this->getFormFlowAncestorSteps(...)), + new TwigFunction('form_flow_step_depth', $this->getFormFlowStepDepth(...)), + new TwigFunction('form_flow_is_group', $this->isFormFlowGroup(...)), + new TwigFunction('form_flow_step_info', $this->getFormFlowStepInfo(...)), + ]; + } + + public function getFormFlowTotalSteps(FormView $view): int + { + return $this->getCursor($view)->getTotalSteps(); + } + + /** + * @return list + */ + public function getFormFlowSteps(FormView $view): array + { + return $this->getCursor($view)->getSteps(); + } + + public function getFormFlowCurrentStep(FormView $view): string + { + return $this->getCursor($view)->getCurrentStep(); + } + + public function getFormFlowStepIndex(FormView $view): int + { + return $this->getCursor($view)->getStepIndex(); + } + + public function getFormFlowNextStep(FormView $view): ?string + { + return $this->getCursor($view)->getNextStep(); + } + + public function getFormFlowPreviousStep(FormView $view): ?string + { + return $this->getCursor($view)->getPreviousStep(); + } + + public function getFormFlowFirstStep(FormView $view): string + { + return $this->getCursor($view)->getFirstStep(); + } + + public function getFormFlowLastStep(FormView $view): string + { + return $this->getCursor($view)->getLastStep(); + } + + public function isFormFlowFirstStep(FormView $view): bool + { + return $this->getCursor($view)->isFirstStep(); + } + + public function isFormFlowLastStep(FormView $view): bool + { + return $this->getCursor($view)->isLastStep(); + } + + public function canFormFlowMoveBack(FormView $view): bool + { + return $this->getCursor($view)->canMoveBack(); + } + + public function canFormFlowMoveNext(FormView $view): bool + { + return $this->getCursor($view)->canMoveNext(); + } + + /** + * Returns the names of the top-level steps of the flow tree. + * + * @return list + */ + public function getFormFlowRootSteps(FormView $view): array + { + return array_keys($this->getStepsTree($view)); + } + + /** + * Returns the name of the parent step, or null if the given (or current) step is at the top level. + */ + public function getFormFlowParentStep(FormView $view, ?string $step = null): ?string + { + $ancestors = $this->locateStep($view, $step)['ancestors']; + + return [] === $ancestors ? null : $ancestors[\count($ancestors) - 1]; + } + + /** + * Returns the names of the direct child steps of the given (or current) step. + * + * @return list + */ + public function getFormFlowChildSteps(FormView $view, ?string $step = null): array + { + return array_keys($this->locateStep($view, $step)['info']['children']); + } + + /** + * Returns the ancestor step names of the given (or current) step, ordered from the root down to the direct parent. + * + * Useful to render a breadcrumb of the nested steps. + * + * @return list + */ + public function getFormFlowAncestorSteps(FormView $view, ?string $step = null): array + { + return $this->locateStep($view, $step)['ancestors']; + } + + /** + * Returns the nesting depth of the given (or current) step (0 for a top-level step). + */ + public function getFormFlowStepDepth(FormView $view, ?string $step = null): int + { + return $this->locateStep($view, $step)['info']['level']; + } + + /** + * Returns whether the given (or current) step is a group, i.e. a non-visitable container of child steps. + */ + public function isFormFlowGroup(FormView $view, ?string $step = null): bool + { + return $this->locateStep($view, $step)['info']['is_group']; + } + + /** + * Returns the computed view metadata of the given (or current) step from the nested steps tree. + * + * The returned array matches the per-step variables exposed by FormFlowType, e.g. + * `name`, `level`, `index`, `position`, `is_current_step`, `is_before_current_step`, + * `is_after_current_step`, `has_current_step_descendant`, `can_be_skipped`, `is_skipped`, + * `is_group`, `children` and `visible_children`. + * + * @return array + */ + public function getFormFlowStepInfo(FormView $view, ?string $step = null): array + { + return $this->locateStep($view, $step)['info']; + } + + private function getCursor(FormView $view): FormFlowCursor + { + $cursor = $view->vars['cursor'] ?? null; + + if (!$cursor instanceof FormFlowCursor) { + throw new LogicException('The "form_flow_*" functions can only be used on a form flow view; none was found in the given view.'); + } + + return $cursor; + } + + /** + * @return array> + */ + private function getStepsTree(FormView $view): array + { + $steps = $view->vars['steps'] ?? null; + + if (!\is_array($steps)) { + throw new LogicException('The "form_flow_*" functions can only be used on a form flow view; none was found in the given view.'); + } + + return $steps; + } + + /** + * Locates a step (defaulting to the current step) within the nested steps tree. + * + * @return array{info: array, ancestors: list} + */ + private function locateStep(FormView $view, ?string $step): array + { + $steps = $this->getStepsTree($view); + $step ??= $this->findCurrentStepName($steps) ?? throw new LogicException('No current step found in the flow view.'); + + return $this->searchStep($steps, $step) ?? throw new InvalidArgumentException(\sprintf('Step "%s" does not exist in the flow view.', $step)); + } + + /** + * Recursively looks up a step's computed metadata and ancestor trail (root down to the direct parent) by name. + * + * @param array> $steps + * @param list $ancestors + * + * @return array{info: array, ancestors: list}|null + */ + private function searchStep(array $steps, string $name, array $ancestors = []): ?array + { + foreach ($steps as $stepName => $info) { + if ($stepName === $name) { + return ['info' => $info, 'ancestors' => $ancestors]; + } + + if ([] !== $info['children'] && null !== $found = $this->searchStep($info['children'], $name, [...$ancestors, $stepName])) { + return $found; + } + } + + return null; + } + + /** + * Recursively finds the name of the current step within the nested steps tree. + * + * @param array> $steps + */ + private function findCurrentStepName(array $steps): ?string + { + foreach ($steps as $name => $info) { + if ($info['is_current_step']) { + return $name; + } + + if ([] !== $info['children'] && null !== $found = $this->findCurrentStepName($info['children'])) { + return $found; + } + } + + return null; + } +} diff --git a/tests/Flow/Type/NavigatorFlowTypeTest.php b/tests/Flow/Type/NavigatorFlowTypeTest.php new file mode 100644 index 0000000..e8f676c --- /dev/null +++ b/tests/Flow/Type/NavigatorFlowTypeTest.php @@ -0,0 +1,40 @@ +factory = Forms::createFormFactoryBuilder()->getFormFactory(); + } + + public function testDefaultOptionsDoNotIncludeReset() + { + $form = $this->factory->create(NavigatorFlowType::class); + + self::assertTrue($form->has('previous')); + self::assertTrue($form->has('next')); + self::assertTrue($form->has('finish')); + self::assertFalse($form->has('reset')); + } + + public function testWithResetOptionAddsResetButton() + { + $form = $this->factory->create(NavigatorFlowType::class, null, [ + 'with_reset' => true, + ]); + + self::assertTrue($form->has('previous')); + self::assertTrue($form->has('next')); + self::assertTrue($form->has('finish')); + self::assertTrue($form->has('reset')); + } +} diff --git a/tests/Integration/App/FormFlowStepper/Controller/StepperController.php b/tests/Integration/App/FormFlowStepper/Controller/StepperController.php new file mode 100644 index 0000000..d38dbda --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/Controller/StepperController.php @@ -0,0 +1,31 @@ +createForm(RegistrationType::class, new RegistrationDto()); + $flow->handleRequest($request); + + if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) { + return new RedirectResponse('/stepper/success'); + } + + return $this->render('stepper.html.twig', [ + 'form' => $flow->getStepForm(), + ]); + } +} diff --git a/tests/Integration/App/FormFlowStepper/Form/Data/RegistrationDto.php b/tests/Integration/App/FormFlowStepper/Form/Data/RegistrationDto.php new file mode 100644 index 0000000..2a34f79 --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/Form/Data/RegistrationDto.php @@ -0,0 +1,22 @@ +add('username'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RegistrationDto::class, + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Integration/App/FormFlowStepper/Form/Type/AccountSecurityType.php b/tests/Integration/App/FormFlowStepper/Form/Type/AccountSecurityType.php new file mode 100644 index 0000000..dd0169e --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/Form/Type/AccountSecurityType.php @@ -0,0 +1,24 @@ +add('password'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RegistrationDto::class, + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Integration/App/FormFlowStepper/Form/Type/ProfileType.php b/tests/Integration/App/FormFlowStepper/Form/Type/ProfileType.php new file mode 100644 index 0000000..fc01be9 --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/Form/Type/ProfileType.php @@ -0,0 +1,24 @@ +add('bio'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RegistrationDto::class, + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Integration/App/FormFlowStepper/Form/Type/RegistrationType.php b/tests/Integration/App/FormFlowStepper/Form/Type/RegistrationType.php new file mode 100644 index 0000000..d43e8ba --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/Form/Type/RegistrationType.php @@ -0,0 +1,35 @@ +addStep( + $builder->createStepGroup('account') + ->addStep('credentials', AccountCredentialsType::class) + ->addStep('security', AccountSecurityType::class) + ); + $builder->addStep('profile', ProfileType::class); + + $builder->add('navigator', NavigatorFlowType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => RegistrationDto::class, + 'step_property_path' => 'step', + ]); + } +} diff --git a/tests/Integration/App/FormFlowStepper/config.yaml b/tests/Integration/App/FormFlowStepper/config.yaml new file mode 100644 index 0000000..cf82558 --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/config.yaml @@ -0,0 +1,9 @@ +imports: + - '../config.yaml' + +services: + _defaults: + autowire: true + autoconfigure: true + + Yceruto\FormFlowBundle\Tests\Integration\App\FormFlowStepper\Controller\StepperController: ~ diff --git a/tests/Integration/App/FormFlowStepper/routes.yaml b/tests/Integration/App/FormFlowStepper/routes.yaml new file mode 100644 index 0000000..8461e2b --- /dev/null +++ b/tests/Integration/App/FormFlowStepper/routes.yaml @@ -0,0 +1,5 @@ +controllers: + resource: + path: ./Controller/ + namespace: Yceruto\FormFlowBundle\Tests\Integration\App\FormFlowStepper\Controller + type: attribute diff --git a/tests/Integration/App/Templates/stepper.html.twig b/tests/Integration/App/Templates/stepper.html.twig new file mode 100644 index 0000000..0d15f32 --- /dev/null +++ b/tests/Integration/App/Templates/stepper.html.twig @@ -0,0 +1,22 @@ +
+ + + {% macro tree(nodes) %} + {%- import _self as self -%} +
    + {%- for name, step in nodes %} +
  • {{ name }} + {%- if step.visible_children is not empty %}{{ self.tree(step.visible_children) }}{% endif -%} +
  • + {% endfor -%} +
+ {% endmacro %} + + {%- import _self as self -%} +
{{ self.tree(form.vars.visible_steps) }}
+ + {{ form(form) }} +
diff --git a/tests/Integration/FormFlowStepperTest.php b/tests/Integration/FormFlowStepperTest.php new file mode 100644 index 0000000..d6a3a69 --- /dev/null +++ b/tests/Integration/FormFlowStepperTest.php @@ -0,0 +1,84 @@ +request('GET', '/stepper'); + + self::assertSame(200, $client->getInternalResponse()->getStatusCode()); + + // The "account" group renders as a group node containing the visitable steps. + $stepper = $crawler->filter('.stepper')->html(); + self::assertStringContainsString('class="step is-group" data-level="0">account', $stepper); + self::assertStringContainsString('class="step is-current" data-level="1">credentials', $stepper); + self::assertStringContainsString('data-level="0">profile', $stepper); + + // Breadcrumb of the nested current step: account / credentials. + $breadcrumb = $crawler->filter('.breadcrumb')->html(); + self::assertStringContainsString('account', $breadcrumb); + self::assertStringContainsString('credentials', $breadcrumb); + + // Advance to the second child of the group. + $crawler = $client->submit($crawler->selectButton('Next')->form(), [ + 'registration[credentials][username]' => 'john', + 'registration[navigator][next]' => '', + ]); + + self::assertSame(200, $client->getInternalResponse()->getStatusCode()); + self::assertStringContainsString('class="step is-current" data-level="1">security', $crawler->filter('.stepper')->html()); + self::assertStringContainsString('account', $crawler->filter('.breadcrumb')->html()); + self::assertStringContainsString('security', $crawler->filter('.breadcrumb')->html()); + + // Advance to the top-level profile step: no ancestors in the breadcrumb. + $crawler = $client->submit($crawler->selectButton('Next')->form(), [ + 'registration[security][password]' => 'secret', + 'registration[navigator][next]' => '', + ]); + + self::assertSame(200, $client->getInternalResponse()->getStatusCode()); + self::assertStringContainsString('class="step is-current" data-level="0">profile', $crawler->filter('.stepper')->html()); + + $breadcrumb = $crawler->filter('.breadcrumb')->html(); + self::assertStringNotContainsString('class="ancestor"', $breadcrumb); + self::assertStringContainsString('profile', $breadcrumb); + + // Finishing on the last step redirects. + $client->submit($crawler->selectButton('Finish')->form(), [ + 'registration[profile][bio]' => 'hello', + 'registration[navigator][finish]' => '', + ]); + + self::assertSame(302, $client->getInternalResponse()->getStatusCode()); + self::assertSame('/stepper/success', $client->getInternalResponse()->getHeader('Location')); + } + + public function testStepperBacktracksAcrossGroupBoundary(): void + { + $client = self::createClient(); + $crawler = $client->request('GET', '/stepper'); + + $crawler = $client->submit($crawler->selectButton('Next')->form(), [ + 'registration[credentials][username]' => 'john', + 'registration[navigator][next]' => '', + ]); + + self::assertStringContainsString('security', $crawler->filter('.breadcrumb')->html()); + + // Go back to the first child of the group. + $crawler = $client->submit($crawler->selectButton('Previous')->form(), [ + 'registration[navigator][previous]' => '', + ]); + + self::assertSame(200, $client->getInternalResponse()->getStatusCode()); + self::assertStringContainsString('credentials', $crawler->filter('.breadcrumb')->html()); + self::assertStringContainsString('value="john"', $crawler->html()); + } +} diff --git a/tests/Twig/FormFlowExtensionRenderingTest.php b/tests/Twig/FormFlowExtensionRenderingTest.php new file mode 100644 index 0000000..2549aea --- /dev/null +++ b/tests/Twig/FormFlowExtensionRenderingTest.php @@ -0,0 +1,154 @@ +factory = Forms::createFormFactoryBuilder() + ->setResolvedTypeFactory(new ResolvedFormTypeFactory()) + ->getFormFactory(); + } + + public function testFlatProgressIndicator() + { + $view = $this->createFlatFlowView('professional'); + + $output = $this->render('Step {{ form_flow_step_index(form) + 1 }} of {{ form_flow_total_steps(form) }}', $view); + + self::assertSame('Step 2 of 3', $output); + } + + public function testFlatStepListWithCurrentMarker() + { + $view = $this->createFlatFlowView('professional'); + + $template = '{% for step in form_flow_steps(form) %}{{ step }}' + .'{{ step == form_flow_current_step(form) ? "*" : "" }}{{ not loop.last ? " " : "" }}{% endfor %}'; + + self::assertSame('personal professional* account', $this->render($template, $view)); + } + + /** + * Navigation buttons are best driven by the movement helpers (can_move_back / can_move_next). + */ + public function testNavigationButtonsViaHelpers() + { + $template = '{{ form_flow_can_move_back(form) ? "back " : "" }}{{ form_flow_can_move_next(form) ? "next" : "finish" }}'; + + self::assertSame('next', $this->render($template, $this->createFlatFlowView('personal'))); + self::assertSame('back next', $this->render($template, $this->createFlatFlowView('professional'))); + self::assertSame('back finish', $this->render($template, $this->createFlatFlowView('account'))); + } + + /** + * Breadcrumbs of a nested step are best built with the ancestor helper. + */ + public function testNestedBreadcrumbViaAncestorHelper() + { + $view = $this->createNestedFlowView('stepB11'); + + $template = '{% for step in form_flow_ancestor_steps(form) %}{{ step }} / {% endfor %}{{ form_flow_current_step(form) }}'; + + self::assertSame('stepB / stepB1 / stepB11', $this->render($template, $view)); + } + + /** + * RECOMMENDED for full nested navigation: recurse over the `steps` view variable with a macro. + * A single recursive walk renders the whole hierarchy with per-step state already attached. + */ + public function testNestedTreeViaStepsVarRecursion() + { + $view = $this->createNestedFlowView('stepB11'); + + $template = '{% macro branch(steps) %}{% import _self as m %}' + .'{% for name, step in steps %}{{ name }}{{ step.is_current_step ? "*" : "" }}' + .'{% if step.children is not empty %}({{ m.branch(step.children) }}){% endif %}' + .'{{ not loop.last ? " " : "" }}{% endfor %}{% endmacro %}' + .'{% import _self as m %}{{ m.branch(form.vars.steps) }}'; + + self::assertSame( + 'stepA(stepA1 stepA2 stepA3) stepB(stepB1(stepB11* stepB12) stepB2) stepC', + $this->render($template, $view), + ); + } + + /** + * The same nested tree can be rebuilt purely from the helpers (root_steps + child_steps), + * but it requires repeated name-based lookups; this proves equivalence with the steps-var walk. + */ + public function testNestedTreeViaHelpersIsEquivalent() + { + $view = $this->createNestedFlowView('stepB11'); + + $template = '{% macro branch(form, names) %}{% import _self as m %}' + .'{% for name in names %}{{ name }}{{ name == form_flow_current_step(form) ? "*" : "" }}' + .'{% set children = form_flow_child_steps(form, name) %}' + .'{% if children is not empty %}({{ m.branch(form, children) }}){% endif %}' + .'{{ not loop.last ? " " : "" }}{% endfor %}{% endmacro %}' + .'{% import _self as m %}{{ m.branch(form, form_flow_root_steps(form)) }}'; + + self::assertSame( + 'stepA(stepA1 stepA2 stepA3) stepB(stepB1(stepB11* stepB12) stepB2) stepC', + $this->render($template, $view), + ); + } + + /** + * Group headers vs. visitable steps can be told apart with form_flow_is_group() + * and the per-step metadata from form_flow_step_info(). + */ + public function testGroupDetectionAndStepInfo() + { + $view = $this->createNestedFlowView('stepA1'); + + $template = '{{ form_flow_is_group(form, "stepA") ? "group" : "step" }}:{{ form_flow_step_info(form, "stepA").level }}' + .';{{ form_flow_is_group(form, "stepC") ? "group" : "step" }}:{{ form_flow_step_info(form, "stepC").level }}' + .';depth-b11={{ form_flow_step_depth(form, "stepB11") }}'; + + self::assertSame('group:0;step:0;depth-b11=2', $this->render($template, $view)); + } + + private function render(string $template, FormView $view): string + { + $twig = new Environment(new ArrayLoader(['flow' => $template])); + $twig->addExtension(new FormFlowExtension()); + + return $twig->render('flow', ['form' => $view]); + } + + private function createFlatFlowView(string $currentStep): FormView + { + $data = new UserSignUp(); + $data->worker = true; + $data->currentStep = $currentStep; + + return $this->factory->create(UserSignUpType::class, $data) + ->getStepForm() + ->createView(); + } + + private function createNestedFlowView(string $currentStep): FormView + { + return $this->factory->create(NestedStepsFlowType::class, ['currentStep' => $currentStep])->createView(); + } +} diff --git a/tests/Twig/FormFlowExtensionTest.php b/tests/Twig/FormFlowExtensionTest.php new file mode 100644 index 0000000..749e38e --- /dev/null +++ b/tests/Twig/FormFlowExtensionTest.php @@ -0,0 +1,209 @@ +factory = Forms::createFormFactoryBuilder() + ->setResolvedTypeFactory(new ResolvedFormTypeFactory()) + ->getFormFactory(); + + $this->extension = new FormFlowExtension(); + } + + public function testFlowAtFirstStep() + { + $view = $this->createFlowView('personal'); + + self::assertSame(3, $this->extension->getFormFlowTotalSteps($view)); + self::assertSame(['personal', 'professional', 'account'], $this->extension->getFormFlowSteps($view)); + self::assertSame('personal', $this->extension->getFormFlowCurrentStep($view)); + self::assertSame(0, $this->extension->getFormFlowStepIndex($view)); + self::assertSame('professional', $this->extension->getFormFlowNextStep($view)); + self::assertNull($this->extension->getFormFlowPreviousStep($view)); + self::assertSame('personal', $this->extension->getFormFlowFirstStep($view)); + self::assertSame('account', $this->extension->getFormFlowLastStep($view)); + self::assertTrue($this->extension->isFormFlowFirstStep($view)); + self::assertFalse($this->extension->isFormFlowLastStep($view)); + self::assertTrue($this->extension->canFormFlowMoveNext($view)); + self::assertFalse($this->extension->canFormFlowMoveBack($view)); + } + + public function testFlowAtMiddleStep() + { + $view = $this->createFlowView('professional'); + + self::assertSame(3, $this->extension->getFormFlowTotalSteps($view)); + self::assertSame(['personal', 'professional', 'account'], $this->extension->getFormFlowSteps($view)); + self::assertSame('professional', $this->extension->getFormFlowCurrentStep($view)); + self::assertSame(1, $this->extension->getFormFlowStepIndex($view)); + self::assertSame('account', $this->extension->getFormFlowNextStep($view)); + self::assertSame('personal', $this->extension->getFormFlowPreviousStep($view)); + self::assertSame('personal', $this->extension->getFormFlowFirstStep($view)); + self::assertSame('account', $this->extension->getFormFlowLastStep($view)); + self::assertFalse($this->extension->isFormFlowFirstStep($view)); + self::assertFalse($this->extension->isFormFlowLastStep($view)); + self::assertTrue($this->extension->canFormFlowMoveNext($view)); + self::assertTrue($this->extension->canFormFlowMoveBack($view)); + } + + public function testFlowAtLastStep() + { + $view = $this->createFlowView('account'); + + self::assertSame(3, $this->extension->getFormFlowTotalSteps($view)); + self::assertSame(['personal', 'professional', 'account'], $this->extension->getFormFlowSteps($view)); + self::assertSame('account', $this->extension->getFormFlowCurrentStep($view)); + self::assertSame(2, $this->extension->getFormFlowStepIndex($view)); + self::assertNull($this->extension->getFormFlowNextStep($view)); + self::assertSame('professional', $this->extension->getFormFlowPreviousStep($view)); + self::assertSame('personal', $this->extension->getFormFlowFirstStep($view)); + self::assertSame('account', $this->extension->getFormFlowLastStep($view)); + self::assertFalse($this->extension->isFormFlowFirstStep($view)); + self::assertTrue($this->extension->isFormFlowLastStep($view)); + self::assertFalse($this->extension->canFormFlowMoveNext($view)); + self::assertTrue($this->extension->canFormFlowMoveBack($view)); + } + + public function testFormWithoutFlowThrows() + { + $view = $this->factory->create(FormType::class)->createView(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "form_flow_*" functions can only be used on a form flow view'); + + $this->extension->getFormFlowCurrentStep($view); + } + + public function testRootSteps() + { + $view = $this->createNestedFlowView(); + + self::assertSame(['stepA', 'stepB', 'stepC'], $this->extension->getFormFlowRootSteps($view)); + } + + public function testParentStep() + { + $view = $this->createNestedFlowView(); + + // Defaults to the current step (stepA1, the first visitable step). + self::assertSame('stepA', $this->extension->getFormFlowParentStep($view)); + self::assertSame('stepA', $this->extension->getFormFlowParentStep($view, 'stepA1')); + self::assertSame('stepB1', $this->extension->getFormFlowParentStep($view, 'stepB11')); + self::assertNull($this->extension->getFormFlowParentStep($view, 'stepC')); + } + + public function testChildSteps() + { + $view = $this->createNestedFlowView(); + + self::assertSame(['stepA1', 'stepA2', 'stepA3'], $this->extension->getFormFlowChildSteps($view, 'stepA')); + self::assertSame(['stepB1', 'stepB2'], $this->extension->getFormFlowChildSteps($view, 'stepB')); + self::assertSame(['stepB11', 'stepB12'], $this->extension->getFormFlowChildSteps($view, 'stepB1')); + self::assertSame([], $this->extension->getFormFlowChildSteps($view, 'stepC')); + } + + public function testAncestorSteps() + { + $view = $this->createNestedFlowView(); + + self::assertSame(['stepA'], $this->extension->getFormFlowAncestorSteps($view, 'stepA1')); + self::assertSame(['stepB', 'stepB1'], $this->extension->getFormFlowAncestorSteps($view, 'stepB11')); + self::assertSame([], $this->extension->getFormFlowAncestorSteps($view, 'stepC')); + } + + public function testStepDepth() + { + $view = $this->createNestedFlowView(); + + self::assertSame(0, $this->extension->getFormFlowStepDepth($view, 'stepA')); + self::assertSame(1, $this->extension->getFormFlowStepDepth($view, 'stepA1')); + self::assertSame(1, $this->extension->getFormFlowStepDepth($view, 'stepB1')); + self::assertSame(2, $this->extension->getFormFlowStepDepth($view, 'stepB11')); + self::assertSame(0, $this->extension->getFormFlowStepDepth($view, 'stepC')); + } + + public function testIsGroup() + { + $view = $this->createNestedFlowView(); + + self::assertTrue($this->extension->isFormFlowGroup($view, 'stepA')); + // stepB1 has children but was not created as a group. + self::assertFalse($this->extension->isFormFlowGroup($view, 'stepB1')); + self::assertFalse($this->extension->isFormFlowGroup($view, 'stepC')); + } + + public function testStepInfo() + { + $view = $this->createNestedFlowView(); + + $info = $this->extension->getFormFlowStepInfo($view, 'stepB1'); + + self::assertSame('stepB1', $info['name']); + self::assertSame(1, $info['level']); + self::assertFalse($info['is_group']); + self::assertFalse($info['is_skipped']); + self::assertArrayHasKey('stepB11', $info['children']); + self::assertArrayHasKey('stepB12', $info['children']); + + // The current step (stepA1) is flagged as such in the computed tree. + self::assertTrue($this->extension->getFormFlowStepInfo($view, 'stepA1')['is_current_step']); + + // Defaults to the current step when no step name is given. + self::assertSame('stepA1', $this->extension->getFormFlowStepInfo($view)['name']); + } + + public function testStepInfoForUnknownStepThrows() + { + $view = $this->createNestedFlowView(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step "unknown" does not exist in the flow view.'); + + $this->extension->getFormFlowStepInfo($view, 'unknown'); + } + + public function testNestedHelpersOnFormWithoutFlowThrow() + { + $view = $this->factory->create(FormType::class)->createView(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "form_flow_*" functions can only be used on a form flow view'); + + $this->extension->getFormFlowRootSteps($view); + } + + private function createFlowView(string $currentStep): FormView + { + $data = new UserSignUp(); + $data->worker = true; + $data->currentStep = $currentStep; + + return $this->factory->create(UserSignUpType::class, $data) + ->getStepForm() + ->createView(); + } + + private function createNestedFlowView(): FormView + { + return $this->factory->create(NestedStepsFlowType::class, [])->createView(); + } +}