PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.3
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.3
4.0.3 4.0.2 4.0.1 4.0.0 3.16.6 3.16.5 3.16.4 3.16.3 3.16.2 3.16.1 3.16.0 3.15.9 3.9.9 3.9.5 3.9.6 3.9.7 3.9.8 1.1.7 1.1.8 1.1.9 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 341 releases
profile-builder / form-builder / blocks / src / lib / persistFieldAttributes.js

persistFieldAttributes.js in User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor 4.0.3, at form-builder/blocks/src/lib/persistFieldAttributes.js

271 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Shared per-edit attribute persister for canvas + VirtualFieldEdit.
3 * Identity is `(scope, id)` — top vs Repeater sub scopes never share timers.
4 * Diff is against full `lastSent`, not the previous patch.
5 */
6
7 import apiFetch from '@wordpress/api-fetch';
8 import { dispatch } from '@wordpress/data';
9 import { __ } from '@wordpress/i18n';
10
11 const DEBOUNCE_MS = 500;
12 const MAX_PUT_RETRIES = 5;
13
14 // Don't echo `conditional-logic` write-backs — server prunes empty rules and
15 // that would wipe a half-filled rule the user is still editing.
16 const WRITE_BACK_EXCLUDED_KEYS = new Set( [ 'conditional-logic' ] );
17
18 const debounceTimers = new Map(); // key → timeout handle
19 const lastSent = new Map(); // key → { ...full attribute snapshot }
20 const pendingPatches = new Map(); // key → accumulated patch awaiting the next debounce flush
21 const retryCounts = new Map(); // key → consecutive failed-PUT attempts
22 const pendingMeta = new Map(); // key → { scope, id, fieldType, clientId } for an unload flush
23
24 /** Surface a persist failure (VirtualFieldEdit has no form-save fallback). */
25 function noticePersistFailure() {
26 const notices = dispatch( 'core/notices' );
27 if ( ! notices || typeof notices.createErrorNotice !== 'function' ) return;
28 notices.createErrorNotice(
29 __(
30 'Profile Builder could not save one or more field settings. Check your connection, then re-apply the change or save the form to retry.',
31 'profile-builder'
32 ),
33 { id: 'wppb-fb-persist-failed', isDismissible: true }
34 );
35 }
36
37 /** Flush queued patches on pagehide (debounce would lose them on navigate). */
38 export function flushAllPendingPersists() {
39 for ( const key of Array.from( pendingPatches.keys() ) ) {
40 const patch = pendingPatches.get( key );
41 const meta = pendingMeta.get( key );
42 pendingPatches.delete( key );
43 if ( debounceTimers.has( key ) ) {
44 clearTimeout( debounceTimers.get( key ) );
45 debounceTimers.delete( key );
46 }
47 if ( ! meta || ! patch || Object.keys( patch ).length === 0 ) continue;
48 flushPut( { ...meta, patch }, true );
49 }
50 }
51
52 let unloadFlushBound = false;
53 function bindUnloadFlush() {
54 if ( unloadFlushBound || typeof window === 'undefined' ) return;
55 unloadFlushBound = true;
56 window.addEventListener( 'pagehide', flushAllPendingPersists );
57 }
58
59 let snapshotSync = null;
60 export function registerSnapshotSync( fn ) {
61 snapshotSync = fn;
62 return () => { if ( snapshotSync === fn ) snapshotSync = null; };
63 }
64
65 const writeBackHandlers = new Map();
66 export function registerWriteBack( clientId, fn ) {
67 writeBackHandlers.set( clientId, fn );
68 return () => { writeBackHandlers.delete( clientId ); };
69 }
70
71 function keyFor( scope, id ) {
72 return scope + ':' + id;
73 }
74
75 /**
76 * Capture the initial attribute state of a block without scheduling a PUT.
77 * Called by the mirror's first walk after editor mount so the diff baseline
78 * matches what the server already has.
79 */
80 export function captureInitial( scope, id, attributes ) {
81 lastSent.set( keyFor( scope, id ), { ...attributes } );
82 }
83
84 /**
85 * Forget the cached state for a key. Called when a block is removed from
86 * canvas so its identity can be reused without false-positive diffs if the
87 * id is later assigned to a different block.
88 */
89 export function forget( scope, id ) {
90 const key = keyFor( scope, id );
91 if ( debounceTimers.has( key ) ) {
92 clearTimeout( debounceTimers.get( key ) );
93 debounceTimers.delete( key );
94 }
95 lastSent.delete( key );
96 pendingPatches.delete( key );
97 retryCounts.delete( key );
98 pendingMeta.delete( key );
99 }
100
101 /** Arm debounce/retry timer that drains pendingPatches into flushPut. */
102 function armFlushTimer( key, meta, delay ) {
103 bindUnloadFlush();
104 pendingMeta.set( key, { scope: meta.scope, id: meta.id, fieldType: meta.fieldType, clientId: meta.clientId } );
105 if ( debounceTimers.has( key ) ) clearTimeout( debounceTimers.get( key ) );
106 debounceTimers.set( key, setTimeout( () => {
107 debounceTimers.delete( key );
108 const flushPatch = pendingPatches.get( key );
109 pendingPatches.delete( key );
110 if ( ! flushPatch || Object.keys( flushPatch ).length === 0 ) return;
111 flushPut( { ...meta, patch: flushPatch } );
112 }, delay ) );
113 }
114
115 /**
116 * Move persister state when a key migrates (Repeater meta-name rename).
117 * Do not `forget` — that drops pending patches and resets the baseline.
118 */
119 export function migrate( fromScope, fromId, toMeta ) {
120 const fromKey = keyFor( fromScope, fromId );
121 const toKey = keyFor( toMeta.scope, toMeta.id );
122 if ( fromKey === toKey ) return;
123
124 if ( lastSent.has( fromKey ) ) {
125 lastSent.set( toKey, lastSent.get( fromKey ) );
126 lastSent.delete( fromKey );
127 }
128 if ( pendingPatches.has( fromKey ) ) {
129 const carried = pendingPatches.get( fromKey );
130 const existing = pendingPatches.get( toKey ) || {};
131 pendingPatches.set( toKey, { ...carried, ...existing } );
132 pendingPatches.delete( fromKey );
133 }
134 if ( retryCounts.has( fromKey ) ) {
135 retryCounts.set( toKey, retryCounts.get( fromKey ) );
136 retryCounts.delete( fromKey );
137 }
138 if ( debounceTimers.has( fromKey ) ) {
139 clearTimeout( debounceTimers.get( fromKey ) );
140 debounceTimers.delete( fromKey );
141 }
142
143 const pend = pendingPatches.get( toKey );
144 if ( pend && Object.keys( pend ).length > 0 ) {
145 armFlushTimer( toKey, toMeta, DEBOUNCE_MS );
146 }
147 }
148
149 /** Schedule a debounced REST PUT for an attribute change. */
150 export function scheduleAttributePersist( { scope, id, fieldType, clientId, attributes } ) {
151 if ( ! id || id <= 0 ) return;
152 if ( ! scope || ! attributes ) return;
153
154 const key = keyFor( scope, id );
155 const prev = lastSent.get( key );
156
157 // First time we see this id at this scope: capture baseline, no PUT.
158 if ( ! prev ) {
159 lastSent.set( key, { ...attributes } );
160 return;
161 }
162
163 // Compute the patch.
164 const patch = {};
165 for ( const k of Object.keys( attributes ) ) {
166 if ( k === 'id' || k === 'field' ) continue;
167 if ( attributes[ k ] !== prev[ k ] ) {
168 patch[ k ] = attributes[ k ];
169 }
170 }
171 if ( Object.keys( patch ).length === 0 ) return;
172
173 // Advance lastSent so a fast follow-up diffs against the in-flight value.
174 lastSent.set( key, { ...prev, ...patch } );
175
176 // Merge into pendingPatches; timer flush sends one PUT.
177 const pending = pendingPatches.get( key ) || {};
178 Object.assign( pending, patch );
179 pendingPatches.set( key, pending );
180
181 // A fresh edit means we're out of failure-retry backoff — full budget again.
182 retryCounts.delete( key );
183
184 armFlushTimer( key, { scope, id, fieldType, clientId }, DEBOUNCE_MS );
185 }
186
187 function flushPut( { scope, id, fieldType, clientId, patch }, keepalive = false ) {
188 const key = keyFor( scope, id );
189 const path = scope === 'top'
190 ? `/wppb/v1/existing-fields/${ id }`
191 : `/wppb/v1/existing-fields/sub/${ encodeURIComponent( scope.slice( 4 ) ) }/${ id }`;
192
193 const body = { attributes: patch };
194 if ( fieldType ) body.field = fieldType;
195
196 const request = { path, method: 'PUT', data: body };
197 // Unload flush: apiFetch forwards unknown options to window.fetch, so this
198 // lets the request survive the document being torn down.
199 if ( keepalive ) request.keepalive = true;
200
201 apiFetch( request )
202 .then( ( response ) => {
203 if ( ! response || ! response.field ) return;
204
205 const canonical = response.field.attributes || {};
206 const sentPatch = patch;
207
208 // Write back only keys the server changed that the user hasn't typed past.
209 const sanitizedDiff = {};
210 for ( const k of Object.keys( sentPatch ) ) {
211 if ( WRITE_BACK_EXCLUDED_KEYS.has( k ) ) continue;
212 if ( canonical[ k ] !== undefined && canonical[ k ] !== sentPatch[ k ] ) {
213 sanitizedDiff[ k ] = canonical[ k ];
214 }
215 }
216 // Also surface meta-name even when not in the sent patch — the
217 // server might have auto-generated it on first upsert.
218 if (
219 response.field.metaName !== undefined &&
220 sentPatch[ 'meta-name' ] === undefined &&
221 ( ! lastSent.get( key ) || lastSent.get( key )[ 'meta-name' ] !== response.field.metaName )
222 ) {
223 sanitizedDiff[ 'meta-name' ] = response.field.metaName;
224 }
225
226 // This PUT succeeded, so clear any failure-retry state for the key.
227 retryCounts.delete( key );
228
229 // Advance lastSent to the server's canonical state for every
230 // key it touched. Keeps future diffs honest.
231 const merged = { ...( lastSent.get( key ) || {} ), ...canonical };
232 if ( response.field.metaName !== undefined ) merged[ 'meta-name' ] = response.field.metaName;
233 // Re-queue edits that landed during the in-flight PUT.
234 const pendingNow = pendingPatches.get( key );
235 if ( pendingNow ) Object.assign( merged, pendingNow );
236 lastSent.set( key, merged );
237
238 if ( Object.keys( sanitizedDiff ).length > 0 ) {
239 const writeBack = writeBackHandlers.get( clientId );
240 if ( writeBack ) writeBack( sanitizedDiff, sentPatch );
241 }
242
243 if ( snapshotSync ) snapshotSync( response.field, scope );
244 } )
245 .catch( ( err ) => {
246 // eslint-disable-next-line no-console
247 console.warn( '[wppb-fb] attribute persist failed', { path, patch, err } );
248
249 // On failure, roll optimistic lastSent back for the failed keys.
250 const attempts = ( retryCounts.get( key ) || 0 ) + 1;
251 if ( attempts > MAX_PUT_RETRIES ) {
252 // Give up after retries: restore lastSent for failed keys and notice.
253 const baseline = lastSent.get( key );
254 if ( baseline ) {
255 for ( const k of Object.keys( patch ) ) delete baseline[ k ];
256 lastSent.set( key, baseline );
257 }
258 retryCounts.delete( key );
259 noticePersistFailure();
260 return;
261 }
262 retryCounts.set( key, attempts );
263
264 // Merge the failed patch UNDER anything queued since (a newer edit's
265 // value for the same key must win), then re-arm.
266 const requeued = pendingPatches.get( key ) || {};
267 pendingPatches.set( key, { ...patch, ...requeued } );
268 armFlushTimer( key, { scope, id, fieldType, clientId }, DEBOUNCE_MS * attempts );
269 } );
270 }
271