Decouple title from title block (#696)
* Decouple title from title block * fix lint * remove dry run for FormTitleMigration * Skip form title migration for forms with hidden titles * Refactor AI Form Generation with Dedicated Prompt Services - Extract AI form generation logic from GenerateTemplate command into dedicated prompt service classes - Update GenerateAiForm job to use new prompt generation services - Improve GptCompleter with more robust error handling and token tracking - Add error field to AiFormCompletion model for better error logging - Simplify command signature from 'ai:make-form-template' to 'form:generate' * Consolidate Template Metadata Generation with Unified Prompt Service - Create GenerateTemplateMetadataPrompt to centralize template metadata generation - Update GenerateTemplate command to use new consolidated metadata generation approach - Enhance GptCompleter to support strict JSON schema validation - Increase form prompt max length to support more complex form descriptions - Refactor form generation to simplify metadata extraction and processing * Implement Form Mode Strategy for Flexible Form Rendering - Introduce FormModeStrategy to centralize form rendering logic - Add support for different form modes: LIVE, PREVIEW, TEST, EDIT, PREFILL - Refactor components to use mode-based rendering strategy - Remove legacy boolean props like adminPreview and creating - Enhance form component flexibility and reusability * Refine Form Mode Strategy Display Behavior - Update FormModeStrategy to hide hidden fields in PREVIEW mode - Add FormMode getter in UrlFormPrefill component for mode-specific rendering - Clarify mode-specific validation and display logic in form strategies * Enhance Form Template Generation with Advanced Field Options - Update GenerateTemplate command to use front_url for template URL output - Expand GenerateFormPrompt with comprehensive field configuration options - Add support for advanced field types: date with time, toggle switches, radio/checkbox selections - Introduce field width configuration and HTML formatting for text elements - Re-enable select, multi-select, and matrix field type definitions with enhanced configurations * Remove Deprecated Template Metadata Generation Services - Delete multiple AI prompt services related to template metadata generation - Simplify GenerateTemplate command to use default values instead of complex metadata generation - Remove GenerateTemplateMetadataPrompt and related classes like GenerateTemplateDescriptionPrompt, GenerateTemplateImageKeywordsPrompt, etc. - Update form template generation to use basic fallback metadata generation approach * Restore GenerateTemplateMetadataPrompt for Comprehensive Template Generation - Reintroduce GenerateTemplateMetadataPrompt to replace default metadata generation - Update GenerateTemplate command to use consolidated metadata generation approach - Extract detailed metadata components including title, description, industries, and image search query - Improve template generation with more dynamic and AI-generated metadata * Refactor Template Preview Section Layout - Remove unnecessary nested div in template preview section - Simplify HTML structure for the template preview component - Maintain existing styling and functionality while improving code readability * Refactor Constructor and Code Formatting in AI Form Generation and Prompt Classes - Updated the constructor in GenerateAiForm.php to use a block structure for improved readability and consistency. - Added a blank line in the Prompt.php file to enhance code formatting and maintain consistency with PHP coding standards. - Modified the migration file to use a more concise class declaration syntax, improving clarity. These changes aim to enhance code readability and maintainability across the affected files. --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
d2b8572d75
commit
aa5c1acf3a
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\Template;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FormTitleMigration extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'forms:form-title-migration';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'One Time Only -- Migrate Form Title to new Form Title';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->formMigration();
|
||||
|
||||
$this->formTemplateMigration();
|
||||
|
||||
$this->line('Migration Done');
|
||||
}
|
||||
|
||||
public function formMigration()
|
||||
{
|
||||
$this->info('Starting forms migration...');
|
||||
|
||||
Form::chunk(100, function ($forms) {
|
||||
foreach ($forms as $form) {
|
||||
if ($form?->hide_title ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$properties = $form->properties ?? [];
|
||||
|
||||
array_unshift($properties, [
|
||||
'type' => 'nf-text',
|
||||
'content' => '<h1>' . $form->title . '</h1>',
|
||||
'name' => 'Title',
|
||||
'align' => $form->layout_rtl ? 'right' : 'left',
|
||||
'id' => Str::uuid()
|
||||
]);
|
||||
|
||||
$form->properties = $properties;
|
||||
$form->timestamps = false;
|
||||
$form->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function formTemplateMigration()
|
||||
{
|
||||
$this->info('Starting forms template migration...');
|
||||
|
||||
Template::chunk(100, function ($templates) {
|
||||
foreach ($templates as $template) {
|
||||
$structure = $template->structure ?? [];
|
||||
if (!$structure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$properties = $structure['properties'] ?? [];
|
||||
|
||||
array_unshift($properties, [
|
||||
'type' => 'nf-text',
|
||||
'content' => '<h1>' . $structure['title'] . '</h1>',
|
||||
'name' => 'Title',
|
||||
'align' => isset($structure['layout_rtl']) && $structure['layout_rtl'] ? 'right' : 'left',
|
||||
'id' => Str::uuid()
|
||||
]);
|
||||
|
||||
$structure['properties'] = $properties;
|
||||
$template->structure = $structure;
|
||||
$template->timestamps = false;
|
||||
$template->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Template;
|
||||
use App\Service\OpenAi\GptCompleter;
|
||||
use App\Service\AI\Prompts\Form\GenerateFormPrompt;
|
||||
use App\Service\AI\Prompts\Template\GenerateTemplateMetadataPrompt;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -15,7 +16,7 @@ class GenerateTemplate extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'ai:make-form-template {prompt}';
|
||||
protected $signature = 'form:generate {prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
|
|
@ -26,190 +27,6 @@ class GenerateTemplate extends Command
|
|||
|
||||
public const MAX_RELATED_TEMPLATES = 8;
|
||||
|
||||
public const FORM_STRUCTURE_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to build a form for our user.
|
||||
|
||||
Forms are represented as Json objects. Here's an example form:
|
||||
```json
|
||||
{
|
||||
"title": "Contact Us",
|
||||
"properties": [
|
||||
{
|
||||
"help": null,
|
||||
"name": "What's your name?",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": "Steve Jobs"
|
||||
},
|
||||
{
|
||||
"help": "We will never share your email with anyone else.",
|
||||
"name": "Email",
|
||||
"type": "email",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": "steve@apple.com"
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "How would you rate your overall experience?",
|
||||
"type": "select",
|
||||
"hidden": false,
|
||||
"select": {
|
||||
"options": [
|
||||
{"name": 1, "value": 1},
|
||||
{"name": 2, "value": 2},
|
||||
{"name": 3, "value": 3},
|
||||
{"name": 4, "value": 4},
|
||||
{"name": 5, "value": 5}
|
||||
]
|
||||
},
|
||||
"prefill": 5,
|
||||
"required": true,
|
||||
"placeholder": null
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "Subject",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": null
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "How can we help?",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"multi_lines": true,
|
||||
"placeholder": null,
|
||||
"generates_uuid": false,
|
||||
"max_char_limit": "2000",
|
||||
"hide_field_name": false,
|
||||
"show_char_limit": false
|
||||
},
|
||||
{
|
||||
"help": "Upload any relevant files here.",
|
||||
"name": "Have any attachments?",
|
||||
"type": "files",
|
||||
"hidden": false,
|
||||
"placeholder": null
|
||||
}
|
||||
],
|
||||
"description": "<p>Looking for a real person to speak to?</p><p>We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.</p>",
|
||||
"re_fillable": false,
|
||||
"use_captcha": false,
|
||||
"redirect_url": null,
|
||||
"submitted_text": "<p>Great, we've received your message. We'll get back to you as soon as we can :)</p>",
|
||||
"uppercase_labels": false,
|
||||
"submit_button_text": "Submit",
|
||||
"re_fill_button_text": "Fill Again",
|
||||
"color": "#64748b"
|
||||
}
|
||||
```
|
||||
The form properties can only have one of the following types: 'text', 'number', 'rating', 'scale','slider', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number', 'signature'.
|
||||
All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'.
|
||||
The placeholder property is optional (can be "null") and is used to display a placeholder text in the input field.
|
||||
The help property is optional (can be "null") and is used to display extra information about the field.
|
||||
|
||||
For the type "select" and "multi_select", the input object must have a key "select" (or "multi_select") that's mapped to an object like this one:
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{"name": 1, "value": 1},
|
||||
{"name": 2, "value": 2},
|
||||
{"name": 3, "value": 3},
|
||||
{"name": 4, "value": 4}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For "rating" you can set the field property "rating_max_value" to set the maximum value of the rating.
|
||||
For "scale" you can set the field property "scale_min_value", "scale_max_value" and "scale_step_value" to set the minimum, maximum and step value of the scale.
|
||||
For "slider" you can set the field property "slider_min_value", "slider_max_value" and "slider_step_value" to set the minimum, maximum and step value of the slider.
|
||||
|
||||
If the form is too long, you can paginate it by adding a page break block in the list of properties:
|
||||
```json
|
||||
{
|
||||
"name":"Page Break",
|
||||
"next_btn_text":"Next",
|
||||
"previous_btn_text":"Previous",
|
||||
"type":"nf-page-break",
|
||||
}
|
||||
```
|
||||
|
||||
If you need to add more context to the form, you can add text blocks:
|
||||
```json
|
||||
{
|
||||
"name":"My Text",
|
||||
"type":"nf-text",
|
||||
"content": "<p>This is a text block.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
Give me the valid JSON object only, representing the following form: "[REPLACE]"
|
||||
Do not ask me for more information about required properties or types, only suggest me a form structure.
|
||||
EOD;
|
||||
|
||||
public const FORM_DESCRIPTION_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
Give me some valid html code (using only h2, p, ul, li html tags) for the following form template page: "[REPLACE]".
|
||||
|
||||
The html code should have the following structure:
|
||||
- A paragraph explaining what the template is about
|
||||
- A paragraph explaining why and when to use such a form
|
||||
- A paragraph explaining who is the target audience and why it's a great idea to build this form
|
||||
- A paragraph explaining that OpnForm is the best tool to build this form. They can duplicate this template in a few seconds, and integrate with many other tools through our webhook or zapier integration.
|
||||
Each paragraph (except for the first one) MUST start with with a h2 tag containing a title for this paragraph.
|
||||
EOD;
|
||||
|
||||
public const FORM_SHORT_DESCRIPTION_PROMPT = <<<'EOD'
|
||||
I own a form builder online named OpnForm. It's free to use.
|
||||
Give me a 1 sentence description for the following form template page: "[REPLACE]". It should be short and concise, but still explain what the form is about.
|
||||
EOD;
|
||||
|
||||
public const FORM_INDUSTRY_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
I am creating a form template: "[REPLACE]". You must assign the template to industries. Return a list of industries (minimum 1, maximum 3 but only if very relevant) and order them by relevance (most relevant first).
|
||||
|
||||
Here are the only industries you can choose from: [INDUSTRIES]
|
||||
Do no make up any new type, only use the ones listed above.
|
||||
|
||||
Reply only with a valid JSON array, being an array of string. Order assigned industries from the most relevant to the less relevant.
|
||||
Ex: { "industries": ["banking_forms","customer_service_forms"]}
|
||||
EOD;
|
||||
|
||||
public const FORM_TYPES_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
I am creating a form template: "[REPLACE]". You must assign the template to one or more types. Return a list of types (minimum 1, maximum 3 but only if very accurate) and order them by relevance (most relevant first).
|
||||
|
||||
Here are the only types you can choose from: [TYPES]
|
||||
Do no make up any new type, only use the ones listed above.
|
||||
|
||||
Reply only with a valid JSON array, being an array of string. Order assigned types from the most relevant to the less relevant.
|
||||
Ex: { "types": ["consent_forms","award_forms"]}
|
||||
EOD;
|
||||
|
||||
public const FORM_QAS_PROMPT = <<<'EOD'
|
||||
Now give me 4 to 6 question and answers to put on the form template page. The questions should be about the reasons for this template (when to use, why, target audience, goal etc.).
|
||||
The questions should also explain why OpnForm is the best option to create this form (open-source, free to use, integrations etc).
|
||||
Reply only with a valid JSON, being an array of object containing the keys "question" and "answer".
|
||||
EOD;
|
||||
|
||||
public const FORM_TITLE_PROMPT = <<<'EOD'
|
||||
Finally give me a title for the template. It must contain or end with "template". It should be short and to the point, without any quotes.
|
||||
EOD;
|
||||
|
||||
public const FORM_IMG_KEYWORDS_PROMPT = <<<'EOD'
|
||||
I want to add an image to illustrate this form template page. Give me a relevant search query for unsplash. Reply only with a valid JSON like this:
|
||||
```json
|
||||
{
|
||||
"search_query": ""
|
||||
}
|
||||
```
|
||||
EOD;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
|
|
@ -217,57 +34,25 @@ class GenerateTemplate extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Get form structure
|
||||
$completer = (new GptCompleter(config('services.openai.api_key')))
|
||||
->useStreaming()
|
||||
->setSystemMessage('You are an assistant helping to generate forms.');
|
||||
$completer->expectsJson()->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
|
||||
]);
|
||||
$formData = $completer->getArray();
|
||||
$formData = self::cleanAiOutput($formData);
|
||||
// Get form structure using the form prompt class
|
||||
$formData = GenerateFormPrompt::run($this->argument('prompt'));
|
||||
|
||||
$completer->doesNotExpectJson();
|
||||
$formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString();
|
||||
$formShortDescription = $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_SHORT_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
|
||||
])->getString();
|
||||
// If description is between quotes, remove quotes
|
||||
$formShortDescription = Str::of($formShortDescription)->replaceMatches('/^"(.*)"$/', '$1')->toString();
|
||||
// Generate all template metadata using the consolidated prompt
|
||||
$metadata = GenerateTemplateMetadataPrompt::run($this->argument('prompt'));
|
||||
|
||||
// Get industry & types
|
||||
$completer->expectsJson();
|
||||
$industry = $this->getIndustries($completer, $this->argument('prompt'));
|
||||
$types = $this->getTypes($completer, $this->argument('prompt'));
|
||||
// Extract metadata components
|
||||
$formShortDescription = $metadata['short_description'];
|
||||
$formDescription = $metadata['detailed_description'];
|
||||
$formTitle = $metadata['title'];
|
||||
$industry = $metadata['industries'];
|
||||
$types = $metadata['types'];
|
||||
$formQAs = $metadata['qa_content'];
|
||||
|
||||
// Get Related Templates
|
||||
$relatedTemplates = $this->getRelatedTemplates($industry, $types);
|
||||
|
||||
// Now get description and QAs
|
||||
$completer->doesNotExpectJson();
|
||||
$formDescription = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
])->getHtml();
|
||||
|
||||
$completer->expectsJson();
|
||||
$formCoverKeywords = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_IMG_KEYWORDS_PROMPT],
|
||||
])->getArray();
|
||||
$imageUrl = $this->getImageCoverUrl($formCoverKeywords['search_query']);
|
||||
|
||||
$formQAs = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_QAS_PROMPT],
|
||||
])->getArray();
|
||||
$completer->doesNotExpectJson();
|
||||
$formTitle = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_TITLE_PROMPT],
|
||||
])->getString();
|
||||
// Get image cover URL
|
||||
$imageUrl = $this->getImageCoverUrl($metadata['image_search_query']);
|
||||
|
||||
$template = $this->createFormTemplate(
|
||||
$formData,
|
||||
|
|
@ -280,11 +65,12 @@ class GenerateTemplate extends Command
|
|||
$types,
|
||||
$relatedTemplates
|
||||
);
|
||||
$this->info('/form-templates/' . $template->slug);
|
||||
|
||||
// Set reverse related Templates
|
||||
$this->setReverseRelatedTemplates($template);
|
||||
|
||||
$this->info(front_url('/form-templates/' . $template->slug));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
|
@ -303,30 +89,6 @@ class GenerateTemplate extends Command
|
|||
return null;
|
||||
}
|
||||
|
||||
private function getIndustries(GptCompleter $completer, string $formPrompt): array
|
||||
{
|
||||
$industriesString = Template::getAllIndustries()->pluck('slug')->join(', ');
|
||||
|
||||
return $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_INDUSTRY_PROMPT)
|
||||
->replace('[REPLACE]', $formPrompt)
|
||||
->replace('[INDUSTRIES]', $industriesString)
|
||||
->toString()],
|
||||
])->getArray()['industries'];
|
||||
}
|
||||
|
||||
private function getTypes(GptCompleter $completer, string $formPrompt): array
|
||||
{
|
||||
$typesString = Template::getAllTypes()->pluck('slug')->join(', ');
|
||||
|
||||
return $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_TYPES_PROMPT)
|
||||
->replace('[REPLACE]', $formPrompt)
|
||||
->replace('[TYPES]', $typesString)
|
||||
->toString()],
|
||||
])->getArray()['types'];
|
||||
}
|
||||
|
||||
private function getRelatedTemplates(array $industries, array $types): array
|
||||
{
|
||||
$templateScore = [];
|
||||
|
|
@ -383,36 +145,4 @@ class GenerateTemplate extends Command
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function cleanAiOutput(array $formData): array
|
||||
{
|
||||
// Add property uuids, improve form with options
|
||||
foreach ($formData['properties'] as &$property) {
|
||||
$property['id'] = Str::uuid()->toString(); // Column ID
|
||||
|
||||
// Fix types
|
||||
if ($property['type'] == 'rating') {
|
||||
$property['rating_max_value'] = $property['rating_max_value'] ?? 5;
|
||||
} elseif ($property['type'] == 'scale') {
|
||||
$property['scale_min_value'] = $property['scale_min_value'] ?? 1;
|
||||
$property['scale_max_value'] = $property['scale_max_value'] ?? 5;
|
||||
$property['scale_step_value'] = $property['scale_step_value'] ?? 1;
|
||||
} elseif ($property['type'] == 'slider') {
|
||||
$property['slider_min_value'] = $property['slider_min_value'] ?? 0;
|
||||
$property['slider_max_value'] = $property['slider_max_value'] ?? 100;
|
||||
$property['slider_step_value'] = $property['slider_step_value'] ?? 1;
|
||||
}
|
||||
|
||||
if (($property['type'] == 'select' && count($property['select']['options']) <= 4)
|
||||
|| ($property['type'] == 'multi_select' && count($property['multi_select']['options']) <= 4)
|
||||
) {
|
||||
$property['without_dropdown'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean data
|
||||
$formData['title'] = Str::of($formData['title'])->replace('"', '')->toString();
|
||||
|
||||
return $formData;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class AiGenerateFormRequest extends FormRequest
|
|||
public function rules()
|
||||
{
|
||||
return [
|
||||
'form_prompt' => 'required|string|max:1000',
|
||||
'form_prompt' => 'required|string|max:4000',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
|||
'logo_picture' => 'url|nullable',
|
||||
'dark_mode' => ['required', Rule::in(Form::DARK_MODE_VALUES)],
|
||||
'color' => 'required|string',
|
||||
'hide_title' => 'required|boolean',
|
||||
'uppercase_labels' => 'required|boolean',
|
||||
'no_branding' => 'required|boolean',
|
||||
'transparent_background' => 'required|boolean',
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@
|
|||
|
||||
namespace App\Jobs\Form;
|
||||
|
||||
use App\Console\Commands\GenerateTemplate;
|
||||
use App\Models\Forms\AI\AiFormCompletion;
|
||||
use App\Service\OpenAi\GptCompleter;
|
||||
use App\Service\AI\Prompts\Form\GenerateFormPrompt;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateAiForm implements ShouldQueue
|
||||
{
|
||||
|
|
@ -26,7 +24,6 @@ class GenerateAiForm implements ShouldQueue
|
|||
*/
|
||||
public function __construct(public AiFormCompletion $completion)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -40,25 +37,17 @@ class GenerateAiForm implements ShouldQueue
|
|||
'status' => AiFormCompletion::STATUS_PROCESSING,
|
||||
]);
|
||||
|
||||
$completer = (new GptCompleter(config('services.openai.api_key')))
|
||||
->useStreaming()
|
||||
->setSystemMessage('You are a robot helping to generate forms.')
|
||||
->expectsJson();
|
||||
|
||||
try {
|
||||
$completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(GenerateTemplate::FORM_STRUCTURE_PROMPT)
|
||||
->replace('[REPLACE]', $this->completion->form_prompt)->toString()],
|
||||
], 3000);
|
||||
// Use the static run method to execute the prompt
|
||||
$formData = GenerateFormPrompt::run($this->completion->form_prompt);
|
||||
|
||||
$this->completion->update([
|
||||
'status' => AiFormCompletion::STATUS_COMPLETED,
|
||||
'result' => GenerateTemplate::cleanAiOutput($completer->getArray())
|
||||
'result' => $formData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->onError($e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,7 +62,7 @@ class GenerateAiForm implements ShouldQueue
|
|||
{
|
||||
$this->completion->update([
|
||||
'status' => AiFormCompletion::STATUS_FAILED,
|
||||
'result' => ['error' => $e->getMessage()],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class AiFormCompletion extends Model
|
|||
'status',
|
||||
'result',
|
||||
'ip',
|
||||
'error',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ class Form extends Model implements CachableAttributes
|
|||
'color',
|
||||
'uppercase_labels',
|
||||
'no_branding',
|
||||
'hide_title',
|
||||
'transparent_background',
|
||||
|
||||
// Custom Code
|
||||
|
|
|
|||
|
|
@ -0,0 +1,578 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\AI\Prompts\Form;
|
||||
|
||||
use App\Service\AI\Prompts\Prompt;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateFormPrompt extends Prompt
|
||||
{
|
||||
protected float $temperature = 0.81;
|
||||
|
||||
protected int $maxTokens = 3000;
|
||||
|
||||
/**
|
||||
* The prompt template for generating forms
|
||||
*/
|
||||
public const PROMPT_TEMPLATE = <<<'EOD'
|
||||
Help me build the json structure for the form described below, be as accurate as possible.
|
||||
|
||||
<form_description>
|
||||
{formPrompt}
|
||||
</form_description>
|
||||
|
||||
Forms are represented as Json objects. There are several input types and layout block types (type start with nf-).
|
||||
You can use for instance nf-text to add a title or text to the form using some basic html (h1, p, b, i, u etc).
|
||||
Order of blocks matters.
|
||||
|
||||
Available field types:
|
||||
- text: Text input (use multi_lines: true for multi-line text)
|
||||
- rich_text: Rich text input
|
||||
- date: Date picker (use with_time: true to include time selection)
|
||||
- url: URL input with validation
|
||||
- phone_number: Phone number input
|
||||
- email: Email input with validation
|
||||
- checkbox: Single checkbox for yes/no (use use_toggle_switch: true for toggle switch)
|
||||
- select: Dropdown selection (use without_dropdown: true for radio buttons, recommended for <5 options)
|
||||
- multi_select: Multiple selection (use without_dropdown: true for checkboxes, recommended for <5 options)
|
||||
- matrix: Matrix input with rows and columns
|
||||
- number: Numeric input
|
||||
- rating: Star rating
|
||||
- scale: Numeric scale
|
||||
- slider: Slider selection
|
||||
- files: File upload
|
||||
- signature: Signature pad
|
||||
- barcode: Barcode scanner
|
||||
- nf-text: Rich text content (not an input field)
|
||||
- nf-page-break: Page break for multi-page forms
|
||||
- nf-divider: Visual divider (not an input field)
|
||||
- nf-image: Image element
|
||||
- nf-code: Code block
|
||||
|
||||
HTML formatting for nf-text:
|
||||
- Headers: <h1>, <h2> for section titles and subtitles
|
||||
- Text formatting: <b> or <strong> for bold, <i> or <em> for italic, <u> for underline, <s> for strikethrough
|
||||
- Links: <a href="url">link text</a> for hyperlinks
|
||||
- Lists: <ul><li>item</li></ul> for bullet lists, <ol><li>item</li></ol> for numbered lists
|
||||
- Colors: <span style="color: #hexcode">colored text</span> for colored text
|
||||
- Paragraphs: <p>paragraph text</p> for text blocks with spacing
|
||||
Use these HTML tags to create well-structured and visually appealing form content.
|
||||
|
||||
Field width options:
|
||||
- width: "full" (default, takes entire width)
|
||||
- width: "1/2" (takes half width)
|
||||
- width: "1/3" (takes a third of the width)
|
||||
- width: "2/3" (takes two thirds of the width)
|
||||
- width: "1/4" (takes a quarter of the width)
|
||||
- width: "3/4" (takes three quarters of the width)
|
||||
Fields with width less than "full" will be placed on the same line if there's enough room. For example:
|
||||
- Two 1/2 width fields will be placed side by side
|
||||
- Three 1/3 width fields will be placed on the same line
|
||||
- etc.
|
||||
No need for lines width to be complete. Don't abuse putting multiple fields on the same line if it doens't make sense. For First name and Last name, it works well for instance.
|
||||
|
||||
If the form is too long, you can paginate it by adding one or multiple page breaks (nf-page-break).
|
||||
|
||||
Create a complete form with appropriate fields based on the description. Include:
|
||||
- A clear `title` (internal for form admin)
|
||||
- `nf-text` blocks to add a title or text to the form using some basic html (h1, p, b, i, u etc)
|
||||
- Logical field grouping
|
||||
- Required fields where necessary
|
||||
- Help text for complex fields
|
||||
- Appropriate validation
|
||||
- Customized submission text
|
||||
EOD;
|
||||
|
||||
/**
|
||||
* JSON schema for form output
|
||||
*/
|
||||
protected ?array $jsonSchema = [
|
||||
'type' => 'object',
|
||||
'required' => ['title', 'properties', 're_fillable', 'use_captcha', 'redirect_url', 'submitted_text', 'uppercase_labels', 'submit_button_text', 're_fill_button_text', 'color'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'title' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The title of the form (default: "New Form")'
|
||||
],
|
||||
're_fillable' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the form can be refilled after submission (default: false)'
|
||||
],
|
||||
'use_captcha' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to use CAPTCHA for spam protection (default: false)'
|
||||
],
|
||||
'redirect_url' => [
|
||||
'type' => ['string', 'null'],
|
||||
'description' => 'URL to redirect to after submission (default: null)'
|
||||
],
|
||||
'submitted_text' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Text to display after form submission (default: "<p>Thank you for your submission!</p>")'
|
||||
],
|
||||
'uppercase_labels' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to display field labels in uppercase (default: false)'
|
||||
],
|
||||
'submit_button_text' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Text for the submit button (default: "Submit")'
|
||||
],
|
||||
're_fill_button_text' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Text for the refill button (default: "Fill Again")'
|
||||
],
|
||||
'color' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Primary color for the form (default: "#64748b")'
|
||||
],
|
||||
'properties' => [
|
||||
'type' => 'array',
|
||||
'description' => 'Array of form fields and elements',
|
||||
'items' => [
|
||||
'anyOf' => [
|
||||
['$ref' => '#/definitions/textProperty'],
|
||||
['$ref' => '#/definitions/richTextProperty'],
|
||||
['$ref' => '#/definitions/dateProperty'],
|
||||
['$ref' => '#/definitions/urlProperty'],
|
||||
['$ref' => '#/definitions/phoneNumberProperty'],
|
||||
['$ref' => '#/definitions/emailProperty'],
|
||||
['$ref' => '#/definitions/checkboxProperty'],
|
||||
['$ref' => '#/definitions/selectProperty'],
|
||||
['$ref' => '#/definitions/multiSelectProperty'],
|
||||
['$ref' => '#/definitions/matrixProperty'],
|
||||
['$ref' => '#/definitions/numberProperty'],
|
||||
['$ref' => '#/definitions/ratingProperty'],
|
||||
['$ref' => '#/definitions/scaleProperty'],
|
||||
['$ref' => '#/definitions/sliderProperty'],
|
||||
['$ref' => '#/definitions/filesProperty'],
|
||||
['$ref' => '#/definitions/signatureProperty'],
|
||||
['$ref' => '#/definitions/barcodeProperty'],
|
||||
['$ref' => '#/definitions/nfTextProperty'],
|
||||
['$ref' => '#/definitions/nfPageBreakProperty'],
|
||||
['$ref' => '#/definitions/nfDividerProperty'],
|
||||
['$ref' => '#/definitions/nfImageProperty'],
|
||||
['$ref' => '#/definitions/nfCodeProperty']
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'definitions' => [
|
||||
'option' => [
|
||||
'type' => 'object',
|
||||
'required' => ['name', 'id'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'id' => ['type' => 'string']
|
||||
]
|
||||
],
|
||||
'selectOptions' => [
|
||||
'type' => 'object',
|
||||
'required' => ['options'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'options' => [
|
||||
'type' => 'array',
|
||||
'items' => ['$ref' => '#/definitions/option'],
|
||||
'description' => 'Options for select fields'
|
||||
]
|
||||
]
|
||||
],
|
||||
'baseProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['name', 'help', 'hidden', 'required', 'placeholder', 'width'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'name' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The name/label of the field'
|
||||
],
|
||||
'help' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Help text for the field (default: null)'
|
||||
],
|
||||
'hidden' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the field is hidden (default: false)'
|
||||
],
|
||||
'required' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the field is required (default: false)'
|
||||
],
|
||||
'placeholder' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Placeholder text for the field. Leave empty if not needed (default: "")'
|
||||
],
|
||||
'width' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['full', '1/2', '1/3', '2/3', '1/4', '3/4'],
|
||||
'description' => 'Width of the field in the form layout. "full" takes the entire width, "1/2" takes half width, "1/3" takes a third, etc. (default: "full")',
|
||||
]
|
||||
]
|
||||
],
|
||||
'textProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['type', 'core', 'multi_lines', 'generates_uuid', 'max_char_limit', 'hide_field_name', 'show_char_limit'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['text']],
|
||||
'multi_lines' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the text field should have multiple lines (default: false)',
|
||||
],
|
||||
'generates_uuid' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether the field should generate a UUID (default: false)'
|
||||
],
|
||||
'max_char_limit' => [
|
||||
'type' => ['integer', 'string'],
|
||||
'description' => 'Maximum character limit for text fields (default: 500)'
|
||||
],
|
||||
'hide_field_name' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to hide the field name (default: false)'
|
||||
],
|
||||
'show_char_limit' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to show the character limit (default: false)'
|
||||
],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'richTextProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['type', 'core', 'max_char_limit'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['rich_text']],
|
||||
'max_char_limit' => [
|
||||
'type' => ['integer', 'string'],
|
||||
'description' => 'Maximum character limit for rich text fields (default: 1000)'
|
||||
],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'dateProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'with_time'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['date']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'with_time' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to include time selection with the date (default: false)',
|
||||
]
|
||||
]
|
||||
],
|
||||
'urlProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'max_char_limit'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['url']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'max_char_limit' => [
|
||||
'type' => ['integer', 'string'],
|
||||
'description' => 'Maximum character limit for URL fields (default: 500)'
|
||||
]
|
||||
]
|
||||
],
|
||||
'phoneNumberProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['phone_number']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'emailProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'max_char_limit'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['email']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'max_char_limit' => [
|
||||
'type' => ['integer', 'string'],
|
||||
'description' => 'Maximum character limit for email fields (default: 320)'
|
||||
]
|
||||
]
|
||||
],
|
||||
'checkboxProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'use_toggle_switch'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['checkbox']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'use_toggle_switch' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to display the checkbox as a toggle switch (default: false)',
|
||||
]
|
||||
]
|
||||
],
|
||||
'selectProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'select', 'without_dropdown'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['select']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'select' => ['$ref' => '#/definitions/selectOptions'],
|
||||
'without_dropdown' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to display select options as radio buttons instead of a dropdown using FlatSelectInput. Recommended for small choices (<5 options) (default: false)',
|
||||
]
|
||||
]
|
||||
],
|
||||
'multiSelectProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'multi_select', 'without_dropdown'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['multi_select']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'multi_select' => ['$ref' => '#/definitions/selectOptions'],
|
||||
'without_dropdown' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether to display multi-select options as checkboxes instead of a dropdown using FlatSelectInput. Recommended for small choices (<5 options) (default: false)',
|
||||
]
|
||||
]
|
||||
],
|
||||
'matrixProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'rows', 'columns'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['matrix']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'rows' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
'description' => 'Rows for matrix fields (ex: ["Row 1"])'
|
||||
],
|
||||
'columns' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
'description' => 'Columns for matrix fields (ex: ["1", "2", "3"])'
|
||||
]
|
||||
]
|
||||
],
|
||||
'numberProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['number']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'ratingProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'rating_max_value'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['rating']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'rating_max_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Maximum rating for rating fields (default: 5)'
|
||||
]
|
||||
]
|
||||
],
|
||||
'scaleProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'scale_min_value', 'scale_max_value', 'scale_step_value'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['scale']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'scale_min_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Minimum value for scale fields (default: 1)'
|
||||
],
|
||||
'scale_max_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Maximum value for scale fields (default: 5)'
|
||||
],
|
||||
'scale_step_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Step value for scale fields (default: 1)'
|
||||
]
|
||||
]
|
||||
],
|
||||
'sliderProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'slider_min_value', 'slider_max_value', 'slider_step_value'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['slider']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'slider_min_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Minimum value for slider fields (default: 0)'
|
||||
],
|
||||
'slider_max_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Maximum value for slider fields (default: 50)'
|
||||
],
|
||||
'slider_step_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Step value for slider fields (default: 1)'
|
||||
]
|
||||
]
|
||||
],
|
||||
'filesProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['files']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'signatureProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['signature']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'barcodeProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'decoders'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['barcode']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'decoders' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
'description' => 'Decoders for barcode fields (default: ["ean_reader", "ean_8_reader"])'
|
||||
]
|
||||
]
|
||||
],
|
||||
'nfTextProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'content'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['nf-text']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'content' => [
|
||||
'type' => 'string',
|
||||
'description' => 'HTML content for text elements. Supports headers (<h1>, <h2>), formatting (<b>, <i>, <u>, <s>), links (<a>), lists (<ul>, <ol>), colors (<span style="color: #hexcode">), and paragraphs (<p>). Example: "<h1>Form Title</h1><p>Please fill out this form.</p>"'
|
||||
]
|
||||
]
|
||||
],
|
||||
'nfPageBreakProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type', 'next_btn_text', 'previous_btn_text'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['nf-page-break']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
'next_btn_text' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Text for the next button in page breaks (default: "Next")'
|
||||
],
|
||||
'previous_btn_text' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Text for the previous button in page breaks (default: "Previous")'
|
||||
]
|
||||
]
|
||||
],
|
||||
'nfDividerProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['nf-divider']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'nfImageProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['nf-image']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
],
|
||||
'nfCodeProperty' => [
|
||||
'type' => 'object',
|
||||
'required' => ['core', 'type'],
|
||||
'additionalProperties' => false,
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['nf-code']],
|
||||
'core' => ['$ref' => '#/definitions/baseProperty'],
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
public string $formPrompt
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function getSystemMessage(): ?string
|
||||
{
|
||||
return 'You are an AI assistant specialized in creating form structures. Design intuitive, user-friendly forms that capture all necessary information based on the provided description.';
|
||||
}
|
||||
|
||||
protected function getPromptTemplate(): string
|
||||
{
|
||||
return self::PROMPT_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the execute method to automatically process the output
|
||||
*/
|
||||
public function execute(): array
|
||||
{
|
||||
$formData = parent::execute();
|
||||
return $this->processOutput($formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the AI output to ensure it meets our requirements
|
||||
*/
|
||||
public function processOutput(array $formData): array
|
||||
{
|
||||
// Add unique identifiers to properties
|
||||
if (isset($formData['properties']) && is_array($formData['properties'])) {
|
||||
foreach ($formData['properties'] as $index => $property) {
|
||||
// Add a unique ID to each property
|
||||
$formData['properties'][$index]['id'] = Str::uuid()->toString();
|
||||
|
||||
// Flatten core properties if they exist
|
||||
if (isset($property['core']) && is_array($property['core'])) {
|
||||
foreach ($property['core'] as $coreKey => $coreValue) {
|
||||
$formData['properties'][$index][$coreKey] = $coreValue;
|
||||
}
|
||||
// Remove the core property after flattening
|
||||
unset($formData['properties'][$index]['core']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean title data
|
||||
if (isset($formData['title'])) {
|
||||
// Remove quotes if the title is enclosed in them
|
||||
$formData['title'] = preg_replace('/^["\'](.*)["\']$/', '$1', $formData['title']);
|
||||
}
|
||||
|
||||
ray($formData);
|
||||
|
||||
return $formData;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\AI\Prompts;
|
||||
|
||||
use App\Service\OpenAi\GptCompleter;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
abstract class Prompt
|
||||
{
|
||||
protected GptCompleter $completer;
|
||||
|
||||
protected ?array $jsonSchema = null;
|
||||
|
||||
protected float $temperature = 0.7;
|
||||
|
||||
protected int $maxTokens = 4096;
|
||||
|
||||
protected string $model = 'gpt-4o';
|
||||
|
||||
protected bool $useStreaming = false;
|
||||
|
||||
/**
|
||||
* Static method to create and execute a prompt in one step
|
||||
*
|
||||
* @param array $args Arguments to pass to the prompt constructor
|
||||
* @return mixed The result of executing the prompt
|
||||
*/
|
||||
public static function run(...$args): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass(static::class);
|
||||
$instance = $reflection->newInstanceArgs($args);
|
||||
return $instance->execute();
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->completer = new GptCompleter(null, 2, $this->model);
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
if ($this->jsonSchema) {
|
||||
$this->completer->setJsonSchema($this->jsonSchema);
|
||||
}
|
||||
|
||||
if ($this->getSystemMessage()) {
|
||||
$this->completer->setSystemMessage($this->getSystemMessage());
|
||||
}
|
||||
|
||||
if ($this->useStreaming) {
|
||||
$this->completer->useStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to set a custom system message
|
||||
*/
|
||||
protected function getSystemMessage(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must return the prompt template with placeholders
|
||||
*/
|
||||
abstract protected function getPromptTemplate(): string;
|
||||
|
||||
public function getCompleter(): GptCompleter
|
||||
{
|
||||
return $this->completer;
|
||||
}
|
||||
|
||||
public function execute(): mixed
|
||||
{
|
||||
$this->initialize();
|
||||
$prompt = $this->buildPrompt();
|
||||
|
||||
try {
|
||||
$this->completer->completeChat(
|
||||
[['role' => 'user', 'content' => $prompt]],
|
||||
$this->maxTokens,
|
||||
$this->temperature
|
||||
);
|
||||
|
||||
return $this->jsonSchema ? $this->completer->getArray() : $this->completer->getString();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error while executing prompt', [
|
||||
'exception' => $e,
|
||||
'prompt' => $prompt,
|
||||
'json_schema' => $this->jsonSchema ?? null
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPrompt(): string
|
||||
{
|
||||
$template = $this->getPromptTemplate();
|
||||
$variables = $this->getPromptVariables();
|
||||
|
||||
return strtr($template, $variables);
|
||||
}
|
||||
|
||||
protected function getPromptVariables(): array
|
||||
{
|
||||
$variables = [];
|
||||
$reflection = new ReflectionClass($this);
|
||||
|
||||
foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
|
||||
$name = $property->getName();
|
||||
$value = $property->getValue($this);
|
||||
|
||||
if (is_scalar($value)) {
|
||||
$variables['{' . $name . '}'] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $variables;
|
||||
}
|
||||
|
||||
public function setTemperature(float $temperature): self
|
||||
{
|
||||
$this->temperature = $temperature;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMaxTokens(int $maxTokens): self
|
||||
{
|
||||
$this->maxTokens = $maxTokens;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setGptCompleter(GptCompleter $completer): self
|
||||
{
|
||||
$this->completer = $completer;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function useStreaming(): self
|
||||
{
|
||||
$this->useStreaming = true;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\AI\Prompts\Template;
|
||||
|
||||
use App\Models\Template;
|
||||
use App\Service\AI\Prompts\Prompt;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateTemplateMetadataPrompt extends Prompt
|
||||
{
|
||||
protected float $temperature = 0.81;
|
||||
|
||||
protected int $maxTokens = 3000;
|
||||
|
||||
/**
|
||||
* Available industries and types loaded from the database
|
||||
*/
|
||||
protected array $availableIndustries = [];
|
||||
protected array $availableTypes = [];
|
||||
|
||||
/**
|
||||
* The prompt template for generating template metadata
|
||||
*/
|
||||
public const PROMPT_TEMPLATE = <<<'EOD'
|
||||
I need to generate metadata for a form template about: "{templatePrompt}"
|
||||
|
||||
Please generate the following information for this template:
|
||||
|
||||
1. SHORT DESCRIPTION:
|
||||
Create a single-sentence description that is concise, clearly explains what the form is about, highlights the main purpose or benefit, and is suitable for OpnForm (a free-to-use form builder). Do not use quotation marks.
|
||||
|
||||
2. DETAILED DESCRIPTION:
|
||||
Create detailed HTML content with the following structure:
|
||||
- A paragraph explaining what the template is about
|
||||
- A section with heading (h2) explaining why and when to use such a form
|
||||
- A section with heading (h2) explaining who the target audience is and why it's beneficial
|
||||
- A section with heading (h2) explaining that OpnForm is the best tool to build this form
|
||||
Use only h2, p, ul, and li HTML tags. Each section (except for the first) MUST start with an h2 tag.
|
||||
|
||||
3. TITLE:
|
||||
Create a title that contains or ends with the word "template", is short and to the point, doesn't include quotes, is optimized for SEO, and clearly describes the purpose of the form.
|
||||
|
||||
4. INDUSTRY CLASSIFICATION:
|
||||
Classify the template into appropriate industries (minimum 1, maximum 3 but only if very relevant) from this list: {availableIndustries}
|
||||
Order them by relevance (most relevant first).
|
||||
|
||||
5. TYPE CLASSIFICATION:
|
||||
Classify the template into appropriate types (minimum 1, maximum 3 but only if very accurate) from this list: {availableTypes}
|
||||
Order them by relevance (most relevant first).
|
||||
|
||||
6. IMAGE SEARCH QUERY:
|
||||
Provide a concise search query for Unsplash that would be visually representative of the form's purpose.
|
||||
|
||||
7. Q&A CONTENT:
|
||||
Create 4 to 6 question and answer pairs covering:
|
||||
- The purpose and benefits of this form template
|
||||
- When and why to use this form
|
||||
- Who the target audience is
|
||||
- Why OpnForm is the best option to create this form
|
||||
|
||||
Reply with a valid JSON object containing all these elements.
|
||||
EOD;
|
||||
|
||||
/**
|
||||
* JSON schema for template metadata output
|
||||
*/
|
||||
protected ?array $jsonSchema = null;
|
||||
|
||||
public function __construct(
|
||||
public string $templatePrompt
|
||||
) {
|
||||
// Load available industries and types from the database
|
||||
$this->loadAvailableOptions();
|
||||
|
||||
parent::__construct();
|
||||
$this->buildJsonSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available industries and types from the Template model
|
||||
*/
|
||||
protected function loadAvailableOptions(): void
|
||||
{
|
||||
$this->availableIndustries = Template::getAllIndustries()->pluck('slug')->toArray();
|
||||
$this->availableTypes = Template::getAllTypes()->pluck('slug')->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically build the JSON schema with enums for industries and types
|
||||
*/
|
||||
protected function buildJsonSchema(): void
|
||||
{
|
||||
$this->jsonSchema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'short_description' => [
|
||||
'type' => 'string',
|
||||
'description' => 'A concise single-sentence description of the form template'
|
||||
],
|
||||
'detailed_description' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Detailed HTML content describing the form template'
|
||||
],
|
||||
'title' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The title of the form template'
|
||||
],
|
||||
'industries' => [
|
||||
'type' => 'array',
|
||||
'description' => 'List of industry slugs for the template',
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'enum' => $this->availableIndustries
|
||||
]
|
||||
],
|
||||
'types' => [
|
||||
'type' => 'array',
|
||||
'description' => 'List of type slugs for the template',
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'enum' => $this->availableTypes
|
||||
]
|
||||
],
|
||||
'image_search_query' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Search query for Unsplash to find a relevant image'
|
||||
],
|
||||
'qa_content' => [
|
||||
'type' => 'array',
|
||||
'description' => 'Q&A content for the template',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'question' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The question about the form template'
|
||||
],
|
||||
'answer' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The answer to the question'
|
||||
]
|
||||
],
|
||||
'required' => ['question', 'answer'],
|
||||
'additionalProperties' => false
|
||||
]
|
||||
]
|
||||
],
|
||||
'required' => [
|
||||
'short_description',
|
||||
'detailed_description',
|
||||
'title',
|
||||
'industries',
|
||||
'types',
|
||||
'image_search_query',
|
||||
'qa_content'
|
||||
],
|
||||
'additionalProperties' => false
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSystemMessage(): ?string
|
||||
{
|
||||
return 'You are an assistant helping to generate comprehensive metadata for form templates. Create well-structured, informative content that explains the purpose, benefits, and target audience of the form template.';
|
||||
}
|
||||
|
||||
protected function getPromptTemplate(): string
|
||||
{
|
||||
return self::PROMPT_TEMPLATE;
|
||||
}
|
||||
|
||||
protected function buildPrompt(): string
|
||||
{
|
||||
$template = $this->getPromptTemplate();
|
||||
|
||||
$industriesString = implode(', ', $this->availableIndustries);
|
||||
$typesString = implode(', ', $this->availableTypes);
|
||||
|
||||
return Str::of($template)
|
||||
->replace('{templatePrompt}', $this->templatePrompt)
|
||||
->replace('{availableIndustries}', $industriesString)
|
||||
->replace('{availableTypes}', $typesString)
|
||||
->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the initialize method to ensure the options are loaded
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
if (empty($this->availableIndustries) || empty($this->availableTypes)) {
|
||||
throw new \InvalidArgumentException('Failed to load available industries and types from the database');
|
||||
}
|
||||
|
||||
parent::initialize();
|
||||
}
|
||||
}
|
||||
|
|
@ -26,13 +26,14 @@ class GptCompleter
|
|||
|
||||
protected bool $expectsJson = false;
|
||||
|
||||
protected int $tokenUsed = 0;
|
||||
protected int $inputTokens = 0;
|
||||
protected int $outputTokens = 0;
|
||||
|
||||
protected bool $useStreaming = false;
|
||||
|
||||
public function __construct(string $apiKey, protected int $retries = 2, protected string $model = self::AI_MODEL)
|
||||
public function __construct(?string $apiKey = null, protected int $retries = 2, protected string $model = self::AI_MODEL)
|
||||
{
|
||||
$this->openAi = \OpenAI::client($apiKey);
|
||||
$this->openAi = \OpenAI::client($apiKey ?? config('services.openai.api_key'));
|
||||
}
|
||||
|
||||
public function setAiModel(string $model)
|
||||
|
|
@ -70,6 +71,20 @@ class GptCompleter
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function setJsonSchema(array $schema): self
|
||||
{
|
||||
$this->completionInput['response_format'] = [
|
||||
'type' => 'json_schema',
|
||||
'json_schema' => [
|
||||
'name' => 'response_schema',
|
||||
'strict' => true,
|
||||
'schema' => $schema
|
||||
]
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function completeChat(array $messages, int $maxTokens = 4096, float $temperature = 0.81, ?bool $exceptJson = null): self
|
||||
{
|
||||
if (! is_null($exceptJson)) {
|
||||
|
|
@ -136,9 +151,14 @@ class GptCompleter
|
|||
return trim($this->result);
|
||||
}
|
||||
|
||||
public function getTokenUsed(): int
|
||||
public function getInputTokens(): int
|
||||
{
|
||||
return $this->tokenUsed;
|
||||
return $this->inputTokens;
|
||||
}
|
||||
|
||||
public function getOutputTokens(): int
|
||||
{
|
||||
return $this->outputTokens;
|
||||
}
|
||||
|
||||
protected function computeChatCompletion(array $messages, int $maxTokens = 4096, float $temperature = 0.81): self
|
||||
|
|
@ -157,10 +177,12 @@ class GptCompleter
|
|||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
if ($this->expectsJson) {
|
||||
if ($this->expectsJson && !isset($this->completionInput['response_format'])) {
|
||||
$completionInput['response_format'] = [
|
||||
'type' => 'json_object',
|
||||
];
|
||||
} elseif (isset($this->completionInput['response_format'])) {
|
||||
$completionInput['response_format'] = $this->completionInput['response_format'];
|
||||
}
|
||||
|
||||
$this->completionInput = $completionInput;
|
||||
|
|
@ -174,23 +196,30 @@ class GptCompleter
|
|||
return $this->queryStreamedCompletion();
|
||||
}
|
||||
|
||||
try {
|
||||
Log::debug('Open AI query: '.json_encode($this->completionInput));
|
||||
$response = $this->openAi->chat()->create($this->completionInput);
|
||||
} catch (ErrorException $errorException) {
|
||||
// Retry once
|
||||
Log::warning("Open AI error, retrying: {$errorException->getMessage()}");
|
||||
$response = $this->openAi->chat()->create($this->completionInput);
|
||||
}
|
||||
$this->tokenUsed += $response->usage->totalTokens;
|
||||
$this->result = $response->choices[0]->message->content;
|
||||
$attempt = 1;
|
||||
$lastError = null;
|
||||
|
||||
return $this;
|
||||
while ($attempt <= $this->retries) {
|
||||
try {
|
||||
Log::debug('Open AI query: ' . json_encode($this->completionInput));
|
||||
$response = $this->openAi->chat()->create($this->completionInput);
|
||||
$this->inputTokens = $response->usage->promptTokens;
|
||||
$this->outputTokens = $response->usage->completionTokens;
|
||||
$this->result = $response->choices[0]->message->content;
|
||||
return $this;
|
||||
} catch (ErrorException $errorException) {
|
||||
$lastError = $errorException;
|
||||
Log::warning("Open AI error, retrying: {$errorException->getMessage()}");
|
||||
$attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastError ?? new \Exception('Failed to complete OpenAI request after multiple attempts');
|
||||
}
|
||||
|
||||
protected function queryStreamedCompletion(): self
|
||||
{
|
||||
Log::debug('Open AI query: '.json_encode($this->completionInput));
|
||||
Log::debug('Open AI query: ' . json_encode($this->completionInput));
|
||||
$this->result = '';
|
||||
$response = $this->openAi->chat()->createStreamed($this->completionInput);
|
||||
foreach ($response as $chunk) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ class FormFactory extends Factory
|
|||
'width' => $this->faker->randomElement(Form::WIDTHS),
|
||||
'dark_mode' => $this->faker->randomElement(Form::DARK_MODE_VALUES),
|
||||
'color' => '#3B82F6',
|
||||
'hide_title' => false,
|
||||
'no_branding' => false,
|
||||
'uppercase_labels' => true,
|
||||
'transparent_background' => false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('forms', function (Blueprint $table) {
|
||||
$table->dropColumn('hide_title');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('forms', function (Blueprint $table) {
|
||||
$table->boolean('hide_title')->default(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ai_form_completions', function (Blueprint $table) {
|
||||
$table->text('error')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ai_form_completions', function (Blueprint $table) {
|
||||
$table->dropColumn('error');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -7,10 +7,10 @@
|
|||
<open-form
|
||||
:theme="theme"
|
||||
:loading="false"
|
||||
:show-hidden="true"
|
||||
:form="form"
|
||||
:fields="form.properties"
|
||||
:default-data-form="submission"
|
||||
:mode="FormMode.EDIT"
|
||||
@submit="updateForm"
|
||||
>
|
||||
<template #submit-btn="{ submitForm }">
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
import { ref, defineProps, defineEmits } from "vue"
|
||||
import OpenForm from "../forms/OpenForm.vue"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, required: true },
|
||||
|
|
|
|||
|
|
@ -6,24 +6,10 @@
|
|||
:style="{ '--font-family': form.font_family, 'direction': form?.layout_rtl ? 'rtl' : 'ltr' }"
|
||||
>
|
||||
<link
|
||||
v-if="adminPreview && form.font_family"
|
||||
v-if="formModeStrategy.display.showFontLink && form.font_family"
|
||||
rel="stylesheet"
|
||||
:href="getFontUrl"
|
||||
>
|
||||
|
||||
<template v-if="!isHideTitle">
|
||||
<EditableTag
|
||||
v-if="adminPreview"
|
||||
v-model="form.title"
|
||||
element="h1"
|
||||
class="mb-2"
|
||||
/>
|
||||
<h1
|
||||
v-else
|
||||
class="mb-2 px-2"
|
||||
v-text="form.title"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
|
||||
|
|
@ -95,7 +81,7 @@
|
|||
</div>
|
||||
|
||||
<form-cleanings
|
||||
v-if="!adminPreview"
|
||||
v-if="formModeStrategy.display.showFormCleanings"
|
||||
:hideable="true"
|
||||
class="mb-4 mx-2"
|
||||
:form="form"
|
||||
|
|
@ -114,7 +100,7 @@
|
|||
:fields="form.properties"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:admin-preview="adminPreview"
|
||||
:mode="mode"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<template #submit-btn="{submitForm: handleSubmit}">
|
||||
|
|
@ -208,14 +194,18 @@ import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"
|
|||
import clonedeep from "clone-deep"
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||
import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
export default {
|
||||
components: { VTransition, OpenFormButton, OpenForm, FormCleanings, FirstSubmissionModal },
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
creating: { type: Boolean, default: false }, // If true, fake form submit
|
||||
adminPreview: { type: Boolean, default: false }, // If used in FormEditorPreview
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
submitButtonClass: { type: String, default: '' },
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
|
|
@ -254,6 +244,12 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
isEmbedPopup () {
|
||||
return import.meta.client && window.location.href.includes('popup=true')
|
||||
},
|
||||
|
|
@ -266,9 +262,6 @@ export default {
|
|||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
isHideTitle () {
|
||||
return this.form.hide_title || (import.meta.client && window.location.href.includes('hide_title=true'))
|
||||
},
|
||||
getFontUrl() {
|
||||
if(!this.form || !this.form.font_family) return null
|
||||
const family = this.form?.font_family.replace(/ /g, '+')
|
||||
|
|
@ -292,7 +285,8 @@ export default {
|
|||
|
||||
methods: {
|
||||
submitForm (form, onFailure) {
|
||||
if (this.creating) {
|
||||
// Check if we should perform actual submission based on the mode
|
||||
if (!this.formModeStrategy.validation.performActualSubmission) {
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
ghost-class="ghost-item"
|
||||
filter=".not-draggable"
|
||||
:animation="200"
|
||||
:disabled="!adminPreview"
|
||||
:disabled="!formModeStrategy.admin.allowDragging"
|
||||
@change="handleDragDropped"
|
||||
>
|
||||
<template #item="{element}">
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
:data-form-value="dataFormValue"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:admin-preview="adminPreview"
|
||||
:mode="mode"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
|
|
@ -133,6 +133,7 @@ import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
|||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import FormTimer from './FormTimer.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
export default {
|
||||
name: 'OpenForm',
|
||||
|
|
@ -155,17 +156,16 @@ export default {
|
|||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
showHidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
defaultDataForm: { type: [Object, null] },
|
||||
adminPreview: {type: Boolean, default: false}, // If used in FormEditorPreview
|
||||
urlPrefillPreview: {type: Boolean, default: false}, // If used in UrlFormPrefill
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
|
@ -227,6 +227,24 @@ export default {
|
|||
groups.push(currentGroup)
|
||||
return groups
|
||||
},
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
/**
|
||||
* Determines if hidden fields should be shown based on the mode
|
||||
*/
|
||||
showHidden() {
|
||||
return this.formModeStrategy.display.showHiddenFields
|
||||
},
|
||||
/**
|
||||
* Determines if the form is in admin preview mode
|
||||
*/
|
||||
isAdminPreview() {
|
||||
return this.formModeStrategy.admin.showAdminControls
|
||||
},
|
||||
formProgress() {
|
||||
const requiredFields = this.fields.filter(field => field.required)
|
||||
if (requiredFields.length === 0) {
|
||||
|
|
@ -343,14 +361,14 @@ export default {
|
|||
// These watchers ensure the form shows the correct page for the field being edited in admin preview
|
||||
selectedFieldIndex: {
|
||||
handler(newIndex) {
|
||||
if (this.adminPreview && this.showEditFieldSidebar) {
|
||||
if (this.isAdminPreview && this.showEditFieldSidebar) {
|
||||
this.setPageForField(newIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
showEditFieldSidebar: {
|
||||
handler(newValue) {
|
||||
if (this.adminPreview && newValue) {
|
||||
if (this.isAdminPreview && newValue) {
|
||||
this.setPageForField(this.selectedFieldIndex)
|
||||
}
|
||||
}
|
||||
|
|
@ -386,6 +404,12 @@ export default {
|
|||
this.$refs['form-timer'].stopTimer()
|
||||
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
|
||||
|
||||
// Add validation strategy check
|
||||
if (!this.formModeStrategy.validation.validateOnSubmit) {
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
},
|
||||
/**
|
||||
|
|
@ -538,11 +562,12 @@ export default {
|
|||
this.scrollToTop()
|
||||
},
|
||||
nextPage() {
|
||||
if (this.adminPreview || this.urlPrefillPreview) {
|
||||
if (!this.formModeStrategy.validation.validateOnNextPage) {
|
||||
this.formPageIndex++
|
||||
this.scrollToTop()
|
||||
return false
|
||||
}
|
||||
|
||||
const fieldsToValidate = this.currentFields.map(f => f.id)
|
||||
this.dataForm.busy = true
|
||||
this.dataForm.validate('POST', '/forms/' + this.form.slug + '/answer', {}, fieldsToValidate)
|
||||
|
|
|
|||
|
|
@ -7,19 +7,19 @@
|
|||
:class="[
|
||||
getFieldWidthClasses(field),
|
||||
{
|
||||
'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:!bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md': adminPreview,
|
||||
'cursor-pointer':workingFormStore.showEditFieldSidebar && adminPreview,
|
||||
'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:!bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md': isAdminPreview,
|
||||
'cursor-pointer':workingFormStore.showEditFieldSidebar && isAdminPreview,
|
||||
'bg-blue-50 hover:!bg-blue-50 dark:bg-gray-800 rounded-md': beingEdited,
|
||||
}]"
|
||||
@click="setFieldAsSelected"
|
||||
>
|
||||
<div
|
||||
class="-m-[1px] w-full max-w-full mx-auto"
|
||||
:class="{'relative transition-colors':adminPreview}"
|
||||
:class="{'relative transition-colors':isAdminPreview}"
|
||||
>
|
||||
<div
|
||||
v-if="adminPreview"
|
||||
class="absolute translate-y-full lg:translate-y-0 -bottom-1 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block z-50"
|
||||
v-if="isAdminPreview"
|
||||
class="absolute translate-y-full lg:translate-y-0 -bottom-1 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block"
|
||||
>
|
||||
<div
|
||||
class="flex lg:flex-col bg-white !bg-white dark:!bg-white border rounded-md shadow-sm z-50 p-[1px] relative"
|
||||
|
|
@ -151,6 +151,7 @@ import {computed} from 'vue'
|
|||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import {default as _has} from 'lodash/has'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
export default {
|
||||
name: 'OpenFormField',
|
||||
|
|
@ -189,16 +190,21 @@ export default {
|
|||
type: Object,
|
||||
required: true
|
||||
},
|
||||
adminPreview: {type: Boolean, default: false} // If used in FormEditorPreview
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE
|
||||
}
|
||||
},
|
||||
|
||||
setup() {
|
||||
setup(props) {
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
return {
|
||||
workingFormStore,
|
||||
currentWorkspace: computed(() => useWorkspacesStore().getCurrent),
|
||||
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar)
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
|
||||
formModeStrategy: computed(() => createFormModeStrategy(props.mode)),
|
||||
isAdminPreview: computed(() => createFormModeStrategy(props.mode).admin.showAdminControls)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -261,7 +267,7 @@ export default {
|
|||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isDisabled()
|
||||
},
|
||||
beingEdited() {
|
||||
return this.adminPreview && this.showEditFieldSidebar && this.form.properties.findIndex((item) => {
|
||||
return this.isAdminPreview && this.showEditFieldSidebar && this.form.properties.findIndex((item) => {
|
||||
return item.id === this.field.id
|
||||
}) === this.selectedFieldIndex
|
||||
},
|
||||
|
|
@ -281,7 +287,7 @@ export default {
|
|||
return fieldsOptions
|
||||
},
|
||||
fieldSideBarOpened() {
|
||||
return this.adminPreview && (this.form && this.selectedFieldIndex !== null) ? (this.form.properties[this.selectedFieldIndex] && this.showEditFieldSidebar) : false
|
||||
return this.isAdminPreview && (this.form && this.selectedFieldIndex !== null) ? (this.form.properties[this.selectedFieldIndex] && this.showEditFieldSidebar) : false
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -292,19 +298,19 @@ export default {
|
|||
|
||||
methods: {
|
||||
editFieldOptions() {
|
||||
if (!this.adminPreview) return
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
setFieldAsSelected () {
|
||||
if (!this.adminPreview || !this.workingFormStore.showEditFieldSidebar) return
|
||||
if (!this.formModeStrategy.admin.showAdminControls || !this.workingFormStore.showEditFieldSidebar) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
openAddFieldSidebar() {
|
||||
if (!this.adminPreview) return
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openAddFieldSidebar(this.field)
|
||||
},
|
||||
removeField () {
|
||||
if (!this.adminPreview) return
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.removeField(this.field)
|
||||
},
|
||||
getFieldWidthClasses(field) {
|
||||
|
|
|
|||
|
|
@ -24,15 +24,6 @@
|
|||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<toggle-switch-input
|
||||
:model-value="modelValue.hide_title"
|
||||
name="hide_title"
|
||||
class="mt-4"
|
||||
label="Hide Form Title"
|
||||
:disabled="form.hide_title === true ? true : null"
|
||||
:help="hideTitleHelp"
|
||||
@update:model-value="onChangeHideTitle"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
:model-value="modelValue.auto_submit"
|
||||
name="auto_submit"
|
||||
|
|
@ -64,22 +55,7 @@ export default {
|
|||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hideTitleHelp() {
|
||||
return this.form.hide_title
|
||||
? "This option is disabled because the form title is already hidden"
|
||||
: null
|
||||
},
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted() {},
|
||||
|
||||
methods: {
|
||||
onChangeHideTitle(val) {
|
||||
this.modelValue.hide_title = val
|
||||
},
|
||||
onChangeAutoSubmit(val) {
|
||||
this.modelValue.auto_submit = val
|
||||
},
|
||||
|
|
|
|||
|
|
@ -148,11 +148,6 @@
|
|||
label="Color image"
|
||||
help="Not visible when form is embedded"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
name="hide_title"
|
||||
:form="form"
|
||||
label="Hide Title"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
name="no_branding"
|
||||
:form="form"
|
||||
|
|
@ -173,11 +168,6 @@
|
|||
icon="heroicons:cog-6-tooth-16-solid"
|
||||
title="Advanced Options"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
name="hide_title"
|
||||
:form="form"
|
||||
label="Hide Form Title"
|
||||
/>
|
||||
<toggle-switch-input
|
||||
name="show_progress_bar"
|
||||
:form="form"
|
||||
|
|
|
|||
|
|
@ -92,11 +92,9 @@
|
|||
ref="formPreview"
|
||||
class="w-full mx-auto py-5"
|
||||
:class="{'max-w-lg': form && (form.width === 'centered'),'px-7': !isExpanded, 'px-3': isExpanded}"
|
||||
:creating="creating"
|
||||
:form="form"
|
||||
:dark-mode="darkMode"
|
||||
:admin-preview="!isExpanded"
|
||||
:show-cleanings="false"
|
||||
:mode="formMode"
|
||||
@restarted="previewFormSubmitted=false"
|
||||
@submitted="previewFormSubmitted=true"
|
||||
/>
|
||||
|
|
@ -113,6 +111,7 @@ import { default as _has } from 'lodash/has'
|
|||
import { useRecordsStore } from '~/stores/records'
|
||||
import { useWorkingFormStore } from '~/stores/working_form'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
const recordsStore = useRecordsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
|
@ -126,7 +125,8 @@ const { content: form } = storeToRefs(workingFormStore)
|
|||
const recordLoading = computed(() => recordsStore.loading)
|
||||
const darkMode = useDarkMode(parent)
|
||||
|
||||
const creating = computed(() => !_has(form.value, 'id'))
|
||||
// Use PREVIEW mode when not expanded, TEST mode when expanded
|
||||
const formMode = computed(() => isExpanded.value ? FormMode.TEST : FormMode.PREVIEW)
|
||||
|
||||
defineShortcuts({
|
||||
escape: {
|
||||
|
|
|
|||
|
|
@ -108,14 +108,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<div class="border-t mt-4 -mx-4" />
|
||||
<toggle-switch-input
|
||||
v-model="advancedOptions.hide_title"
|
||||
name="hide_title"
|
||||
class="mt-4"
|
||||
label="Hide Form Title"
|
||||
:disabled="form.hide_title === true ? true : null"
|
||||
:help="hideTitleHelp"
|
||||
/>
|
||||
<color-input
|
||||
v-model="advancedOptions.bgcolor"
|
||||
name="bgcolor"
|
||||
|
|
@ -175,22 +167,14 @@ const props = defineProps({
|
|||
const embedScriptUrl = "/widgets/embed-min.js"
|
||||
const showEmbedFormAsPopupModal = ref(false)
|
||||
const advancedOptions = ref({
|
||||
hide_title: false,
|
||||
emoji: "💬",
|
||||
position: "right",
|
||||
bgcolor: "#3B82F6",
|
||||
width: "500",
|
||||
})
|
||||
|
||||
const hideTitleHelp = computed(() => {
|
||||
return props.form.hide_title
|
||||
? "This option is disabled because the form title is already hidden"
|
||||
: null
|
||||
})
|
||||
const shareUrl = computed(() => {
|
||||
return advancedOptions.value.hide_title
|
||||
? props.form.share_url + "?hide_title=true"
|
||||
: props.form.share_url
|
||||
return props.form.share_url
|
||||
})
|
||||
const embedPopupCode = computed(() => {
|
||||
const nfData = {
|
||||
|
|
|
|||
|
|
@ -75,10 +75,9 @@
|
|||
v-if="form"
|
||||
:theme="theme"
|
||||
:loading="false"
|
||||
:show-hidden="true"
|
||||
:form="form"
|
||||
:fields="form.properties"
|
||||
:url-prefill-preview="true"
|
||||
:mode="FormMode.PREFILL"
|
||||
@submit="generateUrl"
|
||||
>
|
||||
<template #submit-btn="{ submitForm }">
|
||||
|
|
@ -111,6 +110,7 @@
|
|||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder"
|
||||
import FormUrlPrefill from "../../../open/forms/components/FormUrlPrefill.vue"
|
||||
import OpenForm from "../../../open/forms/OpenForm.vue"
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
export default {
|
||||
name: "UrlFormPrefill",
|
||||
|
|
@ -132,6 +132,9 @@ export default {
|
|||
borderRadius: this.form.border_radius
|
||||
}).getAllComponents()
|
||||
},
|
||||
FormMode() {
|
||||
return FormMode
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export const initForm = (defaultValue = {}, withDefaultProperties = false) => {
|
|||
layout_rtl: false,
|
||||
dark_mode: "auto",
|
||||
color: DEFAULT_COLOR,
|
||||
hide_title: false,
|
||||
no_branding: false,
|
||||
uppercase_labels: false,
|
||||
transparent_background: false,
|
||||
|
|
@ -55,6 +54,12 @@ export const initForm = (defaultValue = {}, withDefaultProperties = false) => {
|
|||
|
||||
function getDefaultProperties() {
|
||||
return [
|
||||
{
|
||||
type: "nf-text",
|
||||
content: "<h1>My Form</h1>",
|
||||
name: "Title",
|
||||
id: generateUUID(),
|
||||
},
|
||||
{
|
||||
name: "Name",
|
||||
type: "text",
|
||||
|
|
@ -96,7 +101,6 @@ export function setFormDefaults(formData) {
|
|||
border_radius: 'small',
|
||||
dark_mode: 'light',
|
||||
color: '#3B82F6',
|
||||
hide_title: false,
|
||||
uppercase_labels: false,
|
||||
no_branding: false,
|
||||
transparent_background: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Form modes for OpenForm components
|
||||
*/
|
||||
export const FormMode = {
|
||||
LIVE: 'live', // Real form with full validation and submission
|
||||
PREVIEW: 'preview', // Admin preview with no validation
|
||||
PREFILL: 'prefill', // URL prefill preview with no validation
|
||||
EDIT: 'edit', // Editing an existing submission
|
||||
TEST: 'test' // Test mode with validation but no actual submission
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a comprehensive strategy based on the form mode
|
||||
* This handles all mode-specific behaviors, not just validation
|
||||
*
|
||||
* @param {string} mode - One of the FormMode values
|
||||
* @returns {Object} - Strategy object with all mode-specific behaviors
|
||||
*/
|
||||
export function createFormModeStrategy(mode) {
|
||||
// Default configuration (LIVE mode)
|
||||
const defaultStrategy = {
|
||||
// Validation behaviors
|
||||
validation: {
|
||||
validateOnNextPage: true,
|
||||
validateOnSubmit: true,
|
||||
performActualSubmission: true
|
||||
},
|
||||
|
||||
// Display behaviors
|
||||
display: {
|
||||
showHiddenFields: false,
|
||||
showFormCleanings: true,
|
||||
showFontLink: false
|
||||
},
|
||||
|
||||
// Admin behaviors
|
||||
admin: {
|
||||
allowDragging: false,
|
||||
showAdminControls: false,
|
||||
isEditingMode: false
|
||||
}
|
||||
}
|
||||
|
||||
// Return default strategy for LIVE mode or unknown modes
|
||||
if (mode === FormMode.LIVE || !Object.values(FormMode).includes(mode)) {
|
||||
return defaultStrategy
|
||||
}
|
||||
|
||||
// Create a copy of the default strategy to modify
|
||||
const strategy = JSON.parse(JSON.stringify(defaultStrategy))
|
||||
|
||||
// Apply mode-specific overrides
|
||||
switch (mode) {
|
||||
case FormMode.PREVIEW:
|
||||
// Admin preview - no validation, show admin controls but NOT hidden fields
|
||||
strategy.validation.validateOnNextPage = false
|
||||
strategy.validation.validateOnSubmit = false
|
||||
strategy.validation.performActualSubmission = false
|
||||
|
||||
strategy.display.showHiddenFields = false
|
||||
strategy.display.showFormCleanings = false
|
||||
strategy.display.showFontLink = true
|
||||
|
||||
strategy.admin.allowDragging = true
|
||||
strategy.admin.showAdminControls = true
|
||||
break
|
||||
|
||||
case FormMode.PREFILL:
|
||||
// URL prefill - no validation, show hidden fields
|
||||
strategy.validation.validateOnNextPage = false
|
||||
strategy.validation.validateOnSubmit = false
|
||||
strategy.validation.performActualSubmission = false
|
||||
|
||||
strategy.display.showHiddenFields = true
|
||||
break
|
||||
|
||||
case FormMode.EDIT:
|
||||
// Editing submission - same validation as LIVE mode, but show hidden fields
|
||||
// This ensures edit mode behaves like live mode for validation
|
||||
strategy.display.showHiddenFields = true
|
||||
strategy.admin.isEditingMode = true
|
||||
break
|
||||
|
||||
case FormMode.TEST:
|
||||
// Test mode - validate on submit but don't submit, and don't validate on next page
|
||||
strategy.validation.performActualSubmission = false
|
||||
strategy.validation.validateOnNextPage = false
|
||||
break
|
||||
}
|
||||
|
||||
return strategy
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
:form="form"
|
||||
class="mb-10"
|
||||
:dark-mode="darkMode"
|
||||
:mode="FormMode.LIVE"
|
||||
@password-entered="passwordEntered"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -72,6 +73,7 @@ import {
|
|||
focusOnFirstFormElement,
|
||||
useDarkMode
|
||||
} from '~/lib/forms/public-page'
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
const crisp = useCrisp()
|
||||
const formsStore = useFormsStore()
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ useOpnSeoMeta({
|
|||
})
|
||||
|
||||
const shareFormConfig = ref({
|
||||
hide_title: false,
|
||||
auto_submit: false,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -57,17 +57,15 @@
|
|||
<section class="pt-12 bg-gray-50 sm:pt-16 border-b pb-[250px] relative">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center max-w-4xl gap-8 mx-auto md:gap-12 md:flex-row"
|
||||
class="flex flex-col items-center justify-center max-w-5xl gap-8 mx-auto md:gap-12 md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="aspect-[4/3] shrink-0 rounded-lg shadow-sm overflow-hidden group max-w-sm"
|
||||
class="aspect-[4/3] shrink-0 rounded-lg shadow-sm overflow-hidden group w-full max-w-sm relative"
|
||||
>
|
||||
<img
|
||||
class="object-cover w-full transition-all duration-200 group-hover:scale-110 h-[240px]"
|
||||
class="object-cover w-full h-full transition-all duration-200 group-hover:scale-110 absolute inset-0"
|
||||
:src="template.image_url"
|
||||
alt="Template cover image"
|
||||
width="500px"
|
||||
height="380px"
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -90,21 +88,19 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative px-4 mx-auto sm:px-6 lg:px-8 -mt-[210px]">
|
||||
<div class="max-w-7xl">
|
||||
<div
|
||||
class="max-w-2xl p-4 mx-auto bg-white shadow-lg sm:p-6 lg:p-8 rounded-xl ring-1 ring-inset ring-gray-200 isolate"
|
||||
>
|
||||
<p class="text-sm font-medium text-center text-gray-500 -mt-2 mb-2">
|
||||
Template Preview
|
||||
</p>
|
||||
<open-complete-form
|
||||
ref="open-complete-form"
|
||||
:form="form"
|
||||
:creating="true"
|
||||
class="mb-4 p-4 bg-gray-50 border border-gray-200 border-dashed rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<section class="w-full max-w-4xl relative px-4 mx-auto sm:px-6 lg:px-8 -mt-[210px]">
|
||||
<div
|
||||
class="p-4 mx-auto bg-white shadow-lg sm:p-6 lg:p-8 rounded-xl ring-1 ring-inset ring-gray-200 isolate"
|
||||
>
|
||||
<p class="text-sm font-medium text-center text-gray-500 -mt-2 mb-2">
|
||||
Template Preview
|
||||
</p>
|
||||
<open-complete-form
|
||||
ref="open-complete-form"
|
||||
:form="form"
|
||||
:mode="FormMode.TEST"
|
||||
class="mb-4 p-4 bg-gray-50 border border-gray-200 border-dashed rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 translate-y-full inset-x-0">
|
||||
|
|
@ -132,7 +128,7 @@
|
|||
<section class="pt-20 pb-12 bg-white sm:pb-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div
|
||||
class="max-w-2xl mx-auto mt-16 space-y-12 sm:mt-16 sm:space-y-16"
|
||||
class="max-w-4xl mx-auto mt-16 space-y-12 sm:mt-16 sm:space-y-16"
|
||||
>
|
||||
<div
|
||||
class="nf-text"
|
||||
|
|
@ -216,7 +212,7 @@
|
|||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 mt-12 md:grid-cols-2 gap-x-8 gap-y-12">
|
||||
<div class="grid grid-cols-1 mt-12 md:grid-cols-2 gap-x-8 gap-y-12 max-w-5xl mx-auto">
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 text-center lg:items-start sm:text-left sm:items-start xl:flex-row"
|
||||
>
|
||||
|
|
@ -276,6 +272,7 @@ import Breadcrumb from "~/components/global/Breadcrumb.vue"
|
|||
import SingleTemplate from "../../components/pages/templates/SingleTemplate.vue"
|
||||
import { fetchTemplate } from "~/stores/templates.js"
|
||||
import FormTemplateModal from "~/components/open/forms/components/templates/FormTemplateModal.vue"
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600,
|
||||
|
|
|
|||
Loading…
Reference in New Issue