diff --git a/projects/packages/forms/HOOKS-IMPLEMENTATION.md b/projects/packages/forms/HOOKS-IMPLEMENTATION.md new file mode 100644 index 0000000000000..07188a5f223a2 --- /dev/null +++ b/projects/packages/forms/HOOKS-IMPLEMENTATION.md @@ -0,0 +1,336 @@ +# Jetpack Forms Hooks Implementation + +## Overview + +Two custom React hooks have been created to manage the integration between the Jetpack Forms block editor and the `jetpack_form` custom post type: + +1. **`useFormRef`** - Creates a CPT when a new form block is inserted +2. **`useFormSync`** - Syncs block content and settings to the CPT + +--- + +## 1. useFormRef Hook + +**File**: `src/blocks/contact-form/hooks/use-form-ref.ts` + +### Purpose +Automatically creates a `jetpack_form` custom post type when a new form block is inserted into the editor. + +### How It Works + +```typescript +const { isCreating, error } = useFormRef({ + formRef, // Current formRef attribute value + setAttributes, // Function to update block attributes + attributes // All block attributes +}); +``` + +### Workflow + +1. **Detection**: Checks if `formRef === 0` (indicates new form) +2. **One-time execution**: Uses `useRef` to prevent duplicate creation attempts +3. **Data preparation**: + - Extracts form settings (subject, recipients, confirmation type, etc.) + - Extracts integrations (CRM, Mailpoet, Salesforce, etc.) + - Generates form title from post title or defaults to "New Form" +4. **API call**: `POST /jetpack-forms/v1/forms/create-from-block` +5. **Update attribute**: Sets `formRef` to the new CPT post ID +6. **Error handling**: Returns error state if creation fails + +### Key Features + +- **Idempotent**: Only creates once, even if component re-renders +- **Non-blocking**: Async operation doesn't freeze the UI +- **Error recovery**: Allows retry if creation fails +- **Integration-aware**: Captures all form settings and integrations on creation + +### Return Values + +- `isCreating` (boolean): True while CPT is being created +- `error` (string|null): Error message if creation failed + +--- + +## 2. useFormSync Hook + +**File**: `src/blocks/contact-form/hooks/use-form-sync.ts` + +### Purpose +Keeps the `jetpack_form` CPT in sync with changes made in the block editor. + +### How It Works + +```typescript +useFormSync({ + formRef, // CPT post ID + clientId, // Block client ID + attributes // All block attributes +}); +``` + +### Workflow + +#### A. Block Content Sync + +1. **Watch inner blocks**: Uses `useSelect` to monitor changes to child blocks +2. **Serialize blocks**: Converts blocks to HTML using `serialize()` +3. **Debounce**: Waits 2 seconds after last change before syncing +4. **Compare**: Only syncs if content has actually changed +5. **API call**: `PUT /jetpack-forms/v1/forms/{id}/blocks` + +#### B. Settings Sync + +1. **Watch attributes**: Monitors all form settings and integrations +2. **Extract data**: Separates settings from integrations +3. **Serialize**: Converts to JSON for comparison +4. **Debounce**: 2-second delay to avoid excessive API calls +5. **Compare**: Only syncs if settings have changed +6. **API call**: `PUT /jetpack-forms/v1/forms/{id}/sync` + +### Key Features + +- **Debounced**: Prevents API spam during rapid edits +- **Smart diffing**: Only syncs when actual changes detected +- **Dual-channel**: Syncs blocks and settings separately +- **Non-blocking**: Background sync doesn't interrupt editing +- **Error handling**: Logs errors without disrupting user experience + +### Synced Data + +**Settings**: +- `subject`, `to` +- `customThankyouHeading`, `customThankyouMessage`, `customThankyouRedirect` +- `confirmationType` +- `saveResponses`, `emailNotifications`, `formNotifications` +- `disableGoBack`, `disableSummary` +- `notificationRecipients` + +**Integrations**: +- `jetpackCRM` +- `salesforceData` +- `mailpoet` +- `hostingerReach` + +--- + +## Integration in Edit Component + +**File**: `src/blocks/contact-form/edit.tsx` + +### Changes Made + +1. **Imports added**: +```typescript +import useFormRef from './hooks/use-form-ref'; +import useFormSync from './hooks/use-form-sync'; +``` + +2. **Type definition updated**: +```typescript +type JetpackContactFormAttributes = { + formRef: number; // Added + // ... existing attributes + jetpackCRM?: boolean; + salesforceData?: Record; + mailpoet?: Record; + hostingerReach?: Record; + saveResponses?: boolean; + formNotifications?: boolean; +}; +``` + +3. **Hooks called in component**: +```typescript +function JetpackContactFormEdit({ attributes, setAttributes, clientId }) { + const { formRef } = attributes; + + // Create CPT if needed + const { isCreating, error } = useFormRef({ + formRef, + setAttributes, + attributes, + }); + + // Sync to CPT + useFormSync({ + formRef, + clientId, + attributes, + }); + + // ... rest of component +} +``` + +--- + +## Data Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ USER INSERTS FORM BLOCK │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ useFormRef Hook │ +│ ├─ Detects formRef === 0 │ +│ ├─ Extracts settings & integrations │ +│ ├─ POST /jetpack-forms/v1/forms/create-from-block │ +│ └─ Updates formRef with new CPT ID │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ USER EDITS FORM (adds fields, changes settings) │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ useFormSync Hook │ +│ ├─ Watches inner blocks changes │ +│ │ ├─ Serializes blocks to HTML │ +│ │ ├─ Debounces for 2 seconds │ +│ │ ├─ PUT /jetpack-forms/v1/forms/{id}/blocks │ +│ │ └─ Updates CPT post_content │ +│ │ │ +│ └─ Watches attribute changes │ +│ ├─ Extracts settings & integrations │ +│ ├─ Debounces for 2 seconds │ +│ ├─ PUT /jetpack-forms/v1/forms/{id}/sync │ +│ └─ Updates CPT post_meta │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ JETPACK_FORM CPT (Database) │ +│ ├─ post_content: Serialized blocks HTML │ +│ ├─ _jetpack_form_settings: Settings JSON │ +│ └─ _jetpack_form_integrations: Integrations JSON │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Performance Optimizations + +### 1. Debouncing +- Both hooks use 2-second debounce +- Prevents API spam during rapid editing +- Reduces server load significantly + +### 2. Smart Diffing +- `useFormSync` compares serialized content before syncing +- Only makes API calls when actual changes detected +- Uses `useRef` to store previous state + +### 3. Idempotency +- `useFormRef` only creates CPT once +- Multiple component renders don't trigger duplicate creations +- Uses `hasAttemptedCreation` ref for tracking + +### 4. Non-blocking +- All API calls are async +- UI remains responsive during sync +- Errors are logged, not thrown + +--- + +## Error Handling + +### useFormRef Errors +- Network failures +- Permission issues +- Server errors + +**Behavior**: +- Logs error to console +- Returns error in hook return value +- Resets attempt flag to allow retry +- User can re-trigger by re-inserting block + +### useFormSync Errors +- Network failures during sync +- Invalid data + +**Behavior**: +- Logs error to console +- Doesn't interrupt user experience +- Next change will retry sync +- Silent failure (doesn't show user-facing errors) + +--- + +## Testing Checklist + +### useFormRef +- [ ] CPT created when new form block inserted +- [ ] formRef attribute updated with new post ID +- [ ] Settings extracted correctly +- [ ] Integrations extracted correctly +- [ ] Form title generated properly +- [ ] Doesn't create duplicate CPTs on re-render +- [ ] Error handling works +- [ ] Retry after error works + +### useFormSync +- [ ] Inner blocks synced to CPT +- [ ] Settings synced to CPT +- [ ] Integrations synced to CPT +- [ ] Debouncing works (no spam during rapid edits) +- [ ] Only syncs when changes detected +- [ ] Doesn't sync before CPT created (formRef === 0) +- [ ] Handles multi-step forms correctly +- [ ] Error logging works + +### Integration +- [ ] Hooks don't interfere with existing functionality +- [ ] Multi-step form conversion still works +- [ ] All form variations work +- [ ] Settings panel still functional +- [ ] No performance degradation +- [ ] No console errors + +--- + +## Known Limitations + +1. **Initial block content**: Empty on creation, filled on first edit +2. **Race conditions**: If user saves post immediately after inserting form, formRef might still be 0 +3. **No offline support**: Requires active network connection +4. **No conflict resolution**: Last write wins if multiple editors + +--- + +## Future Enhancements + +1. **Loading indicators**: Show visual feedback during creation/sync +2. **Manual sync button**: Allow user to force sync +3. **Sync status indicator**: Show "Saved" / "Saving" / "Error" state +4. **Conflict resolution**: Detect and handle concurrent edits +5. **Offline queue**: Queue sync operations when offline +6. **Optimistic updates**: Update UI before API confirms +7. **Rollback**: Undo failed syncs + +--- + +## Files Modified/Created + +### New Files +- `src/blocks/contact-form/hooks/use-form-ref.ts` ✅ +- `src/blocks/contact-form/hooks/use-form-sync.ts` ✅ + +### Modified Files +- `src/blocks/contact-form/edit.tsx` ✅ +- `src/blocks/contact-form/attributes.ts` ✅ + +--- + +## Next Steps + +See `IMPLEMENTATION-STATUS.md` for remaining implementation tasks: +- Update block save function +- Update PHP block renderer +- Add migration system +- Update dashboard integration diff --git a/projects/packages/forms/IMPLEMENTATION-STATUS.md b/projects/packages/forms/IMPLEMENTATION-STATUS.md new file mode 100644 index 0000000000000..e2813f76f2c39 --- /dev/null +++ b/projects/packages/forms/IMPLEMENTATION-STATUS.md @@ -0,0 +1,306 @@ +# Jetpack Forms CPT Implementation Status + +## Completed ✅ + +### 1. Custom Post Type Infrastructure +- **File**: `src/contact-form/class-jetpack-form.php` +- Created `jetpack_form` custom post type +- Registered meta fields for REST API: + - `_jetpack_form_settings` - Form configuration (subject, recipients, notifications) + - `_jetpack_form_integrations` - Third-party integrations + - `_jetpack_form_version` - Schema version + - `_jetpack_form_response_count` - Cached response count +- Implemented helper methods: + - `get_form()` - Retrieve form by ID + - `get_form_settings()` / `update_form_settings()` + - `get_form_integrations()` / `update_form_integrations()` + - `get_response_count()` / `increment_response_count()` + - `get_forms()` - Query multiple forms + - `delete_form()` - Delete with optional response cleanup + - `duplicate_form()` - Clone forms + +### 2. REST API Endpoints +- **File**: `src/contact-form/class-jetpack-form-endpoint.php` +- Created REST API routes under `jetpack-forms/v1`: + - `POST /forms/create-from-block` - Create form from block editor + - `PUT /forms/{id}/blocks` - Update form block content + - `PUT /forms/{id}/sync` - Sync settings and integrations +- Permission callbacks for proper capability checking + +### 3. Block Attributes +- **File**: `src/blocks/contact-form/attributes.ts` +- Added `formRef` attribute to store jetpack_form post ID + +### 4. Plugin Integration +- **File**: `src/contact-form/class-contact-form-plugin.php` +- Initialized `Jetpack_Form::init()` and `Jetpack_Form_Endpoint::init()` + +--- + +## Remaining Work 🚧 + +### Phase 1: JavaScript Integration (Required for Basic Functionality) + +#### A. Create Form on Block Insert +**New File**: `src/blocks/contact-form/hooks/use-form-ref.ts` + +```typescript +/** + * Hook to create and manage jetpack_form CPT reference + * + * Responsibilities: + * - Detect when formRef is 0 (new block) + * - Call REST API to create jetpack_form post + * - Update formRef attribute with new post ID + * - Handle errors gracefully + */ +``` + +**Implementation Steps**: +1. Create `useFormRef` hook +2. Call `POST /wp-json/jetpack-forms/v1/forms/create-from-block` on mount if `formRef === 0` +3. Extract form title from post/page title or block attributes +4. Update `formRef` attribute with response `form_id` + +#### B. Sync Inner Blocks to CPT +**New File**: `src/blocks/contact-form/hooks/use-form-sync.ts` + +```typescript +/** + * Hook to sync block inner blocks to jetpack_form post content + * + * Responsibilities: + * - Watch for changes to inner blocks + * - Debounce sync calls (e.g., 2 seconds) + * - Serialize inner blocks to HTML + * - Call REST API to update form content + * - Sync form settings (subject, to, etc.) + * - Sync integrations (CRM, Mailpoet, etc.) + */ +``` + +**Implementation Steps**: +1. Use `useSelect` to watch inner blocks changes +2. Use `serialize()` from `@wordpress/blocks` to get HTML +3. Debounce with `useDebouncedCallback` or similar +4. Call `PUT /wp-json/jetpack-forms/v1/forms/{id}/blocks` +5. Call `PUT /wp-json/jetpack-forms/v1/forms/{id}/sync` for settings + +#### C. Update Edit Component +**File**: `src/blocks/contact-form/edit.tsx` + +**Changes Needed**: +1. Import and use `useFormRef` hook +2. Import and use `useFormSync` hook +3. Add `formRef` to `JetpackContactFormAttributes` type +4. Pass `formRef` through to hooks + +Example: +```typescript +function JetpackContactFormEdit( { attributes, setAttributes, clientId } ) { + const { formRef } = attributes; + + // Create form ref if needed + useFormRef( formRef, setAttributes, attributes ); + + // Sync blocks and settings to CPT + useFormSync( formRef, clientId, attributes ); + + // ... rest of component +} +``` + +--- + +### Phase 2: Block Save/Render Update + +#### D. Modify Block Save Function +**File**: `src/blocks/contact-form/index.js` + +**Current**: +```javascript +save: () => { + const blockProps = useBlockProps.save(); + return ( +
+ +
+ ); +}, +``` + +**New Approach** (store only reference): +```javascript +save: ( { attributes } ) => { + const blockProps = useBlockProps.save(); + const { formRef } = attributes; + + return ( +
+ {/* Inner blocks are stored in CPT, not here */} +
+ ); +}, +``` + +#### E. Update PHP Block Renderer +**File**: `src/blocks/contact-form/class-contact-form-block.php` + +**Changes Needed**: +1. Extract `formRef` from block attributes in render callback +2. If `formRef` exists, fetch form from `Jetpack_Form::get_form( $formRef )` +3. Parse and render blocks from CPT `post_content` +4. Load settings from CPT meta instead of block attributes +5. Maintain backward compatibility for forms without `formRef` + +--- + +### Phase 3: Migration & Backward Compatibility + +#### F. Detect Legacy Forms +**New File**: `src/blocks/contact-form/hooks/use-legacy-migration.ts` + +**Responsibilities**: +- Detect forms where `formRef === 0` AND has inner blocks (legacy form) +- Prompt user to migrate or auto-migrate +- Create CPT from existing inner blocks +- Update `formRef` + +#### G. Render Fallback +**In Block Renderer**: +- If `formRef === 0` and no inner blocks → show error/placeholder +- If `formRef === 0` but has inner blocks → render as before (legacy mode) +- If `formRef > 0` → render from CPT + +--- + +### Phase 4: Dashboard Integration + +#### H. Link Responses to Forms +**File**: `src/contact-form/class-feedback.php` + +**Changes**: +1. When saving feedback, add `_jetpack_form_id` meta +2. Extract `formRef` from submitted form data +3. Link response to form CPT instead of just post_parent + +#### I. Update Dashboard +**File**: `src/dashboard/` + +**Changes**: +1. Add "Forms" tab to show all jetpack_form posts +2. Show response count per form +3. Link to edit form in block editor +4. Show which pages/posts use each form + +--- + +## Testing Checklist + +- [ ] Create new form block → CPT created automatically +- [ ] Edit form fields → synced to CPT +- [ ] Save post → formRef persisted in block +- [ ] View frontend → form rendered from CPT +- [ ] Submit form → response linked to form CPT +- [ ] Duplicate form → new CPT created +- [ ] Delete form → CPT deleted (with option for responses) +- [ ] Legacy forms still work (backward compatibility) +- [ ] Multi-step forms work with CPT +- [ ] All integrations (CRM, Mailpoet, etc.) work +- [ ] Form settings sync properly +- [ ] REST API permissions work correctly + +--- + +## File Structure + +``` +projects/packages/forms/src/ +├── contact-form/ +│ ├── class-jetpack-form.php ✅ +│ ├── class-jetpack-form-endpoint.php ✅ +│ ├── class-contact-form-plugin.php ✅ (updated) +│ └── class-contact-form-block.php 🚧 (needs update) +├── blocks/contact-form/ +│ ├── attributes.ts ✅ (updated) +│ ├── edit.tsx 🚧 (needs update) +│ ├── index.js 🚧 (needs update) +│ └── hooks/ +│ ├── use-form-ref.ts ⏳ (new) +│ ├── use-form-sync.ts ⏳ (new) +│ └── use-legacy-migration.ts ⏳ (new) +└── dashboard/ 🚧 (needs updates) +``` + +**Legend**: +- ✅ Complete +- 🚧 Needs modification +- ⏳ Needs creation + +--- + +## Next Immediate Steps + +1. **Create `use-form-ref.ts` hook** - This is critical for creating CPT on block insert +2. **Create `use-form-sync.ts` hook** - Keeps CPT in sync with block changes +3. **Update `edit.tsx`** - Integrate the hooks +4. **Test in development environment** +5. **Update block save function** to store only reference +6. **Update PHP renderer** to load from CPT + +--- + +## Notes & Considerations + +### Data Flow +``` +Block Editor (React) + ↓ [useFormRef] +Creates jetpack_form CPT via REST API + ↓ +Block gets formRef attribute + ↓ [useFormSync - debounced] +Inner blocks → serialized → PUT /forms/{id}/blocks +Settings → PUT /forms/{id}/sync + ↓ +CPT stores: + - post_content: serialized blocks + - post_meta: settings, integrations + ↓ [On Frontend] +PHP Block Renderer + ↓ +Fetches from CPT using formRef + ↓ +Renders form from CPT data +``` + +### Backward Compatibility Strategy +- Forms without `formRef` continue to work as before +- Migration can be opt-in initially +- Future versions can auto-migrate on first edit +- Keep both rendering paths during transition + +### Performance Considerations +- Debounce sync calls to avoid excessive API requests +- Cache CPT form data on frontend +- Consider using WordPress object cache +- Batch setting updates when possible + +--- + +## Questions to Resolve + +1. **Auto-create vs Manual**: Should CPT be created automatically on block insert, or require user action? + - **Recommendation**: Auto-create for seamless UX + +2. **Migration Prompt**: How to handle existing forms? + - **Recommendation**: Auto-migrate on first save, keep legacy render path + +3. **Title Generation**: How to name auto-created forms? + - **Recommendation**: Use page/post title + "Form" or "Form - [timestamp]" + +4. **Deletion Behavior**: What happens when post containing form is deleted? + - **Recommendation**: Keep CPT (forms are reusable), add admin UI to clean orphans + +5. **Multi-instance**: Can same formRef appear in multiple posts? + - **Recommendation**: Yes! That's the point - reusable forms diff --git a/projects/packages/forms/changelog/add-forms-build-editor b/projects/packages/forms/changelog/add-forms-build-editor new file mode 100644 index 0000000000000..fa23ba1b08ce1 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-build-editor @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Forms: Add form central management diff --git a/projects/packages/forms/implement-forms-post-type.md b/projects/packages/forms/implement-forms-post-type.md new file mode 100644 index 0000000000000..9bf8f30543661 --- /dev/null +++ b/projects/packages/forms/implement-forms-post-type.md @@ -0,0 +1,312 @@ +# Plan: Jetpack Forms Management System with Custom Post Type + +Based on analysis of the codebase, here's a comprehensive plan to migrate Jetpack Forms from block-embedded storage to a custom post type architecture: + +## Current Architecture Analysis + +**Current State:** +- Forms are stored as `jetpack/contact-form` blocks directly in post/page content +- Form responses use the `feedback` custom post type (already exists) +- Forms have 35+ child blocks (fields, layout, navigation) +- Block attributes store form settings (subject, recipients, integrations, etc.) +- No centralized form management - forms are embedded in pages/posts + +**Key Files:** +- `/projects/packages/forms/src/blocks/contact-form/` - Main form block +- `/projects/packages/forms/src/contact-form/class-feedback.php` - Response handling +- `/projects/packages/forms/src/dashboard/` - Existing response dashboard + +--- + +## Proposed Architecture + +### Phase 1: Custom Post Type for Forms + +**1. Create `jetpack_form` Custom Post Type** + +**Location:** `/projects/packages/forms/src/form-library/` + +``` +jetpack_form (CPT) +├── post_title: Form name +├── post_content: Serialized block content (InnerBlocks) +├── post_status: publish, draft, trash +└── post_meta: + ├── _form_settings: JSON (subject, to, notifications, etc.) + ├── _form_integrations: JSON (CRM, Mailpoet, Salesforce, etc.) + ├── _form_version: Schema version for migrations + └── _response_count: Cached response count +``` + +**Features:** +- Support for revisions (form history) +- Draft/publish workflow +- Trash/restore capability +- Form templates/patterns +- Import/export capability + +--- + +### Phase 2: Reference Block System + +**2. Create `jetpack/form-reference` Block** + +This block replaces the full form block in post content and references the CPT: + +```javascript +{ + "name": "jetpack/form-reference", + "attributes": { + "formId": number, // Reference to jetpack_form post ID + "formTitle": string, // Cached for display + "overrideSettings": object, // Optional per-instance overrides + "displayMode": string // "inline" | "modal" | "slide-in" + } +} +``` + +**Implementation:** +- Block saves only the reference ID +- Editor loads form definition from CPT on edit +- Frontend renders form by fetching CPT data +- Allows same form to appear on multiple pages +- Changes to form automatically reflect everywhere + +--- + +### Phase 3: Form Editor Integration + +**3. Dual Editing Experience** + +**Option A: Block Editor for Forms (Recommended)** +- Reuse existing form blocks in standalone editor +- Forms edited at `/wp-admin/post.php?post={id}&post_type=jetpack_form` +- Use `@wordpress/block-editor` for full block experience +- Inherit all existing block functionality +- No need to rebuild form builder UI + +**Option B: Custom Form Builder** +- Build dedicated React form builder +- Drag-and-drop field management +- Visual form designer +- More overhead but potentially simpler UX + +**Recommendation: Option A** - Leverage existing block infrastructure + +--- + +### Phase 4: Form Library Dashboard + +**4. Forms Management UI** + +**Location:** `/wp-admin/edit.php?post_type=jetpack_form` + +**Features:** +- List all forms with metadata: + - Form name + - Response count + - Last modified + - Used on X pages/posts +- Quick actions: Edit, Duplicate, Export, Trash +- Bulk operations +- Search and filter +- Form templates library + +**Integration with existing dashboard:** +- Enhance `/projects/packages/forms/src/dashboard/` +- Add "Forms Library" section +- Link responses to parent form +- Show form usage analytics + +--- + +### Phase 5: Migration Strategy + +**5. Backward Compatibility & Migration** + +**Migration Path:** + +``` +Existing Block → Detect on Save → Create CPT → Replace with Reference +``` + +**Implementation:** + +1. **Automatic Migration Hook:** + - Hook into `save_post` for posts containing `jetpack/contact-form` + - Extract form block and attributes + - Create new `jetpack_form` CPT + - Replace original block with `jetpack/form-reference` + - Preserve all form settings and fields + +2. **Batch Migration Tool:** + - WP-CLI command: `wp jetpack-forms migrate` + - Admin UI tool: "Migrate Forms to Library" + - Progress tracking and rollback support + +3. **Fallback Rendering:** + - If `jetpack/contact-form` still exists, render normally + - Gradual migration - both systems work simultaneously + - No breaking changes during transition + +--- + +## Detailed Implementation Plan + +### Step 1: Register Custom Post Type +**Files to create:** +- `class-form-post-type.php` - CPT registration +- `class-form-meta.php` - Meta box handlers + +**Tasks:** +- Register `jetpack_form` with appropriate capabilities +- Add meta boxes for form settings +- Enable revisions and autosave +- Set up REST API endpoints + +--- + +### Step 2: Create Reference Block +**Files to modify/create:** +- `blocks/form-reference/block.json` +- `blocks/form-reference/edit.tsx` - Editor component +- `blocks/form-reference/save.tsx` - Save handler +- `blocks/form-reference/view.tsx` - Frontend rendering + +**Tasks:** +- Build form selector UI (dropdown/modal) +- Implement form preview in editor +- Handle form loading and caching +- Support SSR and dynamic rendering + +--- + +### Step 3: Update Form Block for CPT Context +**Files to modify:** +- `blocks/contact-form/editor.ts` +- `blocks/contact-form/class-contact-form-block.php` + +**Tasks:** +- Detect if editing in CPT vs. inline +- Disable certain settings in CPT mode (like subject override) +- Save to post_meta instead of block attributes +- Maintain backward compatibility + +--- + +### Step 4: Build Form Editor +**Files to create:** +- `form-library/class-form-editor.php` +- `form-library/editor.tsx` + +**Tasks:** +- Create custom edit screen for `jetpack_form` +- Initialize block editor +- Load form blocks +- Handle save to CPT + +--- + +### Step 5: Update Response System +**Files to modify:** +- `contact-form/class-feedback.php` +- `contact-form/class-contact-form-endpoint.php` + +**Tasks:** +- Link responses to `jetpack_form` ID instead of post_parent +- Add `_form_id` meta to feedback posts +- Update queries to filter by form +- Maintain backward compatibility for old responses + +--- + +### Step 6: Build Migration System +**Files to create:** +- `migrations/class-form-migrator.php` +- `cli/class-forms-command.php` + +**Tasks:** +- Scan posts for `jetpack/contact-form` blocks +- Extract block content and attributes +- Create CPT entries +- Replace blocks with references +- Add rollback mechanism + +--- + +### Step 7: Update Dashboard +**Files to modify:** +- `dashboard/class-dashboard.php` +- `dashboard/components/` - React components + +**Tasks:** +- Add "Forms Library" navigation +- Show form usage statistics +- Link responses to forms +- Add form analytics + +--- + +## Key Considerations + +### Data Integrity +- **No data loss:** All existing forms must migrate successfully +- **Validation:** Verify form structure before/after migration +- **Audit log:** Track all migrations and changes + +### Performance +- **Caching:** Cache form definitions on frontend +- **Lazy loading:** Load form blocks only when needed in editor +- **Database indexes:** Add indexes on form_id for responses + +### User Experience +- **Seamless transition:** Users shouldn't notice the change +- **Progressive migration:** Allow both systems to coexist +- **Clear messaging:** Notify users of benefits (reusable forms, centralized management) + +### Compatibility +- **Plugins/Themes:** Ensure integrations continue working +- **REST API:** Maintain existing endpoints for responses +- **Filters/Hooks:** Preserve all existing extension points + +### Rollback Plan +- **Version check:** Store schema version in database +- **Reverse migration:** Ability to convert CPT back to blocks +- **Feature flag:** Control rollout with feature flags + +--- + +## Benefits of This Approach + +1. **Centralized Management:** Single location for all forms +2. **Reusability:** Use same form across multiple pages +3. **Version Control:** Form revisions and history +4. **Better Analytics:** Track form performance across site +5. **Easier Maintenance:** Update form once, affects all instances +6. **Templates:** Create form libraries and patterns +7. **Import/Export:** Share forms between sites +8. **Better Permissions:** Control who can edit forms vs. pages +9. **Improved Performance:** Cache form definitions +10. **Future Features:** A/B testing, conditional logic, etc. + +--- + +## Timeline Estimate + +- **Phase 1 (CPT):** 1-2 weeks +- **Phase 2 (Reference Block):** 1-2 weeks +- **Phase 3 (Form Editor):** 2-3 weeks +- **Phase 4 (Dashboard):** 1-2 weeks +- **Phase 5 (Migration):** 2-3 weeks +- **Testing & Polish:** 2-3 weeks + +**Total:** 9-15 weeks for full implementation + +--- + +## Next Steps + +1. Start implementing the custom post type registration +2. Create the reference block +3. Build a proof-of-concept migration script +4. Create detailed technical specifications for each phase diff --git a/projects/packages/forms/src/blocks/contact-form/attributes.ts b/projects/packages/forms/src/blocks/contact-form/attributes.ts index 439bcc3d6e704..a31cbd26cc74a 100644 --- a/projects/packages/forms/src/blocks/contact-form/attributes.ts +++ b/projects/packages/forms/src/blocks/contact-form/attributes.ts @@ -4,6 +4,10 @@ import { __ } from '@wordpress/i18n'; export default { + formRef: { + type: 'number', + default: 0, + }, subject: { type: 'string', default: window.jpFormsBlocks?.defaults?.subject || '', diff --git a/projects/packages/forms/src/blocks/contact-form/components/form-selector.tsx b/projects/packages/forms/src/blocks/contact-form/components/form-selector.tsx new file mode 100644 index 0000000000000..4d0759a079fe0 --- /dev/null +++ b/projects/packages/forms/src/blocks/contact-form/components/form-selector.tsx @@ -0,0 +1,273 @@ +/** + * Form Selector Component + * + * Allows users to select an existing jetpack_form CPT or create a new one. + * This enables form reusability across multiple posts/pages. + */ + +import apiFetch from '@wordpress/api-fetch'; +import { SelectControl, Button, Spinner, Notice, TextControl } from '@wordpress/components'; +import { debounce } from '@wordpress/compose'; +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; + +interface FormSelectorProps { + formRef: number; + onFormSelect: ( formId: number ) => void; + currentFormTitle?: string; +} + +interface JetpackForm { + id: number; + title: { + rendered: string; + }; + date: string; + modified: string; +} + +interface FormsResponse { + forms: JetpackForm[]; +} + +/** + * Component to select a form from available jetpack_form CPTs + * + * @param {object} props - Component props + * @param {number} props.formRef - Current form reference ID + * @param {Function} props.onFormSelect - Callback when form is selected + * @param {string} props.currentFormTitle - Title of current form + * @return {JSX.Element} Form selector component + */ +export default function FormSelector( { + formRef, + onFormSelect, + currentFormTitle = '', +}: FormSelectorProps ): JSX.Element { + const [ forms, setForms ] = useState< JetpackForm[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + const [ isCreatingNew, setIsCreatingNew ] = useState( false ); + const [ formTitle, setFormTitle ] = useState( currentFormTitle ); + const [ isSavingTitle, setIsSavingTitle ] = useState( false ); + + // Update local title when currentFormTitle changes (e.g., when switching forms) + useEffect( () => { + setFormTitle( currentFormTitle ); + }, [ currentFormTitle ] ); + + // Debounced function to save form title + const debouncedSaveTitle = debounce( ( newTitle: string ) => { + if ( ! formRef || formRef === 0 ) { + return; + } + + setIsSavingTitle( true ); + + apiFetch( { + path: `/wp/v2/jetpack-forms/${ formRef }`, + method: 'POST', + data: { + title: newTitle, + }, + } ) + .then( () => { + setIsSavingTitle( false ); + // Refresh the forms list to show updated title + return apiFetch< JetpackForm[] >( { + path: addQueryArgs( '/wp/v2/jetpack-forms', { + per_page: 100, + orderby: 'modified', + order: 'desc', + } ), + } ); + } ) + .then( fetchedForms => { + if ( fetchedForms ) { + setForms( fetchedForms ); + } + } ) + .catch( err => { + setIsSavingTitle( false ); + console.error( 'Failed to save form title:', err ); + } ); + }, 1000 ); + + // Handle title change + const handleTitleChange = ( newTitle: string ) => { + setFormTitle( newTitle ); + debouncedSaveTitle( newTitle ); + }; + + // Load available forms + useEffect( () => { + setIsLoading( true ); + setError( null ); + + const queryArgs = { + per_page: 100, + orderby: 'modified', + order: 'desc', + }; + + apiFetch< JetpackForm[] >( { + path: addQueryArgs( '/wp/v2/jetpack-forms', queryArgs ), + } ) + .then( fetchedForms => { + setForms( fetchedForms ); + setIsLoading( false ); + } ) + .catch( err => { + setError( + err.message || __( 'Failed to load forms. Please refresh the page.', 'jetpack-forms' ) + ); + setIsLoading( false ); + } ); + }, [] ); + + // Create a new form + const handleCreateNew = () => { + setIsCreatingNew( true ); + setError( null ); + + apiFetch< { success: boolean; form_id: number; message: string } >( { + path: '/jetpack-forms/v1/forms/create-from-block', + method: 'POST', + data: { + title: __( 'New Form', 'jetpack-forms' ), + blocks: '', + settings: {}, + integrations: {}, + }, + } ) + .then( response => { + if ( response.success && response.form_id ) { + onFormSelect( response.form_id ); + // Refresh the forms list + return apiFetch< JetpackForm[] >( { + path: addQueryArgs( '/wp/v2/jetpack-forms', { + per_page: 100, + orderby: 'modified', + order: 'desc', + } ), + } ); + } + throw new Error( response.message ); + } ) + .then( fetchedForms => { + if ( fetchedForms ) { + setForms( fetchedForms ); + } + setIsCreatingNew( false ); + } ) + .catch( err => { + setError( + err.message || __( 'Failed to create new form. Please try again.', 'jetpack-forms' ) + ); + setIsCreatingNew( false ); + } ); + }; + + // Handle form selection + const handleFormChange = ( value: string ) => { + const selectedId = parseInt( value, 10 ); + if ( selectedId && selectedId !== formRef ) { + onFormSelect( selectedId ); + } + }; + + // Build options for SelectControl + const formOptions = [ + { + label: __( '-- Select a form --', 'jetpack-forms' ), + value: '0', + disabled: true, + }, + ...forms.map( form => ( { + label: form.title.rendered || __( '(Untitled Form)', 'jetpack-forms' ), + value: String( form.id ), + } ) ), + ]; + + if ( isLoading ) { + return ( +
+ + { __( 'Loading forms…', 'jetpack-forms' ) } +
+ ); + } + + return ( +
+ { error && ( + + { error } + + ) } + + + + + + { formRef > 0 && ( +
+ +
+ + { __( 'Form ID:', 'jetpack-forms' ) } { formRef } + +
+
+ ) } + + { forms.length === 0 && ! isLoading && ( + + { __( 'No forms found. Create your first form!', 'jetpack-forms' ) } + + ) } +
+ ); +} diff --git a/projects/packages/forms/src/blocks/contact-form/edit.tsx b/projects/packages/forms/src/blocks/contact-form/edit.tsx index 34447f4cca5f2..ed30bc1b66b3c 100644 --- a/projects/packages/forms/src/blocks/contact-form/edit.tsx +++ b/projects/packages/forms/src/blocks/contact-form/edit.tsx @@ -47,10 +47,14 @@ import useFormSteps from '../shared/hooks/use-form-steps'; import { SyncedAttributeProvider } from '../shared/hooks/use-synced-attributes'; import { CORE_BLOCKS } from '../shared/util/constants'; import { childBlocks } from './child-blocks'; +import FormSelector from './components/form-selector'; import { ContactFormPlaceholder } from './components/jetpack-contact-form-placeholder'; import ContactFormSkeletonLoader from './components/jetpack-contact-form-skeleton-loader'; import JetpackEmailConnectionSettings from './components/jetpack-email-connection-settings'; import NotificationsSettings from './components/notifications-settings'; +import useFormLoader from './hooks/use-form-loader'; +import useFormRef from './hooks/use-form-ref'; +import useFormSync from './hooks/use-form-sync'; import useFormBlockDefaults from './shared/hooks/use-form-block-defaults'; import VariationPicker from './variation-picker'; import './util/form-styles.js'; @@ -120,6 +124,7 @@ type CustomThankyouType = | 'redirect'; // redirect to a new URL type JetpackContactFormAttributes = { + formRef: number; to: string; subject: string; // Legacy support for the customThankyou attribute @@ -134,6 +139,12 @@ type JetpackContactFormAttributes = { disableGoBack: boolean; disableSummary: boolean; notificationRecipients: string[]; + jetpackCRM?: boolean; + salesforceData?: Record< string, unknown >; + mailpoet?: Record< string, unknown >; + hostingerReach?: Record< string, unknown >; + saveResponses?: boolean; + formNotifications?: boolean; }; type JetpackContactFormEditProps = { name: string; @@ -154,6 +165,7 @@ function JetpackContactFormEdit( { useFormBlockDefaults( { attributes, setAttributes } ); const { + formRef, to, subject, customThankyou, @@ -168,6 +180,37 @@ function JetpackContactFormEdit( { disableSummary, notificationRecipients, } = attributes; + + // Create jetpack_form CPT reference if needed + const { isCreating: isCreatingForm, error: formCreationError } = useFormRef( { + formRef, + setAttributes, + attributes, + } ); + + // Sync form blocks and settings to CPT + useFormSync( { + formRef, + clientId, + attributes, + } ); + + // Load form data when switching forms + const { loadForm } = useFormLoader( { + clientId, + setAttributes, + } ); + + // Handler for form selection + const handleFormSelect = useCallback( + ( selectedFormId: number ) => { + if ( selectedFormId !== formRef ) { + loadForm( selectedFormId ); + } + }, + [ formRef, loadForm ] + ); + const showFormIntegrations = useConfigValue( 'isIntegrationsEnabled' ); const instanceId = useInstanceId( JetpackContactFormEdit ); @@ -804,6 +847,17 @@ function JetpackContactFormEdit( { { variationName === 'multistep' && } + + + ; + mailpoet?: Record< string, unknown >; + hostingerReach?: Record< string, unknown >; +} + +interface UseFormLoaderProps { + clientId: string; + setAttributes: ( attributes: Record< string, unknown > ) => void; +} + +interface UseFormLoaderReturn { + loadForm: ( formId: number ) => Promise< void >; + isLoading: boolean; + error: string | null; +} + +/** + * Hook to load form data from CPT and update block + * + * @param {string} clientId - Block client ID + * @param {Function} setAttributes - Function to update block attributes + * @return {object} Hook state with loadForm function, loading state, and error + */ +export default function useFormLoader( { + clientId, + setAttributes, +}: UseFormLoaderProps ): UseFormLoaderReturn { + const { replaceInnerBlocks } = useDispatch( blockEditorStore ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const loadForm = useCallback( + async ( formId: number ) => { + if ( ! formId || formId === 0 ) { + return; + } + + setIsLoading( true ); + setError( null ); + + try { + // Fetch the form post data + const formData = await apiFetch< FormData >( { + path: `/wp/v2/jetpack-forms/${ formId }`, + } ); + + // Parse settings from meta + let settings: FormSettings = {}; + if ( formData.meta._jetpack_form_settings ) { + try { + settings = JSON.parse( formData.meta._jetpack_form_settings ); + } catch ( e ) { + console.error( 'Failed to parse form settings:', e ); + } + } + + // Parse integrations from meta + let integrations: FormIntegrations = {}; + if ( formData.meta._jetpack_form_integrations ) { + try { + integrations = JSON.parse( formData.meta._jetpack_form_integrations ); + } catch ( e ) { + console.error( 'Failed to parse form integrations:', e ); + } + } + + // Parse blocks from post content + const blocks = parse( formData.content.rendered || '' ); + + // Update block attributes with form settings and integrations + setAttributes( { + formRef: formId, + formTitle: formData.title.rendered, + ...settings, + ...integrations, + } ); + + // Replace inner blocks with form blocks + replaceInnerBlocks( clientId, blocks, false ); + + setIsLoading( false ); + } catch ( err: unknown ) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to load form. Please try again.'; + setError( errorMessage ); + setIsLoading( false ); + console.error( 'Form loading error:', err ); + } + }, + [ clientId, setAttributes, replaceInnerBlocks ] + ); + + return { + loadForm, + isLoading, + error, + }; +} diff --git a/projects/packages/forms/src/blocks/contact-form/hooks/use-form-ref.ts b/projects/packages/forms/src/blocks/contact-form/hooks/use-form-ref.ts new file mode 100644 index 0000000000000..909dadee007fa --- /dev/null +++ b/projects/packages/forms/src/blocks/contact-form/hooks/use-form-ref.ts @@ -0,0 +1,150 @@ +/** + * Hook to manage jetpack_form CPT reference + * + * This hook handles the creation of a jetpack_form custom post type + * when a new form block is inserted. It ensures every form block + * has a corresponding CPT to store its data. + */ + +import apiFetch from '@wordpress/api-fetch'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useEffect, useRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +interface UseFormRefProps { + formRef: number; + setAttributes: ( attributes: { formRef: number } ) => void; + attributes: { + subject?: string; + to?: string; + customThankyouHeading?: string; + customThankyouMessage?: string; + customThankyouRedirect?: string; + confirmationType?: 'text' | 'redirect'; + jetpackCRM?: boolean; + formTitle?: string; + salesforceData?: Record< string, unknown >; + mailpoet?: Record< string, unknown >; + hostingerReach?: Record< string, unknown >; + saveResponses?: boolean; + emailNotifications?: boolean; + disableGoBack?: boolean; + disableSummary?: boolean; + formNotifications?: boolean; + notificationRecipients?: number[]; + }; +} + +interface CreateFormResponse { + success: boolean; + form_id: number; + message: string; +} + +/** + * Hook to create jetpack_form CPT if formRef is not set + * + * @param {number} formRef - Current form reference ID + * @param {Function} setAttributes - Function to update block attributes + * @param {object} attributes - Current block attributes + * @return {object} Hook state with loading and error + */ +export default function useFormRef( { formRef, setAttributes, attributes }: UseFormRefProps ): { + isCreating: boolean; + error: string | null; +} { + const [ isCreating, setIsCreating ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + const hasAttemptedCreation = useRef( false ); + + // Get post title for form naming + const postTitle = useSelect( select => { + const { getEditedPostAttribute } = select( editorStore ); + return getEditedPostAttribute( 'title' ); + }, [] ); + + useEffect( () => { + // Only create if: + // 1. formRef is 0 (not yet created) + // 2. We haven't already attempted creation + // 3. We're not currently creating + if ( formRef !== 0 || hasAttemptedCreation.current || isCreating ) { + return; + } + + // Mark that we've attempted creation to prevent duplicate calls + hasAttemptedCreation.current = true; + setIsCreating( true ); + setError( null ); + + // Extract settings from block attributes + const settings = { + subject: attributes.subject || '', + to: attributes.to || '', + customThankyouHeading: attributes.customThankyouHeading || '', + customThankyouMessage: attributes.customThankyouMessage || '', + customThankyouRedirect: attributes.customThankyouRedirect || '', + confirmationType: attributes.confirmationType || 'text', + saveResponses: attributes.saveResponses !== false, + emailNotifications: attributes.emailNotifications !== false, + disableGoBack: attributes.disableGoBack || false, + disableSummary: attributes.disableSummary || false, + formNotifications: attributes.formNotifications !== false, + notificationRecipients: attributes.notificationRecipients || [], + }; + + // Extract integrations from block attributes + const integrations = { + jetpackCRM: attributes.jetpackCRM || false, + salesforceData: attributes.salesforceData || { organizationId: '' }, + mailpoet: attributes.mailpoet || { listId: null, listName: null }, + hostingerReach: attributes.hostingerReach || { groupName: '' }, + }; + + // Generate form title + const formTitle = + attributes.formTitle || + ( postTitle + ? `${ postTitle } - ${ __( 'Form', 'jetpack-forms' ) }` + : __( 'New Form', 'jetpack-forms' ) ); + + // Create the form via REST API + apiFetch< CreateFormResponse >( { + path: '/jetpack-forms/v1/forms/create-from-block', + method: 'POST', + data: { + title: formTitle, + blocks: '', // Will be synced later by use-form-sync + settings, + integrations, + }, + } ) + .then( response => { + if ( response.success && response.form_id ) { + // Update the block with the new form reference + setAttributes( { formRef: response.form_id } ); + setIsCreating( false ); + } else { + throw new Error( response.message || __( 'Failed to create form', 'jetpack-forms' ) ); + } + } ) + .catch( err => { + const errorMessage = + err.message || __( 'Failed to create form. Please try again.', 'jetpack-forms' ); + setError( errorMessage ); + setIsCreating( false ); + // Reset the attempt flag so user can retry + hasAttemptedCreation.current = false; + + // Log error for debugging + // eslint-disable-next-line no-console + console.error( 'Form creation error:', err ); + } ); + }, [ formRef, isCreating, postTitle, attributes, setAttributes ] ); + + return { + isCreating, + error, + }; +} diff --git a/projects/packages/forms/src/blocks/contact-form/hooks/use-form-sync.ts b/projects/packages/forms/src/blocks/contact-form/hooks/use-form-sync.ts new file mode 100644 index 0000000000000..9e84c66d55315 --- /dev/null +++ b/projects/packages/forms/src/blocks/contact-form/hooks/use-form-sync.ts @@ -0,0 +1,187 @@ +/** + * Hook to sync form blocks and settings to jetpack_form CPT + * + * This hook watches for changes to inner blocks and form settings, + * then syncs them to the corresponding jetpack_form custom post type. + * Changes are debounced to avoid excessive API calls. + */ + +import apiFetch from '@wordpress/api-fetch'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { serialize } from '@wordpress/blocks'; +import { debounce } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { useEffect, useRef, useCallback } from '@wordpress/element'; + +interface UseFormSyncProps { + formRef: number; + clientId: string; + attributes: { + subject?: string; + to?: string; + customThankyouHeading?: string; + customThankyouMessage?: string; + customThankyouRedirect?: string; + confirmationType?: 'text' | 'redirect'; + jetpackCRM?: boolean; + formTitle?: string; + salesforceData?: Record< string, unknown >; + mailpoet?: Record< string, unknown >; + hostingerReach?: Record< string, unknown >; + saveResponses?: boolean; + emailNotifications?: boolean; + disableGoBack?: boolean; + disableSummary?: boolean; + formNotifications?: boolean; + notificationRecipients?: number[]; + }; +} + +interface SyncBlocksResponse { + success: boolean; + message: string; +} + +interface SyncSettingsResponse { + success: boolean; + message: string; +} + +/** + * Hook to sync form blocks and settings to CPT + * + * @param {number} formRef - Form reference ID + * @param {string} clientId - Block client ID + * @param {object} attributes - Block attributes + */ +export default function useFormSync( { formRef, clientId, attributes }: UseFormSyncProps ): void { + const previousBlocksRef = useRef< string >( '' ); + const previousSettingsRef = useRef< string >( '' ); + + // Get inner blocks + const innerBlocks = useSelect( + select => { + const { getBlocks } = select( blockEditorStore ); + return getBlocks( clientId ); + }, + [ clientId ] + ); + + // Create debounced sync functions + const debouncedSyncBlocks = useCallback( + debounce( ( formId: number, serializedBlocks: string ) => { + apiFetch< SyncBlocksResponse >( { + path: `/jetpack-forms/v1/forms/${ formId }/blocks`, + method: 'PUT', + data: { + blocks: serializedBlocks, + }, + } ).catch( err => { + // eslint-disable-next-line no-console + console.error( 'Failed to sync form blocks:', err ); + } ); + }, 2000 ), + [] + ); + + const debouncedSyncSettings = useCallback( + debounce( + ( + formId: number, + settings: Record< string, unknown >, + integrations: Record< string, unknown > + ) => { + apiFetch< SyncSettingsResponse >( { + path: `/jetpack-forms/v1/forms/${ formId }/sync`, + method: 'PUT', + data: { + settings, + integrations, + }, + } ).catch( err => { + // eslint-disable-next-line no-console + console.error( 'Failed to sync form settings:', err ); + } ); + }, + 2000 + ), + [] + ); + + // Sync inner blocks when they change + useEffect( () => { + // Don't sync if formRef is not set yet + if ( ! formRef || formRef === 0 ) { + return; + } + + // Serialize current blocks + const serializedBlocks = serialize( innerBlocks ); + + // Only sync if blocks have actually changed + if ( serializedBlocks !== previousBlocksRef.current ) { + previousBlocksRef.current = serializedBlocks; + debouncedSyncBlocks( formRef, serializedBlocks ); + } + }, [ formRef, innerBlocks, debouncedSyncBlocks ] ); + + // Sync settings when they change + useEffect( () => { + // Don't sync if formRef is not set yet + if ( ! formRef || formRef === 0 ) { + return; + } + + // Extract settings + const settings = { + subject: attributes.subject || '', + to: attributes.to || '', + customThankyouHeading: attributes.customThankyouHeading || '', + customThankyouMessage: attributes.customThankyouMessage || '', + customThankyouRedirect: attributes.customThankyouRedirect || '', + confirmationType: attributes.confirmationType || 'text', + saveResponses: attributes.saveResponses !== false, + emailNotifications: attributes.emailNotifications !== false, + disableGoBack: attributes.disableGoBack || false, + disableSummary: attributes.disableSummary || false, + formNotifications: attributes.formNotifications !== false, + notificationRecipients: attributes.notificationRecipients || [], + }; + + // Extract integrations + const integrations = { + jetpackCRM: attributes.jetpackCRM || false, + salesforceData: attributes.salesforceData || { organizationId: '' }, + mailpoet: attributes.mailpoet || { listId: null, listName: null }, + hostingerReach: attributes.hostingerReach || { groupName: '' }, + }; + + // Create a serialized version for comparison + const currentSettings = JSON.stringify( { settings, integrations } ); + + // Only sync if settings have actually changed + if ( currentSettings !== previousSettingsRef.current ) { + previousSettingsRef.current = currentSettings; + debouncedSyncSettings( formRef, settings, integrations ); + } + }, [ + formRef, + attributes.subject, + attributes.to, + attributes.customThankyouHeading, + attributes.customThankyouMessage, + attributes.customThankyouRedirect, + attributes.confirmationType, + attributes.jetpackCRM, + attributes.salesforceData, + attributes.mailpoet, + attributes.hostingerReach, + attributes.saveResponses, + attributes.emailNotifications, + attributes.disableGoBack, + attributes.disableSummary, + attributes.formNotifications, + attributes.notificationRecipients, + debouncedSyncSettings, + ] ); +} diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 3c23f1114101c..beb150a151ff0 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -101,6 +101,12 @@ public static function init() { if ( ! $instance ) { $instance = new Contact_Form_Plugin(); + // Initialize the Jetpack Form custom post type + Jetpack_Form::init(); + + // Initialize the Jetpack Form REST API endpoints + Jetpack_Form_Endpoint::init(); + // Schedule our daily cleanup add_action( 'wp_scheduled_delete', array( $instance, 'daily_akismet_meta_cleanup' ) ); } diff --git a/projects/packages/forms/src/contact-form/class-jetpack-form-endpoint.php b/projects/packages/forms/src/contact-form/class-jetpack-form-endpoint.php new file mode 100644 index 0000000000000..9aba163ab7373 --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-jetpack-form-endpoint.php @@ -0,0 +1,339 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_form_from_block' ), + 'permission_callback' => array( $this, 'can_edit_forms' ), + 'args' => array( + 'title' => array( + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ), + 'blocks' => array( + 'required' => false, + 'type' => 'string', + 'default' => '', + ), + 'settings' => array( + 'required' => false, + 'type' => 'object', + 'default' => array(), + ), + 'integrations' => array( + 'required' => false, + 'type' => 'object', + 'default' => array(), + ), + ), + ) + ); + + // Update form blocks + register_rest_route( + 'jetpack-forms/v1', + '/forms/(?P\d+)/blocks', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_form_blocks' ), + 'permission_callback' => array( $this, 'can_edit_form' ), + 'args' => array( + 'id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'blocks' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + + // Sync form settings + register_rest_route( + 'jetpack-forms/v1', + '/forms/(?P\d+)/sync', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'sync_form_settings' ), + 'permission_callback' => array( $this, 'can_edit_form' ), + 'args' => array( + 'id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'settings' => array( + 'required' => false, + 'type' => 'object', + 'default' => array(), + ), + 'integrations' => array( + 'required' => false, + 'type' => 'object', + 'default' => array(), + ), + ), + ) + ); + } + + /** + * Check if current user can edit forms. + * + * @return bool + */ + public function can_edit_forms() { + return current_user_can( 'edit_pages' ); + } + + /** + * Check if current user can edit a specific form. + * + * @param WP_REST_Request $request Request object. + * @return bool + */ + public function can_edit_form( $request ) { + $form_id = $request->get_param( 'id' ); + return current_user_can( 'edit_post', $form_id ); + } + + /** + * Create a new jetpack_form post from block data. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function create_form_from_block( $request ) { + $title = $request->get_param( 'title' ); + $blocks = $request->get_param( 'blocks' ); + $settings = $request->get_param( 'settings' ); + $integrations = $request->get_param( 'integrations' ); + + // Generate a default title if none provided + if ( empty( $title ) ) { + $title = sprintf( + /* translators: %s: date and time */ + __( 'Form created on %s', 'jetpack-forms' ), + current_time( 'Y-m-d H:i:s' ) + ); + } + + // Create the post + $post_id = wp_insert_post( + array( + 'post_type' => Jetpack_Form::POST_TYPE, + 'post_title' => $title, + 'post_content' => $blocks, + 'post_status' => 'publish', + 'post_author' => get_current_user_id(), + ) + ); + + if ( is_wp_error( $post_id ) ) { + return new WP_Error( + 'form_creation_failed', + __( 'Failed to create form.', 'jetpack-forms' ), + array( 'status' => 500 ) + ); + } + + // Save settings and integrations + if ( ! empty( $settings ) ) { + Jetpack_Form::update_form_settings( $post_id, $settings ); + } + + if ( ! empty( $integrations ) ) { + Jetpack_Form::update_form_integrations( $post_id, $integrations ); + } + + /** + * Fires after a form is created from a block. + * + * @since 1.0.0 + * + * @param int $post_id Post ID of the created form. + * @param array $request Request parameters. + */ + do_action( 'jetpack_form_created_from_block', $post_id, $request->get_params() ); + + return new WP_REST_Response( + array( + 'success' => true, + 'form_id' => $post_id, + 'message' => __( 'Form created successfully.', 'jetpack-forms' ), + ), + 201 + ); + } + + /** + * Update form blocks content. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function update_form_blocks( $request ) { + $form_id = $request->get_param( 'id' ); + $blocks = $request->get_param( 'blocks' ); + + $form = Jetpack_Form::get_form( $form_id ); + if ( ! $form ) { + return new WP_Error( + 'form_not_found', + __( 'Form not found.', 'jetpack-forms' ), + array( 'status' => 404 ) + ); + } + + // Update the post content + $result = wp_update_post( + array( + 'ID' => $form_id, + 'post_content' => $blocks, + ) + ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( + 'form_update_failed', + __( 'Failed to update form blocks.', 'jetpack-forms' ), + array( 'status' => 500 ) + ); + } + + /** + * Fires after form blocks are updated. + * + * @since 1.0.0 + * + * @param int $form_id Form post ID. + * @param string $blocks Updated blocks content. + */ + do_action( 'jetpack_form_blocks_updated', $form_id, $blocks ); + + return new WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Form blocks updated successfully.', 'jetpack-forms' ), + ), + 200 + ); + } + + /** + * Sync form settings and integrations from block attributes. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function sync_form_settings( $request ) { + $form_id = $request->get_param( 'id' ); + $settings = $request->get_param( 'settings' ); + $integrations = $request->get_param( 'integrations' ); + + $form = Jetpack_Form::get_form( $form_id ); + if ( ! $form ) { + return new WP_Error( + 'form_not_found', + __( 'Form not found.', 'jetpack-forms' ), + array( 'status' => 404 ) + ); + } + + $updated = false; + + // Update settings if provided + if ( ! empty( $settings ) && is_array( $settings ) ) { + Jetpack_Form::update_form_settings( $form_id, $settings ); + $updated = true; + } + + // Update integrations if provided + if ( ! empty( $integrations ) && is_array( $integrations ) ) { + Jetpack_Form::update_form_integrations( $form_id, $integrations ); + $updated = true; + } + + if ( ! $updated ) { + return new WP_Error( + 'no_data_provided', + __( 'No settings or integrations data provided.', 'jetpack-forms' ), + array( 'status' => 400 ) + ); + } + + /** + * Fires after form settings are synced. + * + * @since 1.0.0 + * + * @param int $form_id Form post ID. + * @param array $settings Updated settings. + * @param array $integrations Updated integrations. + */ + do_action( 'jetpack_form_settings_synced', $form_id, $settings, $integrations ); + + return new WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Form settings synced successfully.', 'jetpack-forms' ), + ), + 200 + ); + } +} diff --git a/projects/packages/forms/src/contact-form/class-jetpack-form.php b/projects/packages/forms/src/contact-form/class-jetpack-form.php new file mode 100644 index 0000000000000..87f8868f89eca --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-jetpack-form.php @@ -0,0 +1,546 @@ + _x( 'Forms', 'Post type general name', 'jetpack-forms' ), + 'singular_name' => _x( 'Form', 'Post type singular name', 'jetpack-forms' ), + 'menu_name' => _x( 'Forms', 'Admin Menu text', 'jetpack-forms' ), + 'name_admin_bar' => _x( 'Form', 'Add New on Toolbar', 'jetpack-forms' ), + 'add_new' => __( 'Add New', 'jetpack-forms' ), + 'add_new_item' => __( 'Add New Form', 'jetpack-forms' ), + 'new_item' => __( 'New Form', 'jetpack-forms' ), + 'edit_item' => __( 'Edit Form', 'jetpack-forms' ), + 'view_item' => __( 'View Form', 'jetpack-forms' ), + 'all_items' => __( 'All Forms', 'jetpack-forms' ), + 'search_items' => __( 'Search Forms', 'jetpack-forms' ), + 'parent_item_colon' => __( 'Parent Forms:', 'jetpack-forms' ), + 'not_found' => __( 'No forms found.', 'jetpack-forms' ), + 'not_found_in_trash' => __( 'No forms found in Trash.', 'jetpack-forms' ), + 'featured_image' => __( 'Form featured image', 'jetpack-forms' ), + 'set_featured_image' => __( 'Set form featured image', 'jetpack-forms' ), + 'remove_featured_image' => __( 'Remove form featured image', 'jetpack-forms' ), + 'use_featured_image' => __( 'Use as form featured image', 'jetpack-forms' ), + 'archives' => __( 'Form archives', 'jetpack-forms' ), + 'insert_into_item' => __( 'Insert into form', 'jetpack-forms' ), + 'uploaded_to_this_item' => __( 'Uploaded to this form', 'jetpack-forms' ), + 'filter_items_list' => __( 'Filter forms list', 'jetpack-forms' ), + 'items_list_navigation' => __( 'Forms list navigation', 'jetpack-forms' ), + 'items_list' => __( 'Forms list', 'jetpack-forms' ), + ); + + $args = array( + 'labels' => $labels, + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'jetpack-forms', + 'query_var' => true, + 'rewrite' => array( 'slug' => 'jetpack-form' ), + 'capability_type' => 'page', + 'capabilities' => array( + 'edit_post' => 'edit_pages', + 'read_post' => 'read', + 'delete_post' => 'delete_pages', + 'edit_posts' => 'edit_pages', + 'edit_others_posts' => 'edit_others_pages', + 'publish_posts' => 'publish_pages', + 'read_private_posts' => 'read_private_pages', + ), + 'has_archive' => false, + 'hierarchical' => false, + 'menu_position' => 25, + 'menu_icon' => 'dashicons-feedback', + 'supports' => array( 'title', 'editor', 'revisions', 'custom-fields' ), + 'template' => array( + array( 'jetpack/contact-form' ), + ), + 'template_lock' => 'all', + 'can_export' => true, + 'delete_with_user' => false, + ); + + /** + * Filters the arguments used to register the jetpack_form post type. + * + * @since 1.0.0 + * + * @param array $args Arguments passed to register_post_type(). + */ + $args = apply_filters( 'jetpack_form_post_type_args', $args ); + + register_post_type( self::POST_TYPE, $args ); + + // Register meta fields for REST API + $this->register_meta_fields(); + } + + /** + * Register meta fields for REST API access. + */ + private function register_meta_fields() { + $meta_fields = array( + self::META_SETTINGS => array( + 'type' => 'object', + 'description' => __( 'Form settings including subject, recipients, and notifications.', 'jetpack-forms' ), + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'subject' => array( 'type' => 'string' ), + 'to' => array( 'type' => 'string' ), + 'customThankyou' => array( 'type' => 'string' ), + 'customThankyouHeading' => array( 'type' => 'string' ), + 'customThankyouMessage' => array( 'type' => 'string' ), + 'customThankyouRedirect' => array( 'type' => 'string' ), + 'confirmationType' => array( 'type' => 'string' ), + 'saveResponses' => array( 'type' => 'boolean' ), + 'emailNotifications' => array( 'type' => 'boolean' ), + 'formNotifications' => array( 'type' => 'boolean' ), + 'notificationRecipients' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + ), + ), + ), + ), + ), + self::META_INTEGRATIONS => array( + 'type' => 'object', + 'description' => __( 'Third-party integrations like CRM, Mailpoet, Salesforce.', 'jetpack-forms' ), + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'jetpackCRM' => array( 'type' => 'boolean' ), + 'salesforceData' => array( + 'type' => 'object', + 'properties' => array( + 'organizationId' => array( 'type' => 'string' ), + ), + ), + 'mailpoet' => array( + 'type' => 'object', + 'properties' => array( + 'listId' => array( 'type' => 'integer' ), + 'listName' => array( 'type' => 'string' ), + ), + ), + 'hostingerReach' => array( + 'type' => 'object', + 'properties' => array( + 'groupName' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + ), + self::META_VERSION => array( + 'type' => 'integer', + 'description' => __( 'Schema version for migrations.', 'jetpack-forms' ), + 'single' => true, + 'show_in_rest' => true, + 'default' => self::SCHEMA_VERSION, + ), + self::META_RESPONSE_COUNT => array( + 'type' => 'integer', + 'description' => __( 'Cached count of form responses.', 'jetpack-forms' ), + 'single' => true, + 'show_in_rest' => true, + 'default' => 0, + ), + ); + + foreach ( $meta_fields as $meta_key => $args ) { + register_post_meta( self::POST_TYPE, $meta_key, $args ); + } + } + + /** + * Save form meta when the post is saved. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + */ + public function save_form_meta( $post_id, $post ) { + // Skip autosaves and revisions + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + // Verify user capabilities + if ( ! current_user_can( 'edit_page', $post_id ) ) { + return; + } + + // Set version if not already set + $version = get_post_meta( $post_id, self::META_VERSION, true ); + if ( empty( $version ) ) { + update_post_meta( $post_id, self::META_VERSION, self::SCHEMA_VERSION ); + } + + /** + * Fires after form meta is saved. + * + * @since 1.0.0 + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + */ + do_action( 'jetpack_form_saved', $post_id, $post ); + } + + /** + * Filter REST API queries for jetpack_form. + * + * @param array $args Query arguments. + * @param \WP_REST_Request $request REST request object. + * @return array Modified query arguments. + */ + public function filter_rest_query( $args, $request ) { + // Allow filtering by response count, usage, etc. + // This can be extended as needed + + /** + * Filters the query arguments for jetpack_form REST API requests. + * + * @since 1.0.0 + * + * @param array $args Query arguments. + * @param \WP_REST_Request $request REST request object. + */ + return apply_filters( 'jetpack_form_rest_query', $args, $request ); + } + + /** + * Get a form by ID. + * + * @param int $form_id Form post ID. + * @return \WP_Post|null Post object or null if not found. + */ + public static function get_form( $form_id ) { + $post = get_post( $form_id ); + + if ( ! $post || self::POST_TYPE !== $post->post_type ) { + return null; + } + + return $post; + } + + /** + * Get form settings from meta. + * + * @param int $form_id Form post ID. + * @return array Form settings. + */ + public static function get_form_settings( $form_id ) { + $settings = get_post_meta( $form_id, self::META_SETTINGS, true ); + return is_array( $settings ) ? $settings : array(); + } + + /** + * Update form settings. + * + * @param int $form_id Form post ID. + * @param array $settings Form settings to save. + * @return bool True on success, false on failure. + */ + public static function update_form_settings( $form_id, $settings ) { + if ( ! self::get_form( $form_id ) ) { + return false; + } + + return update_post_meta( $form_id, self::META_SETTINGS, $settings ); + } + + /** + * Get form integrations from meta. + * + * @param int $form_id Form post ID. + * @return array Form integrations. + */ + public static function get_form_integrations( $form_id ) { + $integrations = get_post_meta( $form_id, self::META_INTEGRATIONS, true ); + return is_array( $integrations ) ? $integrations : array(); + } + + /** + * Update form integrations. + * + * @param int $form_id Form post ID. + * @param array $integrations Form integrations to save. + * @return bool True on success, false on failure. + */ + public static function update_form_integrations( $form_id, $integrations ) { + if ( ! self::get_form( $form_id ) ) { + return false; + } + + return update_post_meta( $form_id, self::META_INTEGRATIONS, $integrations ); + } + + /** + * Get response count for a form. + * + * @param int $form_id Form post ID. + * @param bool $refresh Whether to refresh the cached count. + * @return int Number of responses. + */ + public static function get_response_count( $form_id, $refresh = false ) { + if ( ! $refresh ) { + $cached_count = get_post_meta( $form_id, self::META_RESPONSE_COUNT, true ); + if ( '' !== $cached_count ) { + return (int) $cached_count; + } + } + + // Query actual count from feedback posts + $query = new \WP_Query( + array( + 'post_type' => Feedback::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => '_jetpack_form_id', + 'value' => $form_id, + ), + ), + ) + ); + + $count = $query->found_posts; + + // Update cache + update_post_meta( $form_id, self::META_RESPONSE_COUNT, $count ); + + return $count; + } + + /** + * Increment response count for a form. + * + * @param int $form_id Form post ID. + */ + public static function increment_response_count( $form_id ) { + $count = (int) get_post_meta( $form_id, self::META_RESPONSE_COUNT, true ); + update_post_meta( $form_id, self::META_RESPONSE_COUNT, $count + 1 ); + } + + /** + * Get all forms with optional filters. + * + * @param array $args Query arguments. + * @return array Array of WP_Post objects. + */ + public static function get_forms( $args = array() ) { + $defaults = array( + 'post_type' => self::POST_TYPE, + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => -1, + 'orderby' => 'modified', + 'order' => 'DESC', + ); + + $args = wp_parse_args( $args, $defaults ); + + $query = new \WP_Query( $args ); + return $query->posts; + } + + /** + * Delete a form and optionally its responses. + * + * @param int $form_id Form post ID. + * @param bool $delete_responses Whether to also delete form responses. + * @return bool True on success, false on failure. + */ + public static function delete_form( $form_id, $delete_responses = false ) { + if ( ! self::get_form( $form_id ) ) { + return false; + } + + if ( $delete_responses ) { + // Delete all responses for this form + $responses = get_posts( + array( + 'post_type' => Feedback::POST_TYPE, + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => '_jetpack_form_id', + 'value' => $form_id, + ), + ), + ) + ); + + foreach ( $responses as $response_id ) { + wp_delete_post( $response_id, true ); + } + } + + $result = wp_delete_post( $form_id, true ); + return false !== $result; + } + + /** + * Duplicate a form. + * + * @param int $form_id Form post ID to duplicate. + * @param string $new_title Optional. Title for the duplicated form. + * @return int|false New form ID on success, false on failure. + */ + public static function duplicate_form( $form_id, $new_title = '' ) { + $original_post = self::get_form( $form_id ); + + if ( ! $original_post ) { + return false; + } + + if ( empty( $new_title ) ) { + /* translators: %s: original form title */ + $new_title = sprintf( __( '%s (Copy)', 'jetpack-forms' ), $original_post->post_title ); + } + + // Create new post + $new_post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_title' => $new_title, + 'post_content' => $original_post->post_content, + 'post_status' => 'draft', + 'post_author' => get_current_user_id(), + ) + ); + + if ( is_wp_error( $new_post_id ) ) { + return false; + } + + // Copy meta data + $settings = self::get_form_settings( $form_id ); + $integrations = self::get_form_integrations( $form_id ); + + if ( ! empty( $settings ) ) { + self::update_form_settings( $new_post_id, $settings ); + } + + if ( ! empty( $integrations ) ) { + self::update_form_integrations( $new_post_id, $integrations ); + } + + update_post_meta( $new_post_id, self::META_VERSION, self::SCHEMA_VERSION ); + + /** + * Fires after a form is duplicated. + * + * @since 1.0.0 + * + * @param int $new_post_id New form post ID. + * @param int $form_id Original form post ID. + */ + do_action( 'jetpack_form_duplicated', $new_post_id, $form_id ); + + return $new_post_id; + } +} diff --git a/projects/packages/forms/src/dashboard/components/layout/index.tsx b/projects/packages/forms/src/dashboard/components/layout/index.tsx index b6925f4657118..b383ff2aa2eb8 100644 --- a/projects/packages/forms/src/dashboard/components/layout/index.tsx +++ b/projects/packages/forms/src/dashboard/components/layout/index.tsx @@ -53,6 +53,10 @@ const Layout = () => { name: 'responses', title: __( 'Responses', 'jetpack-forms' ), }, + { + name: 'forms', + title: __( 'Forms', 'jetpack-forms' ), + }, ...( enableIntegrationsTab ? [ { name: 'integrations', title: __( 'Integrations', 'jetpack-forms' ) } ] : [] ), @@ -76,6 +80,7 @@ const Layout = () => { }, [ location.pathname, tabs, hasFeedback ] ); const isResponsesTab = getCurrentTab() === 'responses'; + const isFormsTab = getCurrentTab() === 'forms'; const handleTabSelect = useCallback( ( tabName: string ) => { @@ -118,7 +123,7 @@ const Layout = () => { { isResponsesTab && } { isResponsesTab && isResponsesTrashView && } { isResponsesTab && isResponsesSpamView && } - { ! isResponsesTrashView && ! isResponsesSpamView && ( + { ( isFormsTab || ( ! isResponsesTrashView && ! isResponsesSpamView ) ) && ( ) } diff --git a/projects/packages/forms/src/dashboard/forms/dataviews/actions.js b/projects/packages/forms/src/dashboard/forms/dataviews/actions.js new file mode 100644 index 0000000000000..2f5b37e075522 --- /dev/null +++ b/projects/packages/forms/src/dashboard/forms/dataviews/actions.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { external, edit, copy, trash } from '@wordpress/icons'; + +/** + * View form action - opens the form in the editor. + */ +export const viewFormAction = { + id: 'view-form', + label: __( 'View', 'jetpack-forms' ), + isPrimary: true, + icon: external, + callback( items ) { + const [ item ] = items; + window.open( item.link, '_blank' ); + }, +}; + +/** + * Edit form action - opens the form in the block editor. + */ +export const editFormAction = { + id: 'edit-form', + label: __( 'Edit', 'jetpack-forms' ), + icon: edit, + callback( items ) { + const [ item ] = items; + // Navigate to the WordPress post editor for this form + const editUrl = `/wp-admin/post.php?post=${ item.id }&action=edit`; + window.location.href = editUrl; + }, +}; + +/** + * Duplicate form action - creates a copy of the form. + */ +export const duplicateFormAction = { + id: 'duplicate-form', + label: __( 'Duplicate', 'jetpack-forms' ), + icon: copy, + callback( items ) { + const [ item ] = items; + + // Create a duplicate via REST API + apiFetch( { + path: `/wp/v2/jetpack-forms/${ item.id }`, + method: 'POST', + data: { + title: `${ item.title.rendered } (Copy)`, + content: item.content.raw, + status: 'draft', + meta: { + _jetpack_form_settings: item.meta._jetpack_form_settings, + _jetpack_form_integrations: item.meta._jetpack_form_integrations, + }, + }, + } ) + .then( newForm => { + // Navigate to edit the new form + window.location.href = `/wp-admin/post.php?post=${ newForm.id }&action=edit`; + } ) + .catch( error => { + // eslint-disable-next-line no-console + console.error( 'Failed to duplicate form:', error ); + // eslint-disable-next-line no-alert + alert( __( 'Failed to duplicate form. Please try again.', 'jetpack-forms' ) ); + } ); + }, +}; + +/** + * Delete form action - moves form to trash. + */ +export const deleteFormAction = { + id: 'delete-form', + label: __( 'Move to Trash', 'jetpack-forms' ), + icon: trash, + isDestructive: true, + callback( items ) { + const [ item ] = items; + + if ( + ! window.confirm( __( 'Are you sure you want to move this form to trash?', 'jetpack-forms' ) ) + ) { + return; + } + + apiFetch( { + path: `/wp/v2/jetpack-forms/${ item.id }`, + method: 'DELETE', + } ) + .then( () => { + // Reload the page to refresh the forms list + window.location.reload(); + } ) + .catch( error => { + // eslint-disable-next-line no-console + console.error( 'Failed to delete form:', error ); + // eslint-disable-next-line no-alert + alert( __( 'Failed to delete form. Please try again.', 'jetpack-forms' ) ); + } ); + }, +}; diff --git a/projects/packages/forms/src/dashboard/forms/dataviews/index.js b/projects/packages/forms/src/dashboard/forms/dataviews/index.js new file mode 100644 index 0000000000000..cf58089aabba4 --- /dev/null +++ b/projects/packages/forms/src/dashboard/forms/dataviews/index.js @@ -0,0 +1,149 @@ +/** + * External dependencies + */ +import { ExternalLink } from '@wordpress/components'; +import { DataViews } from '@wordpress/dataviews/wp'; +import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; +import { useMemo, useCallback } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import useFormsData from '../../hooks/use-forms-data'; +import { viewFormAction, editFormAction, duplicateFormAction, deleteFormAction } from './actions'; +import { useView, defaultLayouts } from './views'; + +const EMPTY_ARRAY = []; + +/** + * Get form ID for DataViews. + * + * @param {object} item - Form item. + * @return {string} Form ID as string. + */ +const getItemId = item => { + return item.id.toString(); +}; + +/** + * The DataViews implementation for Forms. + * + * @return {import('react').JSX.Element} The DataViews component. + */ +export default function FormsView() { + const [ view, setView ] = useView(); + const dateSettings = getDateSettings(); + + // Ensure view type is always table + const tableView = { + ...view, + type: 'table', + }; + + const { forms, isLoadingForms, totalItems, totalPages } = useFormsData( { + per_page: tableView.perPage || 20, + page: tableView.page || 1, + search: tableView.search || '', + orderby: tableView.sort?.field || 'modified', + order: tableView.sort?.direction || 'desc', + } ); + + // Debug: Log the forms data + console.log( 'Forms data:', { forms, isLoadingForms, totalItems, totalPages } ); + + const paginationInfo = useMemo( + () => ( { totalItems, totalPages } ), + [ totalItems, totalPages ] + ); + + const fields = useMemo( + () => [ + { + id: 'title', + label: __( 'Form', 'jetpack-forms' ), + render: ( { item } ) => { + const title = + decodeEntities( item.title.rendered ) || __( '(Untitled)', 'jetpack-forms' ); + const editUrl = `/wp-admin/post.php?post=${ item.id }&action=edit`; + return ( + + { title } + + ); + }, + getValue: ( { item } ) => { + return decodeEntities( item.title.rendered ) || __( '(Untitled)', 'jetpack-forms' ); + }, + enableSorting: false, + enableHiding: true, + }, + { + id: 'responses', + label: __( 'Responses', 'jetpack-forms' ), + render: ( { item } ) => { + return item.meta._jetpack_form_response_count || 0; + }, + getValue: ( { item } ) => { + return item.meta._jetpack_form_response_count || 0; + }, + enableSorting: false, + }, + { + id: 'modified', + label: __( 'Last Modified', 'jetpack-forms' ), + render: ( { item } ) => { + return dateI18n( dateSettings.formats.datetime, item.modified ); + }, + getValue: ( { item } ) => { + return item.modified; + }, + enableSorting: true, + }, + { + id: 'date', + label: __( 'Created', 'jetpack-forms' ), + render: ( { item } ) => { + return dateI18n( dateSettings.formats.datetime, item.date ); + }, + getValue: ( { item } ) => { + return item.date; + }, + enableSorting: true, + }, + ], + [ dateSettings.formats.datetime ] + ); + + const actions = useMemo( + () => [ viewFormAction, editFormAction, duplicateFormAction, deleteFormAction ], + [] + ); + + const handleViewChange = useCallback( + newView => { + // Always force table view + setView( { + ...newView, + type: 'table', + } ); + }, + [ setView ] + ); + + return ( +
+ +
+ ); +} diff --git a/projects/packages/forms/src/dashboard/forms/dataviews/views.js b/projects/packages/forms/src/dashboard/forms/dataviews/views.js new file mode 100644 index 0000000000000..875204ed17b95 --- /dev/null +++ b/projects/packages/forms/src/dashboard/forms/dataviews/views.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { useState, useCallback, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +const FORMS_VIEW_CONFIG_KEY = 'jetpack-forms-dataviews-forms-view'; + +export const defaultLayouts = { + table: { + layout: { + primaryField: 'title', + styles: { + title: { + width: '40%', + }, + responses: { + width: '15%', + }, + modified: { + width: '25%', + }, + date: { + width: '20%', + }, + }, + }, + }, +}; + +/** + * Get default view configuration. + * + * @return {object} Default view configuration. + */ +const getDefaultView = () => { + return { + type: 'table', + perPage: 20, + page: 1, + sort: { + field: 'modified', + direction: 'desc', + }, + search: '', + filters: [], + hiddenFields: [], + layout: defaultLayouts.table.layout, + }; +}; + +/** + * Custom hook to manage the view state for forms DataViews. + * + * @return {Array} View state and setter function. + */ +export function useView() { + const [ view, setViewState ] = useState( () => { + const storedView = localStorage.getItem( FORMS_VIEW_CONFIG_KEY ); + if ( storedView ) { + try { + return JSON.parse( storedView ); + } catch ( e ) { + // If parsing fails, return default view + return getDefaultView(); + } + } + return getDefaultView(); + } ); + + const setView = useCallback( newView => { + setViewState( newView ); + localStorage.setItem( FORMS_VIEW_CONFIG_KEY, JSON.stringify( newView ) ); + }, [] ); + + return [ view, setView ]; +} diff --git a/projects/packages/forms/src/dashboard/forms/index.js b/projects/packages/forms/src/dashboard/forms/index.js new file mode 100644 index 0000000000000..7436fea08415f --- /dev/null +++ b/projects/packages/forms/src/dashboard/forms/index.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import FormsView from './dataviews'; +import './style.scss'; + +const Forms = () => { + return ; +}; + +export default Forms; diff --git a/projects/packages/forms/src/dashboard/forms/style.scss b/projects/packages/forms/src/dashboard/forms/style.scss new file mode 100644 index 0000000000000..c0f7a3b8f9010 --- /dev/null +++ b/projects/packages/forms/src/dashboard/forms/style.scss @@ -0,0 +1,18 @@ +.jp-forms__forms__dataviews { + flex-grow: 1; + + .dataviews-wrapper { + height: 100%; + } + + .jp-forms__form-title-link { + color: #2271b1; + text-decoration: none; + font-weight: 500; + + &:hover { + color: #135e96; + text-decoration: underline; + } + } +} diff --git a/projects/packages/forms/src/dashboard/hooks/use-forms-data.js b/projects/packages/forms/src/dashboard/hooks/use-forms-data.js new file mode 100644 index 0000000000000..0b97961cefe4c --- /dev/null +++ b/projects/packages/forms/src/dashboard/hooks/use-forms-data.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useEffect, useState } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Custom hook to fetch forms data from the REST API. + * + * @param {object} queryArgs - Query arguments for the API request. + * @return {object} Forms data and loading state. + */ +export default function useFormsData( queryArgs = {} ) { + const [ forms, setForms ] = useState( [] ); + const [ isLoadingForms, setIsLoadingForms ] = useState( true ); + const [ totalItems, setTotalItems ] = useState( 0 ); + const [ totalPages, setTotalPages ] = useState( 0 ); + + useEffect( () => { + setIsLoadingForms( true ); + + const fetchForms = async () => { + try { + const response = await apiFetch( { + path: addQueryArgs( '/wp/v2/jetpack-forms', { + ...queryArgs, + _fields: 'id,title,date,modified,link,content,meta', + } ), + parse: false, // We need to parse headers manually + } ); + + const data = await response.json(); + const total = parseInt( response.headers.get( 'X-WP-Total' ), 10 ); + const pages = parseInt( response.headers.get( 'X-WP-TotalPages' ), 10 ); + + setForms( data ); + setTotalItems( total ); + setTotalPages( pages ); + setIsLoadingForms( false ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to fetch forms:', error ); + setForms( [] ); + setTotalItems( 0 ); + setTotalPages( 0 ); + setIsLoadingForms( false ); + } + }; + + fetchForms(); + }, [ JSON.stringify( queryArgs ) ] ); + + return { + forms, + isLoadingForms, + totalItems, + totalPages, + }; +} diff --git a/projects/packages/forms/src/dashboard/index.tsx b/projects/packages/forms/src/dashboard/index.tsx index 6ed88bb051605..64c3572f43022 100644 --- a/projects/packages/forms/src/dashboard/index.tsx +++ b/projects/packages/forms/src/dashboard/index.tsx @@ -10,6 +10,7 @@ import { RouterProvider } from 'react-router/dom'; */ import About from './about'; import Layout from './components/layout'; +import Forms from './forms'; import Inbox from './inbox'; import Integrations from './integrations'; import DashboardNotices from './notices-list'; @@ -31,6 +32,10 @@ window.addEventListener( 'load', () => { path: 'responses', element: , }, + { + path: 'forms', + element: , + }, { path: 'integrations', element: ,