diff --git a/src/Controller/Onboarding/OnboardingController.php b/src/Controller/Onboarding/OnboardingController.php index 09d40b22..b92bba1c 100644 --- a/src/Controller/Onboarding/OnboardingController.php +++ b/src/Controller/Onboarding/OnboardingController.php @@ -47,7 +47,7 @@ final class OnboardingController extends AbstractController * progress rail generates a URL for every applicable step. Adding a case * without adding it here is what OnboardingStepCoverageTest now checks. */ - public const string STEP_PATTERN = 'admin-mail|admin-integrations|account|profile|security|appearance|integrations'; + public const string STEP_PATTERN = 'admin-mail|admin-integrations|admin-ai|account|profile|security|appearance|integrations'; public function __construct( private readonly OnboardingFlow $flow, diff --git a/src/Domain/Enum/Onboarding/OnboardingStep.php b/src/Domain/Enum/Onboarding/OnboardingStep.php index 9b5ddf43..a3855308 100644 --- a/src/Domain/Enum/Onboarding/OnboardingStep.php +++ b/src/Domain/Enum/Onboarding/OnboardingStep.php @@ -30,6 +30,10 @@ enum OnboardingStep: string { case AdminMailCredentials = 'admin-mail'; case AdminIntegrationCredentials = 'admin-integrations'; + // After the two credential steps and before anything a person sets up for + // themselves: it configures the install, and it is the only one of the + // three an admin can reasonably answer with "no". + case AdminAi = 'admin-ai'; case Account = 'account'; case Integrations = 'integrations'; case Profile = 'profile'; @@ -48,6 +52,7 @@ enum OnboardingStep: string return match ($this) { self::AdminMailCredentials => 'fa-solid fa-key', self::AdminIntegrationCredentials => 'fa-solid fa-plug-circle-bolt', + self::AdminAi => 'fa-solid fa-wand-magic-sparkles', self::Account => 'fa-solid fa-envelope', self::Profile => 'fa-solid fa-user', self::Security => 'fa-solid fa-shield-halved', @@ -63,7 +68,7 @@ enum OnboardingStep: string public function requiresAdmin(): bool { return match ($this) { - self::AdminMailCredentials, self::AdminIntegrationCredentials => true, + self::AdminMailCredentials, self::AdminIntegrationCredentials, self::AdminAi => true, default => false, }; } diff --git a/src/Service/Onboarding/Step/AdminAiStepHandler.php b/src/Service/Onboarding/Step/AdminAiStepHandler.php new file mode 100644 index 00000000..6e8de314 --- /dev/null +++ b/src/Service/Onboarding/Step/AdminAiStepHandler.php @@ -0,0 +1,121 @@ +getRoles(), true); + } + + public function isSatisfied(User $user): bool + { + // A row exists only because somebody pressed the button on this page or + // in Admin → AI, so its presence is the record that the question was + // put and answered — including answered "no". + return null !== $this->settings->current(); + } + + public function failureMessage(User $user): ?string + { + // Nothing here can be verified without reaching another machine, and a + // wizard step that fails because a container is not up yet would block + // setup on something that has nothing to do with setup. The Test button + // in Admin → AI is where that question belongs. + return null; + } + + public function template(): string + { + return 'onboarding/steps/_admin_ai.html.twig'; + } + + public function createForm(User $user, Request $request): FormInterface + { + return $this->formFactory->create(AiSettingsType::class, $this->settings->currentOrDefault(), [ + // Explicit, because a form rendered into a turbo-frame otherwise + // posts to the document URL — which here is whatever page the + // wizard was opened over. + 'action' => $this->urlGenerator->generate('app_onboarding_step', [ + 'step' => $this->step()->value, + ]), + ]); + } + + public function viewData(User $user, Request $request): array + { + return [ + 'settings' => $this->settings->currentOrDefault(), + 'aside_template' => 'onboarding/steps/_admin_ai_aside.html.twig', + ]; + } + + public function persist(User $user, FormInterface $form): void + { + $settings = $form->getData(); + + // Unmapped, so an empty box leaves a stored token alone rather than + // clearing it — the same rule the admin page follows. + $token = (string) $form->get('apiToken')->getData(); + + if ('' !== trim($token)) { + $settings->apiToken = $token; + } + + // Persisted even when every box is empty, and that is the point: the + // row IS the record that somebody was asked and said no. Without it the + // step would be unsatisfiable by anybody who does not want the feature. + $this->entityManager->persist($settings); + $this->entityManager->flush(); + } +} diff --git a/templates/onboarding/steps/_admin_ai.html.twig b/templates/onboarding/steps/_admin_ai.html.twig new file mode 100644 index 00000000..e47e169c --- /dev/null +++ b/templates/onboarding/steps/_admin_ai.html.twig @@ -0,0 +1,13 @@ +{# +The optional step. Everything here may be left exactly as it is, and pressing +Next with nothing filled in is a complete answer — see AdminAiStepHandler on why +that has to be recorded rather than merely tolerated. + +The form is the admin panel's own, so there is one description of these fields +rather than two that drift apart. +#} +
+ {{ 'onboarding.step.admin-ai.intro'|trans }} +
+ +{{ form_widget(form) }} diff --git a/templates/onboarding/steps/_admin_ai_aside.html.twig b/templates/onboarding/steps/_admin_ai_aside.html.twig new file mode 100644 index 00000000..2cfa8915 --- /dev/null +++ b/templates/onboarding/steps/_admin_ai_aside.html.twig @@ -0,0 +1,9 @@ +{# What somebody needs to have ready before this step is answerable, and + permission to skip it — which is the more important half. #} +{{ 'onboarding.aside.admin_ai.heading'|trans }}
+{{ 'onboarding.aside.admin_ai.body'|trans }}
+ +{{ 'onboarding.aside.admin_ai.skip_heading'|trans }}
+{{ 'onboarding.aside.admin_ai.skip_body'|trans }}
+