PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / style-customizer-v2 / BuilderSync.ts
everest-forms / src / style-customizer-v2 Last commit date
AiAssistant.tsx 2 days ago App.tsx 2 days ago BuilderSync.ts 2 days ago ControlRenderer.tsx 2 days ago HoverTip.tsx 2 days ago Popover.tsx 2 days ago PreviewBridge.ts 2 days ago PreviewPane.tsx 2 days ago constants.ts 2 days ago cssVars.ts 2 days ago globals.d.ts 2 days ago index.tsx 2 days ago panes.tsx 2 days ago store.ts 2 days ago style.scss 2 days ago types.ts 2 days ago
BuilderSync.ts
347 lines
1 /**
2 * Style Customizer v2 — Fields/Settings ↔ Style live synchronisation. Serialises the builder's
3 * current form (fields, layout, settings) to the `preview-draft` endpoint, applies any section/
4 * schema visibility the response recomputed, and reloads the preview iframe when it changes.
5 */
6 import { getActiveBridge } from './PreviewBridge';
7 import { StyleStore } from './store';
8
9 const apiFetch = ( window as any ).wp?.apiFetch;
10
11 const BUILDER_FORM_ID = 'everest-forms-builder-form';
12 const FIELD_WRAPPER_SELECTOR = '.evf-admin-field-wrapper';
13 const STYLE_PANEL_ID = 'everest-forms-panel-style';
14
15 /** Debounce for builder-change bursts (drag, typing) before a single preview refresh (ms). */
16 const CHANGE_DEBOUNCE = 500;
17
18 interface SerializedItem {
19 name: string;
20 value: string;
21 }
22
23 export class BuilderSync {
24 private store: StyleStore;
25 private stylePanel: HTMLElement | null = null;
26 private active = false;
27 private started = false;
28
29 /** Signature of the structure currently rendered in the preview (baseline = the saved form). */
30 private lastSignature: string | null = null;
31 /** A sync was requested before the bridge was ready — run it once the frame loads. */
32 private pendingSync = false;
33 /** Coalesce a burst of builder changes into one refresh. */
34 private changeTimer: ReturnType< typeof setTimeout > | null = null;
35 /** Guards against overlapping draft POST + reload cycles. */
36 private syncing = false;
37 /** Structural style-token overrides (see syncStyleToken()) waiting to go out on the next POST. */
38 private pendingStyleTokens: Record< string, unknown > = {};
39
40 private fieldObserver: MutationObserver | null = null;
41 private panelObserver: MutationObserver | null = null;
42
43 constructor( store: StyleStore ) {
44 this.store = store;
45 }
46
47 /* --------------------------------------------------------------------- *
48 * Lifecycle
49 * --------------------------------------------------------------------- */
50
51 start() {
52 if ( this.started ) {
53 return;
54 }
55 this.started = true;
56
57 this.stylePanel = document.getElementById( STYLE_PANEL_ID );
58 this.lastSignature = this.serialize().signature;
59 this.active = this.isStyleTabActive();
60
61 if ( this.stylePanel ) {
62 this.panelObserver = new MutationObserver( () => this.onPanelToggle() );
63 this.panelObserver.observe( this.stylePanel, { attributes: true, attributeFilter: [ 'class' ] } );
64 }
65
66 document.addEventListener( 'input', this.onBuilderInput, true );
67 document.addEventListener( 'change', this.onBuilderInput, true );
68
69 const wrapper = document.querySelector( FIELD_WRAPPER_SELECTOR );
70 if ( wrapper ) {
71 this.fieldObserver = new MutationObserver( () => this.onFieldMutation() );
72 this.fieldObserver.observe( wrapper, { childList: true, subtree: true } );
73 }
74
75 const jq = ( window as any ).jQuery;
76 if ( jq ) {
77 jq( document ).on( 'everest_forms_save_data.evfscv2', this.onSave );
78 }
79 }
80
81 stop() {
82 this.started = false;
83 if ( this.changeTimer ) {
84 clearTimeout( this.changeTimer );
85 this.changeTimer = null;
86 }
87 if ( this.panelObserver ) {
88 this.panelObserver.disconnect();
89 this.panelObserver = null;
90 }
91 if ( this.fieldObserver ) {
92 this.fieldObserver.disconnect();
93 this.fieldObserver = null;
94 }
95 document.removeEventListener( 'input', this.onBuilderInput, true );
96 document.removeEventListener( 'change', this.onBuilderInput, true );
97 const jq = ( window as any ).jQuery;
98 if ( jq ) {
99 jq( document ).off( 'everest_forms_save_data.evfscv2', this.onSave );
100 }
101 }
102
103 /** Called by the preview bridge once a (re)load has fully bridged — flush any pending sync. */
104 onBridgeReady() {
105 if ( this.pendingSync ) {
106 this.pendingSync = false;
107 this.syncPreview( false );
108 }
109 }
110
111 /* --------------------------------------------------------------------- *
112 * Triggers
113 * --------------------------------------------------------------------- */
114
115 private isStyleTabActive(): boolean {
116 return !! this.stylePanel && this.stylePanel.classList.contains( 'active' );
117 }
118
119 private onPanelToggle() {
120 const nowActive = this.isStyleTabActive();
121 if ( nowActive === this.active ) {
122 return;
123 }
124 this.active = nowActive;
125 if ( nowActive ) {
126 this.syncPreview( false );
127 }
128 }
129
130 private onBuilderInput = ( e: Event ) => {
131 if ( ! this.active ) {
132 return;
133 }
134 const target = e.target as HTMLElement | null;
135 if ( ! target || ! target.closest ) {
136 return;
137 }
138 // The Style panel's own controls live inside the builder form and fire input/change too;
139 // ignore those, they change styles, not field structure.
140 if ( target.closest( '#' + STYLE_PANEL_ID ) ) {
141 return;
142 }
143 if ( target.closest( '#' + BUILDER_FORM_ID ) ) {
144 this.scheduleSync();
145 }
146 };
147
148 private onFieldMutation() {
149 if ( ! this.active ) {
150 return;
151 }
152 this.scheduleSync();
153 }
154
155 private onSave = () => {
156 if ( this.active ) {
157 this.syncPreview( true );
158 } else {
159 this.lastSignature = null; // force a refresh on the next Style-tab open.
160 }
161 };
162
163 /**
164 * Push a "structural" style-token value (one with no CSS-variable fast path — e.g.
165 * Pagination's indicator TYPE, whose themes render genuinely different markup, not just a
166 * class/variable — see PreviewBridge.ts's applyKeys()) to the server and reload the preview
167 * once it's re-rendered with it. Queued on the instance so it survives a busy/not-ready sync
168 * being retried, and rides along with whatever POST goes out next.
169 */
170 syncStyleToken( key: string, value: unknown ) {
171 this.pendingStyleTokens[ key ] = value;
172 this.syncPreview( false );
173 }
174
175 private scheduleSync() {
176 if ( this.changeTimer ) {
177 clearTimeout( this.changeTimer );
178 }
179 this.changeTimer = setTimeout( () => {
180 this.changeTimer = null;
181 this.syncPreview( false );
182 }, CHANGE_DEBOUNCE );
183 }
184
185 /* --------------------------------------------------------------------- *
186 * The sync itself
187 * --------------------------------------------------------------------- */
188
189 /**
190 * Push the builder's current structure to the preview and reload the iframe.
191 *
192 * @param force Bypass the signature short-circuit (e.g. after save).
193 */
194 private async syncPreview( force: boolean ) {
195 if ( this.syncing ) {
196 this.pendingSync = true;
197 return;
198 }
199
200 const bridge = getActiveBridge();
201 if ( ! bridge || ! bridge.isReady() ) {
202 this.pendingSync = true;
203 return;
204 }
205
206 const { json, signature } = this.serialize();
207 if ( ! json ) {
208 return;
209 }
210 const styleTokens = this.pendingStyleTokens;
211 const hasStyleTokens = Object.keys( styleTokens ).length > 0;
212 if ( ! force && ! hasStyleTokens && signature === this.lastSignature ) {
213 return;
214 }
215
216 if ( ! apiFetch ) {
217 return;
218 }
219
220 this.syncing = true;
221 try {
222 const data: Record< string, unknown > = { form_data: json, session: this.store.settings.previewSession };
223 if ( hasStyleTokens ) {
224 data.style_tokens = styleTokens;
225 }
226 const res = await apiFetch( {
227 path: `${ this.store.settings.restBase }/${ this.store.settings.formId }/preview-draft`,
228 method: 'POST',
229 data,
230 } );
231 this.lastSignature = signature;
232 this.pendingStyleTokens = {};
233 // The server recomputes section/token visibility against this same draft (e.g. a
234 // conditional section a Pro addon just became eligible/ineligible for) — apply it
235 // before the reload so the panel's sidebar is in sync with what the new iframe renders.
236 if ( res && res.sections && Array.isArray( res.schema ) ) {
237 this.store.setVisibility( res.sections, res.schema );
238 }
239 getActiveBridge()?.reload();
240 } catch ( err ) {
241 // eslint-disable-next-line no-console
242 if ( ( window as any ).console ) {
243 // eslint-disable-next-line no-console
244 console.warn( 'EVF Style: preview sync failed', err );
245 }
246 } finally {
247 this.syncing = false;
248 if ( this.pendingSync ) {
249 this.pendingSync = false;
250 this.scheduleSync();
251 }
252 }
253 }
254
255 /* --------------------------------------------------------------------- *
256 * Serialisation (mirrors the builder's own save payload)
257 * --------------------------------------------------------------------- */
258
259 /** Serialises the builder form the same way the real save AJAX does. */
260 private serialize(): { json: string; signature: string } {
261 const jq = ( window as any ).jQuery;
262 if ( ! jq ) {
263 return { json: '', signature: '' };
264 }
265 const form = jq( '#' + BUILDER_FORM_ID );
266 if ( ! form.length ) {
267 return { json: '', signature: '' };
268 }
269
270 let formData: SerializedItem[] = form.serializeArray();
271
272 // Let addons rewrite/append to the payload the same way the real save AJAX does (see
273 // form-builder.js's own save handler) — Multi-Part, for one, computes its row→part
274 // mapping (`multi_part[part_N][rows][…]`) ENTIRELY inside this handler; it's never a
275 // static <input>. Skipping it left the draft with a part's `id` but no `rows`, which
276 // made the frontend renderer never close the first part's wrapper — every field from
277 // every part rendered flattened onto "Step 1" in the live preview until an actual save
278 // (which does fire this event) replaced the draft with a correctly-shaped saved record.
279 if ( form.triggerHandler( 'everest_forms_process_ajax_data', [ form, formData ] ) ) {
280 formData = form.triggerHandler( 'everest_forms_process_ajax_data', [ form, formData ] );
281 }
282
283 const structure = this.getStructure( jq );
284 const all = formData.concat( structure );
285
286 return { json: JSON.stringify( all ), signature: this.signatureFrom( all ) };
287 }
288
289 /** Replicate the builder's `getStructure()` (field layout: rows → grids → ordered field ids). */
290 private getStructure( jq: any ): SerializedItem[] {
291 const structure: SerializedItem[] = [];
292 const wrapper = jq( FIELD_WRAPPER_SELECTOR );
293
294 wrapper.find( '.evf-admin-row' ).each( function ( this: HTMLElement ) {
295 const $row = jq( this );
296 const rowId = $row.attr( 'data-row-id' );
297
298 $row.find( '.evf-admin-grid' ).each( function ( this: HTMLElement ) {
299 const $grid = jq( this );
300 const gridId = $grid.attr( 'data-grid-id' );
301 const $fields = $grid.find( '.everest-forms-field' );
302
303 let index = 0;
304 $fields.each( function ( this: HTMLElement ) {
305 structure.push( {
306 name: `structure[row_${ rowId }][grid_${ gridId }][${ index }]`,
307 value: jq( this ).attr( 'data-field-id' ),
308 } );
309 index++;
310 } );
311
312 if ( $fields.length < 1 ) {
313 structure.push( { name: `structure[row_${ rowId }][grid_${ gridId }]`, value: '' } );
314 }
315 } );
316 } );
317
318 return structure;
319 }
320
321 /**
322 * Signature of only the render-relevant inputs: fields, layout, AND settings — settings are
323 * included because a Pro addon's conditional section/group (e.g. Multi-Part's "Enable
324 * Multi-Part form" toggle gating the Pagination section) can depend on one, not just on
325 * fields/structure.
326 */
327 private signatureFrom( all: SerializedItem[] ): string {
328 const relevant = all.filter(
329 ( i ) =>
330 i.name.indexOf( 'form_fields[' ) === 0 ||
331 i.name.indexOf( 'structure[' ) === 0 ||
332 i.name.indexOf( 'settings[' ) === 0
333 );
334 return JSON.stringify( relevant );
335 }
336 }
337
338 let active: BuilderSync | null = null;
339
340 export function setActiveSync( sync: BuilderSync | null ) {
341 active = sync;
342 }
343
344 export function getActiveSync(): BuilderSync | null {
345 return active;
346 }
347