@wordpress-gcb/fields 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +51 -35
  2. package/dist/conditional-logic.js +83 -0
  3. package/{src → dist}/control-context.js +3 -2
  4. package/{src → dist}/controls/MediaCapabilityGate.js +12 -8
  5. package/dist/controls/MediaPicker.js +149 -0
  6. package/dist/controls/MediaTriggerBadges.js +35 -0
  7. package/{src → dist}/controls/PopoverOrModal.js +49 -43
  8. package/dist/controls/SortableItem.js +126 -0
  9. package/dist/controls/button-group.js +46 -0
  10. package/dist/controls/checkbox-group.js +65 -0
  11. package/dist/controls/checkbox.js +15 -0
  12. package/dist/controls/code.js +24 -0
  13. package/dist/controls/color.js +241 -0
  14. package/dist/controls/date.js +55 -0
  15. package/dist/controls/datetime.js +61 -0
  16. package/dist/controls/email.js +17 -0
  17. package/dist/controls/file.js +163 -0
  18. package/dist/controls/gallery.js +371 -0
  19. package/dist/controls/google-map.js +143 -0
  20. package/dist/controls/heading-level.js +93 -0
  21. package/dist/controls/icon.js +292 -0
  22. package/dist/controls/image.js +360 -0
  23. package/dist/controls/index.js +88 -0
  24. package/dist/controls/message.js +86 -0
  25. package/dist/controls/number.js +19 -0
  26. package/dist/controls/oembed.js +42 -0
  27. package/{src → dist}/controls/page-link.js +1 -2
  28. package/dist/controls/post-object.js +913 -0
  29. package/dist/controls/radio.js +19 -0
  30. package/dist/controls/range.js +108 -0
  31. package/{src → dist}/controls/relationship.js +12 -7
  32. package/dist/controls/repeater.js +277 -0
  33. package/dist/controls/richtext.js +494 -0
  34. package/dist/controls/select.js +144 -0
  35. package/dist/controls/size.js +59 -0
  36. package/dist/controls/spacing.js +141 -0
  37. package/dist/controls/taxonomy.js +569 -0
  38. package/dist/controls/text.js +16 -0
  39. package/dist/controls/textarea.js +17 -0
  40. package/dist/controls/toggle-group.js +28 -0
  41. package/dist/controls/toggle.js +15 -0
  42. package/dist/controls/url.js +235 -0
  43. package/dist/controls/user.js +383 -0
  44. package/{src → dist}/controls/wysiwyg.js +1 -1
  45. package/{src → dist}/hooks/useTokens.js +25 -21
  46. package/{src → dist}/index.js +2 -8
  47. package/dist/inspector.js +163 -0
  48. package/{src → dist}/provider.js +18 -17
  49. package/dist/utils/map-utils.js +54 -0
  50. package/dist/utils/token-helper.js +396 -0
  51. package/{src → dist}/validation-context.js +4 -4
  52. package/package.json +35 -13
  53. package/src/conditional-logic.js +0 -77
  54. package/src/controls/MediaPicker.js +0 -139
  55. package/src/controls/MediaTriggerBadges.js +0 -31
  56. package/src/controls/SortableItem.js +0 -110
  57. package/src/controls/button-group.js +0 -49
  58. package/src/controls/checkbox-group.js +0 -55
  59. package/src/controls/checkbox.js +0 -13
  60. package/src/controls/code.js +0 -21
  61. package/src/controls/color.js +0 -235
  62. package/src/controls/date.js +0 -37
  63. package/src/controls/datetime.js +0 -54
  64. package/src/controls/email.js +0 -15
  65. package/src/controls/file.js +0 -134
  66. package/src/controls/gallery.js +0 -338
  67. package/src/controls/google-map.js +0 -117
  68. package/src/controls/heading-level.js +0 -99
  69. package/src/controls/icon.js +0 -301
  70. package/src/controls/image.js +0 -334
  71. package/src/controls/index.js +0 -95
  72. package/src/controls/message.js +0 -56
  73. package/src/controls/number.js +0 -17
  74. package/src/controls/oembed.js +0 -32
  75. package/src/controls/post-object.js +0 -788
  76. package/src/controls/radio.js +0 -18
  77. package/src/controls/range.js +0 -110
  78. package/src/controls/repeater.js +0 -290
  79. package/src/controls/richtext.js +0 -505
  80. package/src/controls/select.js +0 -141
  81. package/src/controls/size.js +0 -49
  82. package/src/controls/spacing.js +0 -141
  83. package/src/controls/taxonomy.js +0 -488
  84. package/src/controls/text.js +0 -14
  85. package/src/controls/textarea.js +0 -15
  86. package/src/controls/toggle-group.js +0 -34
  87. package/src/controls/toggle.js +0 -13
  88. package/src/controls/url.js +0 -164
  89. package/src/controls/user.js +0 -343
  90. package/src/inspector.js +0 -174
  91. package/src/utils/map-utils.js +0 -51
  92. package/src/utils/token-helper.js +0 -243
@@ -1,788 +0,0 @@
1
- /**
2
- * PostObjectField — ported verbatim from the original GCB PostObjectControlComponent.
3
- *
4
- * Features preserved:
5
- * - Single OR multiple selection (control.multiple)
6
- * - Stored as ID(s) or full post object(s) (control.returnFormat)
7
- * - Multiple post types (CSV string or array)
8
- * - Multiple post statuses
9
- * - Optional taxonomy filter dropdowns (control.enableTaxonomyFilter / filterTaxonomies)
10
- * - Optional post-type filter dropdown (control.enablePostTypeFilter)
11
- * - Drag-and-drop reordering of selected posts (multi-select only)
12
- * - REST endpoint discovery via /wp/v2/types
13
- */
14
-
15
- import { __ } from '@wordpress/i18n';
16
- import {
17
- Button,
18
- SelectControl,
19
- TextControl,
20
- __experimentalTruncate as Truncate,
21
- } from '@wordpress/components';
22
- import PopoverOrModal from './PopoverOrModal';
23
- import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
24
- import apiFetch from '@wordpress/api-fetch';
25
- import {
26
- DndContext,
27
- closestCenter,
28
- PointerSensor,
29
- useSensor,
30
- useSensors,
31
- DragOverlay,
32
- } from '@dnd-kit/core';
33
- import {
34
- SortableContext,
35
- verticalListSortingStrategy,
36
- useSortable,
37
- arrayMove,
38
- } from '@dnd-kit/sortable';
39
- import { CSS } from '@dnd-kit/utilities';
40
-
41
- const TOGGLE_BUTTON_STYLE = {
42
- width: '100%',
43
- height: 'auto',
44
- padding: '12px',
45
- justifyContent: 'flex-start',
46
- border: '1px solid #ddd',
47
- borderRadius: '2px',
48
- backgroundColor: '#fff',
49
- };
50
-
51
- /**
52
- * Sortable selected-post row.
53
- */
54
- function SortablePostItem({ post, onRemove }) {
55
- const {
56
- attributes,
57
- listeners,
58
- setNodeRef,
59
- transform,
60
- transition,
61
- isDragging,
62
- } = useSortable({ id: post.id });
63
-
64
- const style = {
65
- transform: CSS.Transform.toString(transform),
66
- transition,
67
- opacity: isDragging ? 0.5 : 1,
68
- };
69
-
70
- return (
71
- <div
72
- ref={setNodeRef}
73
- style={style}
74
- className="gcb-post-object-selected-item"
75
- {...attributes}
76
- {...listeners}
77
- >
78
- <div className="gcb-post-object-drag-handle" aria-hidden>
79
- <svg viewBox="0 0 20 20" width="12">
80
- <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
81
- </svg>
82
- </div>
83
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" style={{ marginRight: 8, flexShrink: 0, opacity: 0.6 }}>
84
- <path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" />
85
- <path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" />
86
- </svg>
87
- <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'none' }}>
88
- {post.title?.rendered || __('(no title)', 'gcblite')}
89
- </span>
90
- <button
91
- type="button"
92
- onClick={(e) => {
93
- e.stopPropagation();
94
- onRemove(post.id);
95
- }}
96
- onPointerDown={(e) => e.stopPropagation()}
97
- className="gcb-sortable-remove"
98
- aria-label={__('Remove', 'gcblite')}
99
- >
100
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
101
- <path d="M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z" />
102
- </svg>
103
- </button>
104
- </div>
105
- );
106
- }
107
-
108
- /**
109
- * The multi-select panel — search dropdown plus a sortable list of selected posts.
110
- */
111
- function PostObjectMultiSelect({
112
- posts,
113
- selectedIds,
114
- selectedPosts,
115
- loading,
116
- search,
117
- setSearch,
118
- loadPosts,
119
- handleSelect,
120
- handleRemove,
121
- handleReorder,
122
- handleClear,
123
- control,
124
- postTypeFilter,
125
- setPostTypeFilter,
126
- availablePostTypes,
127
- }) {
128
- const [activeId, setActiveId] = useState(null);
129
- const [taxonomyTermsBySlug, setTaxonomyTermsBySlug] = useState({});
130
- const [loadingTaxonomies, setLoadingTaxonomies] = useState(false);
131
- const [taxonomyFilters, setTaxonomyFilters] = useState({});
132
-
133
- useEffect(() => {
134
- if (!control.enableTaxonomyFilter || !Array.isArray(control.filterTaxonomies) || control.filterTaxonomies.length === 0) {
135
- return;
136
- }
137
- setLoadingTaxonomies(true);
138
- const promises = control.filterTaxonomies.map((tax) =>
139
- apiFetch({ path: `/wp/v2/${tax.slug}?per_page=100` })
140
- .then((terms) => ({
141
- slug: tax.slug,
142
- label: tax.label,
143
- terms: terms.map((t) => ({ value: t.id, label: t.name })),
144
- }))
145
- .catch(() => ({ slug: tax.slug, label: tax.label, terms: [] }))
146
- );
147
- Promise.all(promises)
148
- .then((results) => {
149
- const map = {};
150
- const initialFilters = {};
151
- results.forEach((r) => {
152
- map[r.slug] = { label: r.label, terms: r.terms };
153
- initialFilters[r.slug] = 'all';
154
- });
155
- setTaxonomyTermsBySlug(map);
156
- setTaxonomyFilters(initialFilters);
157
- })
158
- .finally(() => setLoadingTaxonomies(false));
159
- }, [control.enableTaxonomyFilter, control.filterTaxonomies]);
160
-
161
- const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
162
-
163
- const handleDragStart = (event) => setActiveId(event.active.id);
164
- const handleDragCancel = () => setActiveId(null);
165
-
166
- const handleDragEnd = (event) => {
167
- const { active, over } = event;
168
- if (over && active.id !== over.id) {
169
- const oldIndex = selectedIds.indexOf(active.id);
170
- const newIndex = selectedIds.indexOf(over.id);
171
- handleReorder(arrayMove(selectedIds, oldIndex, newIndex));
172
- }
173
- setActiveId(null);
174
- };
175
-
176
- const activePost = activeId ? selectedPosts.find((p) => p.id === activeId) : null;
177
- const availablePosts = posts.filter((p) => !selectedIds.includes(p.id));
178
-
179
- return (
180
- <div className="gcb-post-object-stacked">
181
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
182
- <PopoverOrModal
183
- modalTitle={control.label || __('Select posts', 'gcblite')}
184
- dropdownProps={{ popoverProps: { placement: 'left-start' } }}
185
- renderToggle={({ isOpen, onToggle }) => (
186
- <Button
187
- onClick={onToggle}
188
- aria-expanded={isOpen}
189
- className="gcb-modal-toggle-button"
190
- style={{ ...TOGGLE_BUTTON_STYLE, flex: 1 }}
191
- >
192
- {selectedPosts.length > 0
193
- ? `${selectedPosts.length} ${selectedPosts.length === 1 ? __('post', 'gcblite') : __('posts', 'gcblite')} ${__('selected', 'gcblite')}`
194
- : __('Select Posts', 'gcblite')}
195
- </Button>
196
- )}
197
- renderContent={({ close: onClose, variant }) => (
198
- <div style={variant === 'modal' ? { width: '100%' } : { minWidth: 320, maxWidth: 400 }}>
199
- <div style={{ padding: '0 16px 8px 16px' }}>
200
- <TextControl
201
- value={search}
202
- onChange={(val) => {
203
- setSearch(val);
204
- loadPosts(val, postTypeFilter, taxonomyFilters);
205
- }}
206
- placeholder={__('Search…', 'gcblite')}
207
- __nextHasNoMarginBottom
208
- />
209
-
210
- {control.enablePostTypeFilter && availablePostTypes.length > 1 && (
211
- <div style={{ marginTop: 8 }}>
212
- <SelectControl
213
- label={__('Filter by Post Type', 'gcblite')}
214
- value={postTypeFilter}
215
- options={[
216
- { value: 'all', label: __('All Types', 'gcblite') },
217
- ...availablePostTypes,
218
- ]}
219
- onChange={(val) => {
220
- setPostTypeFilter(val);
221
- loadPosts(search, val, taxonomyFilters);
222
- }}
223
- __nextHasNoMarginBottom
224
- />
225
- </div>
226
- )}
227
-
228
- {control.enableTaxonomyFilter && Object.keys(taxonomyTermsBySlug).length > 0 &&
229
- Object.entries(taxonomyTermsBySlug).map(([slug, data]) => (
230
- <div key={slug} style={{ marginTop: 8 }}>
231
- <SelectControl
232
- label={__('Filter by ', 'gcblite') + data.label}
233
- value={taxonomyFilters[slug] || 'all'}
234
- options={[
235
- { value: 'all', label: __('All', 'gcblite') },
236
- ...data.terms,
237
- ]}
238
- onChange={(val) => {
239
- const newFilters = { ...taxonomyFilters, [slug]: val };
240
- setTaxonomyFilters(newFilters);
241
- loadPosts(search, postTypeFilter, newFilters);
242
- }}
243
- disabled={loadingTaxonomies}
244
- __nextHasNoMarginBottom
245
- />
246
- </div>
247
- ))}
248
- </div>
249
-
250
- <div className="block-editor-link-control__search-results-wrapper" style={{ maxHeight: 300, overflowY: 'auto' }}>
251
- {loading && (
252
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
253
- {__('Loading…', 'gcblite')}
254
- </p>
255
- )}
256
- {!loading && availablePosts.length === 0 && (
257
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
258
- {__('No posts found', 'gcblite')}
259
- </p>
260
- )}
261
- {!loading && availablePosts.length > 0 && (
262
- <div className="block-editor-link-control__search-results" role="listbox">
263
- <div className="components-menu-group">
264
- <div role="group">
265
- {availablePosts.map((post) => (
266
- <button
267
- key={post.id}
268
- type="button"
269
- role="option"
270
- className="components-button components-menu-item__button block-editor-link-control__search-item"
271
- onClick={() => handleSelect(post.id)}
272
- style={{
273
- display: 'flex',
274
- alignItems: 'center',
275
- width: '100%',
276
- padding: '8px 16px',
277
- textAlign: 'left',
278
- border: 'none',
279
- background: 'transparent',
280
- justifyContent: 'space-between',
281
- }}
282
- >
283
- <span style={{ display: 'flex', alignItems: 'center', flex: 1, overflow: 'hidden' }}>
284
- <svg
285
- xmlns="http://www.w3.org/2000/svg"
286
- viewBox="0 0 24 24"
287
- width="24"
288
- height="24"
289
- className="block-editor-link-control__search-item-icon"
290
- style={{ marginRight: 12, flexShrink: 0 }}
291
- >
292
- <path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" />
293
- <path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" />
294
- </svg>
295
- <span
296
- className="components-menu-item__item"
297
- style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
298
- >
299
- {post.title?.rendered || __('(no title)', 'gcblite')}
300
- </span>
301
- </span>
302
- </button>
303
- ))}
304
- </div>
305
- </div>
306
- </div>
307
- )}
308
- </div>
309
- </div>
310
- )}
311
- />
312
-
313
- {selectedPosts.length > 0 && (
314
- <Button
315
- onClick={handleClear}
316
- variant="secondary"
317
- isSmall
318
- className="components-range-control__reset"
319
- >
320
- {__('Reset', 'gcblite')}
321
- </Button>
322
- )}
323
- </div>
324
-
325
- {selectedPosts.length > 0 && (
326
- <div className="gcb-post-object-selected-list" style={{ marginTop: 8 }}>
327
- <DndContext
328
- sensors={sensors}
329
- collisionDetection={closestCenter}
330
- onDragStart={handleDragStart}
331
- onDragEnd={handleDragEnd}
332
- onDragCancel={handleDragCancel}
333
- >
334
- <SortableContext items={selectedIds} strategy={verticalListSortingStrategy}>
335
- {selectedPosts.map((post) => (
336
- <SortablePostItem key={post.id} post={post} onRemove={handleRemove} />
337
- ))}
338
- </SortableContext>
339
- <DragOverlay>
340
- {activePost ? (
341
- <div
342
- className="gcb-post-object-selected-item"
343
- style={{ opacity: 0.8, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}
344
- >
345
- <div className="gcb-post-object-drag-handle" aria-hidden>
346
- <svg viewBox="0 0 20 20" width="12">
347
- <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
348
- </svg>
349
- </div>
350
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" style={{ marginRight: 8, flexShrink: 0, opacity: 0.6 }}>
351
- <path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" />
352
- <path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" />
353
- </svg>
354
- <span style={{ flex: 1 }}>
355
- {activePost.title?.rendered || __('(no title)', 'gcblite')}
356
- </span>
357
- </div>
358
- ) : null}
359
- </DragOverlay>
360
- </DndContext>
361
- </div>
362
- )}
363
- </div>
364
- );
365
- }
366
-
367
- // Normalise whatever shape the post-object value used to take into the
368
- // canonical { post_type, ids[] } shape. Handles:
369
- // - canonical { post_type, ids[] } (preferred since v0.2)
370
- // - bare scalar id (legacy single)
371
- // - array of ids (legacy multi)
372
- // - { id, ... } object (returnFormat=object, single)
373
- // - array of those objects (returnFormat=object, multi)
374
- function normalisePostObjectValue(value, schemaDefault) {
375
- if (!value) return { post_type: schemaDefault, ids: [] };
376
- if (typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.ids)) {
377
- return { post_type: value.post_type || schemaDefault, ids: value.ids };
378
- }
379
- if (typeof value === 'number' || typeof value === 'string') {
380
- return { post_type: schemaDefault, ids: [Number(value)] };
381
- }
382
- if (typeof value === 'object' && !Array.isArray(value) && value.id != null) {
383
- return { post_type: value.type || schemaDefault, ids: [Number(value.id)] };
384
- }
385
- if (Array.isArray(value)) {
386
- const ids = value
387
- .map((entry) => typeof entry === 'object' ? Number(entry.id) : Number(entry))
388
- .filter(Boolean);
389
- const pt = value.find((entry) => typeof entry === 'object' && entry?.type)?.type;
390
- return { post_type: pt || schemaDefault, ids };
391
- }
392
- return { post_type: schemaDefault, ids: [] };
393
- }
394
-
395
- export default function PostObjectField({ control, value, onChange }) {
396
- const [allPosts, setAllPosts] = useState([]);
397
- const [searchResults, setSearchResults] = useState([]);
398
- const [loading, setLoading] = useState(false);
399
- const [search, setSearch] = useState('');
400
- const [postTypeFilter, setPostTypeFilter] = useState('all');
401
- const [postTypeEndpoints, setPostTypeEndpoints] = useState({});
402
- const [allAvailableTypes, setAllAvailableTypes] = useState([]);
403
-
404
- // Schema-locked vs editor-picks-at-edit-time. When control.postType
405
- // is omitted entirely, the field is dynamic — user picks at edit
406
- // time and we store { post_type, ids } alongside the selection.
407
- const dynamic = !control.postType;
408
- const schemaDefault = (() => {
409
- if (!control.postType) return 'post';
410
- if (typeof control.postType === 'string') {
411
- return control.postType.split(',')[0].trim() || 'post';
412
- }
413
- if (Array.isArray(control.postType)) return control.postType[0] || 'post';
414
- return 'post';
415
- })();
416
-
417
- const normalised = useMemo(
418
- () => normalisePostObjectValue(value, schemaDefault),
419
- [value, schemaDefault]
420
- );
421
- const currentPostType = normalised.post_type;
422
-
423
- const availablePostTypes = useMemo(() => {
424
- if (!control.postType) {
425
- // Dynamic mode: offer everything the live registry knows about.
426
- return allAvailableTypes.length > 0 ? allAvailableTypes : [{ value: 'post', label: 'Posts' }];
427
- }
428
- const types = typeof control.postType === 'string'
429
- ? control.postType.split(',').map((pt) => pt.trim()).filter(Boolean)
430
- : Array.isArray(control.postType) ? control.postType : ['post'];
431
- return types.map((type) => ({
432
- value: type,
433
- label: type.charAt(0).toUpperCase() + type.slice(1) + 's',
434
- }));
435
- }, [control.postType, allAvailableTypes]);
436
-
437
- useEffect(() => {
438
- (async () => {
439
- try {
440
- const types = await apiFetch({ path: '/wp/v2/types' });
441
- const endpoints = {};
442
- const list = [];
443
- Object.entries(types).forEach(([key, info]) => {
444
- endpoints[key] = info.rest_base || key;
445
- // Skip non-author-facing types in the dynamic dropdown.
446
- if (info.viewable && key !== 'attachment') {
447
- list.push({ value: key, label: info.name || key });
448
- }
449
- });
450
- setPostTypeEndpoints(endpoints);
451
- setAllAvailableTypes(list);
452
- } catch {
453
- setPostTypeEndpoints({ post: 'posts', page: 'pages', media: 'media', attachment: 'media' });
454
- setAllAvailableTypes([
455
- { value: 'post', label: 'Posts' },
456
- { value: 'page', label: 'Pages' },
457
- ]);
458
- }
459
- })();
460
- }, []);
461
-
462
- const isMultiple = !!control.multiple;
463
- const selectedIds = isMultiple ? normalised.ids : null;
464
- const singleSelectedId = !isMultiple ? (normalised.ids[0] || null) : null;
465
-
466
- // Emit the canonical shape — always { post_type, ids[] } regardless
467
- // of single/multi. Renderer reads both old and canonical so this is
468
- // safe across migration.
469
- const emitChange = useCallback((ids) => {
470
- onChange({ post_type: currentPostType, ids });
471
- }, [onChange, currentPostType]);
472
-
473
- const handlePostTypeChange = (newType) => {
474
- // Switching post type clears the selected IDs — IDs from one
475
- // type don't translate to another.
476
- onChange({ post_type: newType, ids: [] });
477
- setAllPosts([]);
478
- setSearchResults([]);
479
- };
480
-
481
- const mergePostsIntoCache = useCallback((newPosts) => {
482
- setAllPosts((prev) => {
483
- const merged = [...prev];
484
- newPosts.forEach((np) => {
485
- if (!merged.find((p) => p.id === np.id)) merged.push(np);
486
- });
487
- return merged;
488
- });
489
- }, []);
490
-
491
- const selectedPosts = useMemo(() => {
492
- if (isMultiple) {
493
- return selectedIds.map((id) => allPosts.find((p) => p.id === id)).filter(Boolean);
494
- }
495
- return allPosts.find((p) => p.id === singleSelectedId) || null;
496
- }, [allPosts, selectedIds, singleSelectedId, isMultiple]);
497
-
498
- const loadPosts = useCallback(async (searchTerm = '', filterPostType = 'all', taxFilters = {}) => {
499
- setLoading(true);
500
- try {
501
- let postTypes;
502
- if (dynamic) {
503
- // In dynamic mode the editor has chosen the type via the
504
- // picker dropdown; search only within that one type.
505
- postTypes = [currentPostType];
506
- } else {
507
- postTypes = control.postType;
508
- if (!postTypes || postTypes === '') {
509
- postTypes = ['post'];
510
- } else if (typeof postTypes === 'string') {
511
- postTypes = postTypes.split(',').map((pt) => pt.trim()).filter(Boolean);
512
- if (postTypes.length === 0) postTypes = ['post'];
513
- } else if (!Array.isArray(postTypes)) {
514
- postTypes = ['post'];
515
- }
516
- }
517
- if (filterPostType !== 'all') postTypes = [filterPostType];
518
-
519
- let postStatuses = control.postStatus;
520
- if (!postStatuses || postStatuses === '') {
521
- postStatuses = ['publish'];
522
- } else if (typeof postStatuses === 'string') {
523
- postStatuses = postStatuses.split(',').map((s) => s.trim()).filter(Boolean);
524
- if (postStatuses.length === 0) postStatuses = ['publish'];
525
- } else if (!Array.isArray(postStatuses)) {
526
- postStatuses = ['publish'];
527
- }
528
-
529
- if (Object.keys(postTypeEndpoints).length === 0) {
530
- setLoading(false);
531
- return;
532
- }
533
-
534
- const collected = [];
535
- for (const postType of postTypes) {
536
- try {
537
- const endpoint = postTypeEndpoints[postType] || postType;
538
- let statusParam = 'publish';
539
- if (postStatuses.length > 1 || (postStatuses.length === 1 && postStatuses[0] !== 'publish')) {
540
- statusParam = 'any';
541
- } else if (postStatuses.length === 1) {
542
- statusParam = postStatuses[0];
543
- }
544
- const fields = postStatuses.length > 1 ? 'id,title,type,status' : 'id,title,type';
545
- let query = `search=${searchTerm}&per_page=50&_fields=${fields}&status=${statusParam}`;
546
-
547
- if (taxFilters && typeof taxFilters === 'object') {
548
- Object.entries(taxFilters).forEach(([slug, termId]) => {
549
- if (termId !== 'all') query += `&${slug}=${termId}`;
550
- });
551
- }
552
-
553
- if (control.taxonomy && control.taxonomyTerms) {
554
- const taxonomies = typeof control.taxonomy === 'string'
555
- ? control.taxonomy.split(',').map((t) => t.trim()).filter(Boolean)
556
- : [control.taxonomy];
557
- taxonomies.forEach((tax) => {
558
- query += `&${tax}=${control.taxonomyTerms}`;
559
- });
560
- }
561
-
562
- const response = await apiFetch({ path: `/wp/v2/${endpoint}?${query}` });
563
- if (Array.isArray(response)) {
564
- if (postStatuses.length > 1 && statusParam === 'any') {
565
- collected.push(...response.filter((post) => postStatuses.includes(post.status)));
566
- } else {
567
- collected.push(...response);
568
- }
569
- }
570
- } catch {
571
- // ignore per-type errors; continue
572
- }
573
- }
574
-
575
- const unique = collected.filter((post, i, self) => i === self.findIndex((p) => p.id === post.id));
576
- setSearchResults(unique);
577
- mergePostsIntoCache(unique);
578
- } catch {
579
- // ignore
580
- }
581
- setLoading(false);
582
- }, [control.postType, control.postStatus, control.taxonomy, control.taxonomyTerms, mergePostsIntoCache, postTypeEndpoints, dynamic, currentPostType]);
583
-
584
- useEffect(() => {
585
- loadPosts();
586
- }, [loadPosts]);
587
-
588
- const handleSelect = (postId) => {
589
- if (isMultiple) {
590
- const newIds = selectedIds.includes(postId)
591
- ? selectedIds.filter((id) => id !== postId)
592
- : [...selectedIds, postId];
593
- emitChange(newIds);
594
- } else {
595
- emitChange([postId]);
596
- }
597
- };
598
-
599
- const handleRemove = (postId) => {
600
- if (!isMultiple) return;
601
- emitChange(selectedIds.filter((id) => id !== postId));
602
- };
603
-
604
- const handleReorder = (newOrder) => {
605
- emitChange(newOrder);
606
- };
607
-
608
- const handleClear = () => emitChange([]);
609
-
610
- const getDisplayText = () => {
611
- if (isMultiple) {
612
- return selectedPosts.length > 0
613
- ? `${selectedPosts.length} ${__('selected', 'gcblite')}`
614
- : __('Select Posts', 'gcblite');
615
- }
616
- return selectedPosts?.title?.rendered || __('Select Post', 'gcblite');
617
- };
618
-
619
- return (
620
- <div className="components-base-control gcb-post-object-control">
621
- <div className="components-base-control__field">
622
- <label className="components-base-control__label">{control.label}</label>
623
- </div>
624
- {control.helpText && (
625
- <p className="components-base-control__help">{control.helpText}</p>
626
- )}
627
-
628
- {dynamic && (
629
- <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
630
- <label style={{ fontSize: 12, fontWeight: 600, color: '#1e1e1e', minWidth: 70 }}>
631
- {__('Post type', 'gcblite')}
632
- </label>
633
- <select
634
- value={currentPostType}
635
- onChange={(e) => handlePostTypeChange(e.target.value)}
636
- style={{
637
- flex: 1,
638
- padding: '6px 8px',
639
- border: '1px solid #8c8f94',
640
- borderRadius: 4,
641
- fontSize: 13,
642
- background: '#fff',
643
- }}
644
- >
645
- {availablePostTypes.map((pt) => (
646
- <option key={pt.value} value={pt.value}>
647
- {pt.label} ({pt.value})
648
- </option>
649
- ))}
650
- </select>
651
- </div>
652
- )}
653
-
654
- {isMultiple ? (
655
- <PostObjectMultiSelect
656
- posts={searchResults}
657
- selectedIds={selectedIds}
658
- selectedPosts={selectedPosts}
659
- loading={loading}
660
- search={search}
661
- setSearch={setSearch}
662
- loadPosts={loadPosts}
663
- handleSelect={handleSelect}
664
- handleRemove={handleRemove}
665
- handleReorder={handleReorder}
666
- handleClear={handleClear}
667
- control={control}
668
- postTypeFilter={postTypeFilter}
669
- setPostTypeFilter={setPostTypeFilter}
670
- availablePostTypes={availablePostTypes}
671
- />
672
- ) : (
673
- <PopoverOrModal
674
- modalTitle={control.label || __('Select a post', 'gcblite')}
675
- dropdownProps={{ popoverProps: { placement: 'left-start' } }}
676
- renderToggle={({ isOpen, onToggle }) => (
677
- <Button
678
- onClick={onToggle}
679
- aria-expanded={isOpen}
680
- className="gcb-modal-toggle-button"
681
- style={TOGGLE_BUTTON_STYLE}
682
- >
683
- <Truncate numberOfLines={1}>{getDisplayText()}</Truncate>
684
- </Button>
685
- )}
686
- renderContent={({ close: onClose, variant }) => (
687
- <div style={variant === 'modal' ? { width: '100%' } : { minWidth: 320, maxWidth: 400 }}>
688
- <div style={{ padding: '0 16px 8px 16px' }}>
689
- <TextControl
690
- value={search}
691
- onChange={(val) => {
692
- setSearch(val);
693
- loadPosts(val);
694
- }}
695
- placeholder={__('Search or type title', 'gcblite')}
696
- __nextHasNoMarginBottom
697
- />
698
- </div>
699
-
700
- <div className="block-editor-link-control__search-results-wrapper" style={{ maxHeight: 300, overflowY: 'auto' }}>
701
- {loading && (
702
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
703
- {__('Loading…', 'gcblite')}
704
- </p>
705
- )}
706
- {!loading && searchResults.length === 0 && (
707
- <p style={{ textAlign: 'center', color: '#757575', padding: 16 }}>
708
- {__('No posts found', 'gcblite')}
709
- </p>
710
- )}
711
- {!loading && searchResults.length > 0 && (
712
- <div className="block-editor-link-control__search-results" role="listbox">
713
- <div className="components-menu-group">
714
- <div role="group">
715
- {searchResults.map((post) => {
716
- const isSelected = singleSelectedId === post.id;
717
- return (
718
- <button
719
- key={post.id}
720
- type="button"
721
- role="option"
722
- className="components-button components-menu-item__button block-editor-link-control__search-item"
723
- onClick={() => handleSelect(post.id)}
724
- style={{
725
- display: 'flex',
726
- alignItems: 'center',
727
- width: '100%',
728
- padding: '8px 16px',
729
- textAlign: 'left',
730
- border: 'none',
731
- background: 'transparent',
732
- justifyContent: 'space-between',
733
- }}
734
- >
735
- <span style={{ display: 'flex', alignItems: 'center', flex: 1, overflow: 'hidden' }}>
736
- <svg
737
- xmlns="http://www.w3.org/2000/svg"
738
- viewBox="0 0 24 24"
739
- width="24"
740
- height="24"
741
- className="block-editor-link-control__search-item-icon"
742
- style={{ marginRight: 12, flexShrink: 0 }}
743
- >
744
- <path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" />
745
- <path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" />
746
- </svg>
747
- <span
748
- className="components-menu-item__item"
749
- style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
750
- >
751
- {post.title?.rendered || __('(no title)', 'gcblite')}
752
- </span>
753
- </span>
754
- {isSelected && (
755
- <svg
756
- xmlns="http://www.w3.org/2000/svg"
757
- viewBox="0 0 24 24"
758
- width="24"
759
- height="24"
760
- className="components-menu-items__item-icon has-icon-right"
761
- style={{ marginLeft: 8, flexShrink: 0, fill: '#2271b1' }}
762
- >
763
- <path d="M16.5 7.5 10 13.9l-2.5-2.4-1 1 3.5 3.6 7.5-7.6z" />
764
- </svg>
765
- )}
766
- </button>
767
- );
768
- })}
769
- </div>
770
- </div>
771
- </div>
772
- )}
773
- </div>
774
-
775
- {selectedPosts && (
776
- <div style={{ padding: '8px 16px 16px', borderTop: '1px solid #ddd' }}>
777
- <Button onClick={handleClear} variant="tertiary" style={{ width: '100%' }}>
778
- {__('Clear Selection', 'gcblite')}
779
- </Button>
780
- </div>
781
- )}
782
- </div>
783
- )}
784
- />
785
- )}
786
- </div>
787
- );
788
- }