PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.1.5
Tableberg – Simple Gutenberg Table Block v1.1.5
1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 trunk 0.0.2 0.2.1 0.3.2 0.3.3 0.4.1 0.5.0 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 All 42 releases
tableberg / src / components / DynamicDataPanel / index.tsx

index.tsx in Tableberg – Simple Gutenberg Table Block 1.1.5, at src/components/DynamicDataPanel/index.tsx

671 lines 22.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __ } from "@wordpress/i18n";
2 import {
3 PanelBody,
4 TextControl,
5 Button,
6 Spinner,
7 SelectControl,
8 } from "@wordpress/components";
9 import { useState, useEffect, useRef, useMemo, RefObject } from "react";
10 import { useSelect } from "@wordpress/data";
11 import { useQuery } from "@tanstack/react-query";
12 import { store as editorStore } from "@wordpress/editor";
13
14 import {
15 BindingSource,
16 ElementBindings,
17 BindableAttribute,
18 } from "../../dynamic-data/types";
19 import { postFieldKeys } from "../../dynamic-data/sources";
20 import {
21 useDynamicData,
22 fetchAvailableMetaKeys,
23 } from "../../dynamic-data/hooks/useDynamicData";
24 import { getElementBindableAttributes } from "../../elements/bindable-attributes";
25 import { useTableStore } from "../../store";
26
27 interface DynamicDataPanelProps {
28 elementType: string;
29 bindings: ElementBindings | undefined;
30 postId?: number;
31 }
32
33 export function DynamicDataPanel({
34 elementType,
35 bindings = {},
36 postId: propPostId,
37 }: DynamicDataPanelProps) {
38 const [isAdding, setIsAdding] = useState(false);
39 const bindingDefinitions = useTableStore(state => state.bindings);
40 const updateSelectedElementBindings = useTableStore(
41 state => state.updateSelectedElementBindings
42 );
43 const createBindingDefinition = useTableStore(
44 state => state.createBindingDefinition
45 );
46 const updateBindingDefinition = useTableStore(
47 state => state.updateBindingDefinition
48 );
49 const removeBindingDefinition = useTableStore(
50 state => state.removeBindingDefinition
51 );
52
53 const effectivePostId = useSelect(
54 select => {
55 if (propPostId) {
56 return propPostId;
57 }
58 const editorSelect = select(editorStore) as {
59 getCurrentPostId?: () => number | undefined;
60 };
61 return editorSelect.getCurrentPostId?.() ?? undefined;
62 },
63 [propPostId]
64 );
65
66 const bindableAttributes = getElementBindableAttributes(elementType);
67
68 if (bindableAttributes.length === 0) {
69 return;
70 }
71
72 const boundAttributes = bindableAttributes.filter(
73 attr =>
74 attr.path in bindings && !!bindingDefinitions[bindings[attr.path]]
75 );
76 const unboundAttributes = bindableAttributes.filter(
77 attr => !(attr.path in bindings)
78 );
79
80 const handleAddBinding = (path: string, binding: BindingSource) => {
81 const bindingId = createBindingDefinition(binding);
82 const newBindings = {
83 ...bindings,
84 [path]: bindingId,
85 };
86
87 updateSelectedElementBindings(newBindings);
88 };
89
90 const handleUpdateBinding = (
91 previousPath: string,
92 nextPath: string,
93 bindingId: string,
94 binding: BindingSource
95 ) => {
96 updateBindingDefinition(bindingId, binding);
97
98 const nextBindings = { ...bindings };
99 if (previousPath !== nextPath) {
100 delete nextBindings[previousPath];
101 }
102 nextBindings[nextPath] = bindingId;
103 updateSelectedElementBindings(nextBindings);
104 };
105
106 const handleRemoveBinding = (path: string, bindingId: string) => {
107 const newBindings = { ...bindings };
108 delete newBindings[path];
109
110 updateSelectedElementBindings(
111 Object.keys(newBindings).length > 0 ? newBindings : undefined
112 );
113 removeBindingDefinition(bindingId);
114 };
115
116 return (
117 <PanelBody
118 title={__("Dynamic Data", "tableberg")}
119 initialOpen={boundAttributes.length > 0}
120 >
121 {boundAttributes.map(attr => {
122 const bindingId = bindings[attr.path];
123 const binding = bindingDefinitions[bindingId];
124
125 if (!bindingId || !binding) {
126 return null;
127 }
128
129 return (
130 <AttributeBinding
131 key={attr.path}
132 attribute={attr}
133 binding={binding}
134 bindableAttributes={bindableAttributes}
135 boundPaths={boundAttributes.map(a => a.path)}
136 onBindingChange={(newPath, newBinding) => {
137 handleUpdateBinding(
138 attr.path,
139 newPath,
140 bindingId,
141 newBinding
142 );
143 }}
144 onRemove={() =>
145 handleRemoveBinding(attr.path, bindingId)
146 }
147 postId={effectivePostId}
148 />
149 );
150 })}
151
152 {isAdding && (
153 <NewBindingForm
154 unboundAttributes={unboundAttributes}
155 onApply={(path, binding) => {
156 handleAddBinding(path, binding);
157 setIsAdding(false);
158 }}
159 onCancel={() => setIsAdding(false)}
160 postId={effectivePostId}
161 />
162 )}
163
164 {!isAdding && unboundAttributes.length > 0 && (
165 <div className="tableberg-dynamic-data__add">
166 <Button
167 variant="secondary"
168 onClick={() => setIsAdding(true)}
169 >
170 {__("Add Binding", "tableberg")}
171 </Button>
172 </div>
173 )}
174 </PanelBody>
175 );
176 }
177
178 interface NewBindingFormProps {
179 unboundAttributes: BindableAttribute[];
180 onApply: (path: string, binding: BindingSource) => void;
181 onCancel: () => void;
182 postId?: number;
183 }
184
185 function BindingFormFields({
186 selectedPath,
187 setSelectedPath,
188 pathOptions,
189 keyValue,
190 setKeyValue,
191 customPostId,
192 setCustomPostId,
193 fallback,
194 setFallback,
195 submitLabel,
196 cancelLabel,
197 onCancel,
198 showRefresh,
199 onRefresh,
200 refreshLabel,
201 isLoadingKeys,
202 hasSuggestions,
203 suggestions,
204 isSuggestionsOpen,
205 setIsSuggestionsOpen,
206 fieldInputRef,
207 previewValue,
208 isDestructiveRemove,
209 onRemove,
210 }: {
211 selectedPath: string;
212 setSelectedPath: (value: string) => void;
213 pathOptions: Array<{ value: string; label: string }>;
214 keyValue: string;
215 setKeyValue: (value: string) => void;
216 customPostId: string;
217 setCustomPostId: (value: string) => void;
218 fallback: string;
219 setFallback: (value: string) => void;
220 submitLabel: string;
221 cancelLabel?: string;
222 onCancel?: () => void;
223 showRefresh?: boolean;
224 onRefresh?: () => void;
225 refreshLabel?: string;
226 isLoadingKeys: boolean;
227 hasSuggestions: boolean;
228 suggestions: {
229 postFields: Array<{ key: string; label?: string }>;
230 metaKeys: Array<{ key: string; label?: string }>;
231 };
232 isSuggestionsOpen: boolean;
233 setIsSuggestionsOpen: (value: boolean) => void;
234 fieldInputRef: RefObject<HTMLDivElement | null>;
235 previewValue?: string | null;
236 isDestructiveRemove?: boolean;
237 onRemove?: () => void;
238 }) {
239 return (
240 <>
241 <SelectControl
242 label={__("Attribute", "tableberg")}
243 value={selectedPath}
244 onChange={setSelectedPath}
245 options={pathOptions}
246 />
247
248 <TextControl
249 label={__("Post ID (optional)", "tableberg")}
250 value={customPostId}
251 onChange={setCustomPostId}
252 placeholder={__("Current post", "tableberg")}
253 type="number"
254 help={__("Leave empty to use the current post", "tableberg")}
255 />
256
257 <div
258 ref={fieldInputRef as RefObject<HTMLDivElement>}
259 className="tableberg-dynamic-data-row__field-autocomplete"
260 >
261 <TextControl
262 label={__("Field", "tableberg")}
263 value={keyValue}
264 onChange={value => {
265 setKeyValue(value);
266 setIsSuggestionsOpen(true);
267 }}
268 onFocus={() => setIsSuggestionsOpen(true)}
269 placeholder={__("Search or type field key...", "tableberg")}
270 autoComplete="off"
271 />
272 {isSuggestionsOpen && !isLoadingKeys && hasSuggestions && (
273 <div className="tableberg-dynamic-data-row__suggestions">
274 {suggestions.postFields.length > 0 && (
275 <>
276 <div className="tableberg-dynamic-data-row__suggestions-group">
277 {__("Post Fields", "tableberg")}
278 </div>
279 {suggestions.postFields.map(item => (
280 <button
281 key={item.key}
282 type="button"
283 className={`tableberg-dynamic-data-row__suggestion${keyValue === item.key ? " is-selected" : ""}`}
284 onClick={() => {
285 setKeyValue(item.key);
286 setIsSuggestionsOpen(false);
287 }}
288 >
289 <span className="tableberg-dynamic-data-row__suggestion-label">
290 {item.label ?? item.key}
291 </span>
292 <span className="tableberg-dynamic-data-row__suggestion-key">
293 {item.key}
294 </span>
295 </button>
296 ))}
297 </>
298 )}
299 {suggestions.metaKeys.length > 0 && (
300 <>
301 <div className="tableberg-dynamic-data-row__suggestions-group">
302 {__("Custom Fields", "tableberg")}
303 </div>
304 {suggestions.metaKeys.map(item => (
305 <button
306 key={item.key}
307 type="button"
308 className={`tableberg-dynamic-data-row__suggestion${keyValue === item.key ? " is-selected" : ""}`}
309 onClick={() => {
310 setKeyValue(item.key);
311 setIsSuggestionsOpen(false);
312 }}
313 >
314 <span className="tableberg-dynamic-data-row__suggestion-label">
315 {item.label ?? item.key}
316 </span>
317 {item.label && (
318 <span className="tableberg-dynamic-data-row__suggestion-key">
319 {item.key}
320 </span>
321 )}
322 </button>
323 ))}
324 </>
325 )}
326 </div>
327 )}
328 {isSuggestionsOpen && isLoadingKeys && <Spinner />}
329 </div>
330
331 <TextControl
332 label={__("Fallback Value", "tableberg")}
333 value={fallback}
334 onChange={setFallback}
335 placeholder={__("Value if field is empty", "tableberg")}
336 />
337
338 {previewValue !== undefined && previewValue !== null && (
339 <div className="tableberg-dynamic-data-row__preview">
340 <strong>{__("Preview:", "tableberg")}</strong>{" "}
341 <span>{String(previewValue)}</span>
342 </div>
343 )}
344
345 <div className="tableberg-dynamic-data-row__actions">
346 {showRefresh ? (
347 <Button
348 variant="secondary"
349 type="button"
350 icon="update"
351 onClick={onRefresh}
352 >
353 {refreshLabel || __("Refresh", "tableberg")}
354 </Button>
355 ) : (
356 <Button
357 variant="primary"
358 type="submit"
359 disabled={!selectedPath || !keyValue}
360 >
361 {submitLabel}
362 </Button>
363 )}
364 {onRemove && (
365 <Button
366 variant="link"
367 type="button"
368 isDestructive={isDestructiveRemove}
369 onClick={onRemove}
370 >
371 {__("Remove", "tableberg")}
372 </Button>
373 )}
374 {onCancel && (
375 <Button variant="link" type="button" onClick={onCancel}>
376 {cancelLabel || __("Cancel", "tableberg")}
377 </Button>
378 )}
379 </div>
380 </>
381 );
382 }
383
384 function useBindingFormState({
385 keyValue,
386 postId,
387 }: {
388 keyValue: string;
389 postId?: number;
390 }) {
391 const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false);
392 const fieldInputRef = useRef<HTMLDivElement>(null);
393
394 const { data: metaKeys = [], isLoading: isLoadingKeys } = useQuery({
395 queryKey: ["metaKeys", postId],
396 queryFn: () => fetchAvailableMetaKeys(postId),
397 staleTime: Infinity,
398 });
399
400 useEffect(() => {
401 const handleClickOutside = (event: MouseEvent) => {
402 if (
403 fieldInputRef.current &&
404 !fieldInputRef.current.contains(event.target as Node)
405 ) {
406 setIsSuggestionsOpen(false);
407 }
408 };
409
410 document.addEventListener("mousedown", handleClickOutside);
411 return () =>
412 document.removeEventListener("mousedown", handleClickOutside);
413 }, []);
414
415 const suggestions = useMemo(() => {
416 const query = keyValue.toLowerCase();
417
418 return {
419 postFields: postFieldKeys.filter(
420 item =>
421 item.key.toLowerCase().includes(query) ||
422 (item.label && item.label.toLowerCase().includes(query))
423 ),
424 metaKeys: metaKeys.filter(
425 item =>
426 item.key.toLowerCase().includes(query) ||
427 (item.label && item.label.toLowerCase().includes(query))
428 ),
429 };
430 }, [keyValue, metaKeys]);
431
432 return {
433 fieldInputRef,
434 isSuggestionsOpen,
435 setIsSuggestionsOpen,
436 suggestions,
437 hasSuggestions:
438 suggestions.postFields.length > 0 ||
439 suggestions.metaKeys.length > 0,
440 isLoadingKeys,
441 };
442 }
443
444 function createBindingSource(
445 keyValue: string,
446 customPostId: string,
447 fallback: string
448 ): BindingSource {
449 const binding: BindingSource = { key: keyValue };
450
451 if (customPostId) {
452 const parsedId = parseInt(customPostId, 10);
453 if (!isNaN(parsedId) && parsedId > 0) {
454 binding.postId = parsedId;
455 }
456 }
457
458 if (fallback) {
459 binding.fallback = fallback;
460 }
461
462 return binding;
463 }
464
465 function NewBindingForm({
466 unboundAttributes,
467 onApply,
468 onCancel,
469 postId,
470 }: NewBindingFormProps) {
471 const [selectedPath, setSelectedPath] = useState("");
472 const [keyValue, setKeyValue] = useState("");
473 const [customPostId, setCustomPostId] = useState("");
474 const [fallback, setFallback] = useState("");
475
476 const metaPostId = customPostId
477 ? parseInt(customPostId, 10) || undefined
478 : postId;
479
480 const {
481 fieldInputRef,
482 isSuggestionsOpen,
483 setIsSuggestionsOpen,
484 suggestions,
485 hasSuggestions,
486 isLoadingKeys,
487 } = useBindingFormState({
488 keyValue,
489 postId: metaPostId,
490 });
491
492 return (
493 <div className="tableberg-dynamic-data-row">
494 <form
495 className="tableberg-dynamic-data-row__config"
496 onSubmit={event => {
497 event.preventDefault();
498 if (!selectedPath || !keyValue) {
499 return;
500 }
501 onApply(
502 selectedPath,
503 createBindingSource(keyValue, customPostId, fallback)
504 );
505 }}
506 >
507 <BindingFormFields
508 selectedPath={selectedPath}
509 setSelectedPath={setSelectedPath}
510 pathOptions={[
511 {
512 value: "",
513 label: __("Select attribute...", "tableberg"),
514 },
515 ...unboundAttributes.map(attribute => ({
516 value: attribute.path,
517 label: attribute.label,
518 })),
519 ]}
520 keyValue={keyValue}
521 setKeyValue={setKeyValue}
522 customPostId={customPostId}
523 setCustomPostId={setCustomPostId}
524 fallback={fallback}
525 setFallback={setFallback}
526 submitLabel={__("Apply Binding", "tableberg")}
527 cancelLabel={__("Cancel", "tableberg")}
528 onCancel={onCancel}
529 isLoadingKeys={isLoadingKeys}
530 hasSuggestions={hasSuggestions}
531 suggestions={suggestions}
532 isSuggestionsOpen={isSuggestionsOpen}
533 setIsSuggestionsOpen={setIsSuggestionsOpen}
534 fieldInputRef={fieldInputRef}
535 />
536 </form>
537 </div>
538 );
539 }
540
541 interface AttributeBindingProps {
542 attribute: BindableAttribute;
543 binding: BindingSource;
544 bindableAttributes: BindableAttribute[];
545 boundPaths: string[];
546 onBindingChange: (path: string, binding: BindingSource) => void;
547 onRemove: () => void;
548 postId?: number;
549 }
550
551 function AttributeBinding({
552 attribute,
553 binding,
554 bindableAttributes,
555 boundPaths,
556 onBindingChange,
557 onRemove,
558 postId,
559 }: AttributeBindingProps) {
560 const [isExpanded, setIsExpanded] = useState(false);
561 const [selectedPath, setSelectedPath] = useState(attribute.path);
562 const [keyValue, setKeyValue] = useState(binding.key);
563 const [customPostId, setCustomPostId] = useState(
564 binding.postId?.toString() ?? ""
565 );
566 const [fallback, setFallback] = useState(binding.fallback ?? "");
567
568 const metaPostId = customPostId
569 ? parseInt(customPostId, 10) || undefined
570 : postId;
571
572 const {
573 fieldInputRef,
574 isSuggestionsOpen,
575 setIsSuggestionsOpen,
576 suggestions,
577 hasSuggestions,
578 isLoadingKeys,
579 } = useBindingFormState({
580 keyValue,
581 postId: metaPostId,
582 });
583
584 const hasUnsavedChanges =
585 selectedPath !== attribute.path ||
586 keyValue !== binding.key ||
587 customPostId !== (binding.postId?.toString() ?? "") ||
588 fallback !== (binding.fallback ?? "");
589
590 const { data: previewValue, refresh } = useDynamicData(binding, postId);
591
592 const summary = customPostId
593 ? `${binding.key} (post #${customPostId})`
594 : binding.key;
595
596 return (
597 <div className="tableberg-dynamic-data-row">
598 <button
599 type="button"
600 className="tableberg-dynamic-data-row__header"
601 onClick={() => setIsExpanded(!isExpanded)}
602 >
603 <span className="tableberg-dynamic-data-row__label">
604 {attribute.label}
605 </span>
606 <span className="tableberg-dynamic-data-row__summary">
607 {summary}
608 </span>
609 </button>
610
611 {isExpanded && (
612 <form
613 className="tableberg-dynamic-data-row__config"
614 onSubmit={event => {
615 event.preventDefault();
616 if (!keyValue) {
617 return;
618 }
619 onBindingChange(
620 selectedPath,
621 createBindingSource(
622 keyValue,
623 customPostId,
624 fallback
625 )
626 );
627 }}
628 >
629 <BindingFormFields
630 selectedPath={selectedPath}
631 setSelectedPath={setSelectedPath}
632 pathOptions={bindableAttributes
633 .filter(
634 item =>
635 item.path === attribute.path ||
636 !boundPaths.includes(item.path)
637 )
638 .map(item => ({
639 value: item.path,
640 label: item.label,
641 }))}
642 keyValue={keyValue}
643 setKeyValue={setKeyValue}
644 customPostId={customPostId}
645 setCustomPostId={setCustomPostId}
646 fallback={fallback}
647 setFallback={setFallback}
648 submitLabel={__("Update", "tableberg")}
649 showRefresh={!hasUnsavedChanges}
650 onRefresh={() => {
651 void refresh();
652 }}
653 refreshLabel={__("Refresh", "tableberg")}
654 isLoadingKeys={isLoadingKeys}
655 hasSuggestions={hasSuggestions}
656 suggestions={suggestions}
657 isSuggestionsOpen={isSuggestionsOpen}
658 setIsSuggestionsOpen={setIsSuggestionsOpen}
659 fieldInputRef={fieldInputRef}
660 previewValue={previewValue}
661 isDestructiveRemove
662 onRemove={onRemove}
663 />
664 </form>
665 )}
666 </div>
667 );
668 }
669
670 export default DynamicDataPanel;
671