@usesidekick/react 0.1.0
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.
- package/README.md +246 -0
- package/dist/index.d.mts +358 -0
- package/dist/index.d.ts +358 -0
- package/dist/index.js +2470 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2403 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsx-dev-runtime.d.mts +21 -0
- package/dist/jsx-dev-runtime.d.ts +21 -0
- package/dist/jsx-dev-runtime.js +160 -0
- package/dist/jsx-dev-runtime.js.map +1 -0
- package/dist/jsx-dev-runtime.mjs +122 -0
- package/dist/jsx-dev-runtime.mjs.map +1 -0
- package/dist/jsx-runtime.d.mts +26 -0
- package/dist/jsx-runtime.d.ts +26 -0
- package/dist/jsx-runtime.js +150 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/jsx-runtime.mjs +109 -0
- package/dist/jsx-runtime.mjs.map +1 -0
- package/dist/server/index.d.mts +235 -0
- package/dist/server/index.d.ts +235 -0
- package/dist/server/index.js +642 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +597 -0
- package/dist/server/index.mjs.map +1 -0
- package/package.json +64 -0
- package/src/components/SidekickPanel.tsx +868 -0
- package/src/components/index.ts +1 -0
- package/src/context.tsx +157 -0
- package/src/flags.ts +47 -0
- package/src/index.ts +71 -0
- package/src/jsx-dev-runtime.ts +138 -0
- package/src/jsx-runtime.ts +159 -0
- package/src/loader.ts +35 -0
- package/src/primitives/behavior.ts +70 -0
- package/src/primitives/data.ts +91 -0
- package/src/primitives/index.ts +3 -0
- package/src/primitives/ui.ts +268 -0
- package/src/provider.tsx +1264 -0
- package/src/runtime-loader.ts +106 -0
- package/src/server/drizzle-adapter.ts +53 -0
- package/src/server/drizzle-schema.ts +16 -0
- package/src/server/generate.ts +578 -0
- package/src/server/handler.ts +343 -0
- package/src/server/index.ts +20 -0
- package/src/server/storage.ts +1 -0
- package/src/server/types.ts +49 -0
- package/src/types.ts +295 -0
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { ReactNode, useCallback, useEffect, useMemo, useState, useRef, ComponentType } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { SidekickContext, SidekickContextValue } from './context';
|
|
6
|
+
import { SidekickState, Override, SDK, AddedColumn, ColumnRename, ColumnOrder, HiddenColumn, RowFilter, WrappedComponent, InjectionPoint } from './types';
|
|
7
|
+
import { loadFlags, setFlag as saveFlag, getFlag } from './flags';
|
|
8
|
+
import { getRegisteredOverrides } from './loader';
|
|
9
|
+
import { createUIPrimitives } from './primitives/ui';
|
|
10
|
+
import { createDataPrimitives } from './primitives/data';
|
|
11
|
+
import { createBehaviorPrimitives } from './primitives/behavior';
|
|
12
|
+
import { fetchAndCompileOverrides } from './runtime-loader';
|
|
13
|
+
import { configureJsxRuntime } from './jsx-runtime';
|
|
14
|
+
import { configureJsxDevRuntime } from './jsx-dev-runtime';
|
|
15
|
+
|
|
16
|
+
interface SidekickProviderProps {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
/** API endpoint to fetch overrides from (for production/DB-backed overrides) */
|
|
19
|
+
overridesEndpoint?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert a kebab-case table ID to PascalCase component name.
|
|
24
|
+
* e.g., 'task-table' -> 'TaskTable'
|
|
25
|
+
*/
|
|
26
|
+
function tableIdToComponentName(tableId: string): string {
|
|
27
|
+
return tableId
|
|
28
|
+
.split('-')
|
|
29
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
30
|
+
.join('');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a wrapper component that auto-injects added columns into a table
|
|
35
|
+
* via DOM manipulation + React portals. This allows addColumn() to work
|
|
36
|
+
* without the target component needing any Sidekick awareness.
|
|
37
|
+
*/
|
|
38
|
+
function createAutoColumnWrapper(
|
|
39
|
+
columns: AddedColumn[],
|
|
40
|
+
renames: ColumnRename[],
|
|
41
|
+
columnOrder?: ColumnOrder,
|
|
42
|
+
hiddenColumns?: HiddenColumn[],
|
|
43
|
+
rowFilters?: RowFilter[]
|
|
44
|
+
): (Original: ComponentType) => ComponentType {
|
|
45
|
+
return (Original: ComponentType) => {
|
|
46
|
+
function AutoColumnInjector(props: Record<string, unknown>) {
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const [cellTargets, setCellTargets] = useState<Array<{ element: HTMLElement; taskIndex: number; colIndex: number }>>([]);
|
|
49
|
+
const allTasks = (props.tasks || []) as Record<string, unknown>[];
|
|
50
|
+
|
|
51
|
+
// Apply row filters to tasks before rendering
|
|
52
|
+
const tasks = useMemo(() => {
|
|
53
|
+
if (!rowFilters || rowFilters.length === 0) return allTasks;
|
|
54
|
+
return allTasks.filter(row =>
|
|
55
|
+
rowFilters.every(rf => rf.filter(row))
|
|
56
|
+
);
|
|
57
|
+
}, [allTasks]);
|
|
58
|
+
|
|
59
|
+
// Inject column headers and cell containers into the DOM
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const container = containerRef.current;
|
|
62
|
+
if (!container) return;
|
|
63
|
+
const table = container.querySelector('table');
|
|
64
|
+
if (!table) return;
|
|
65
|
+
|
|
66
|
+
// Clean up any previously injected elements
|
|
67
|
+
table.querySelectorAll('[data-sidekick-injected]').forEach(el => el.remove());
|
|
68
|
+
// Reset hidden columns from previous runs
|
|
69
|
+
table.querySelectorAll('[data-sidekick-hidden]').forEach(el => {
|
|
70
|
+
(el as HTMLElement).style.display = '';
|
|
71
|
+
el.removeAttribute('data-sidekick-hidden');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Apply column renames to existing headers
|
|
75
|
+
if (renames.length > 0) {
|
|
76
|
+
const headers = table.querySelectorAll('thead th');
|
|
77
|
+
headers.forEach(th => {
|
|
78
|
+
const text = th.textContent?.trim();
|
|
79
|
+
if (text) {
|
|
80
|
+
const rename = renames.find(r => r.originalHeader === text);
|
|
81
|
+
if (rename) {
|
|
82
|
+
th.textContent = rename.newHeader;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Reorder columns if a column order is specified
|
|
89
|
+
if (columnOrder) {
|
|
90
|
+
const headerRow = table.querySelector('thead tr');
|
|
91
|
+
if (headerRow) {
|
|
92
|
+
const ths = Array.from(headerRow.querySelectorAll('th'));
|
|
93
|
+
// Build mapping: header text -> original index
|
|
94
|
+
const headerToIndex = new Map<string, number>();
|
|
95
|
+
ths.forEach((th, i) => {
|
|
96
|
+
const text = th.textContent?.trim() ?? '';
|
|
97
|
+
headerToIndex.set(text, i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Build sorted index order: ordered headers first, then remaining in original order
|
|
101
|
+
const orderedIndices: number[] = [];
|
|
102
|
+
const usedIndices = new Set<number>();
|
|
103
|
+
for (const headerName of columnOrder.order) {
|
|
104
|
+
const idx = headerToIndex.get(headerName);
|
|
105
|
+
if (idx !== undefined) {
|
|
106
|
+
orderedIndices.push(idx);
|
|
107
|
+
usedIndices.add(idx);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Append remaining columns in their original order
|
|
111
|
+
for (let i = 0; i < ths.length; i++) {
|
|
112
|
+
if (!usedIndices.has(i)) {
|
|
113
|
+
orderedIndices.push(i);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reorder thead ths
|
|
118
|
+
const sortedThs = orderedIndices.map(i => ths[i]);
|
|
119
|
+
for (const th of sortedThs) {
|
|
120
|
+
headerRow.appendChild(th);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reorder tbody tds to match
|
|
124
|
+
const bodyRows = table.querySelectorAll('tbody tr');
|
|
125
|
+
bodyRows.forEach(row => {
|
|
126
|
+
const tds = Array.from(row.querySelectorAll('td'));
|
|
127
|
+
const sortedTds = orderedIndices.map(i => tds[i]).filter(Boolean);
|
|
128
|
+
for (const td of sortedTds) {
|
|
129
|
+
row.appendChild(td);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Hide columns
|
|
136
|
+
if (hiddenColumns && hiddenColumns.length > 0) {
|
|
137
|
+
const headers = table.querySelectorAll('thead th');
|
|
138
|
+
const hiddenHeaders = new Set(hiddenColumns.map(h => h.header));
|
|
139
|
+
const hiddenIndices = new Set<number>();
|
|
140
|
+
|
|
141
|
+
headers.forEach((th, i) => {
|
|
142
|
+
const text = th.textContent?.trim() ?? '';
|
|
143
|
+
if (hiddenHeaders.has(text)) {
|
|
144
|
+
(th as HTMLElement).style.display = 'none';
|
|
145
|
+
th.setAttribute('data-sidekick-hidden', 'true');
|
|
146
|
+
hiddenIndices.add(i);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (hiddenIndices.size > 0) {
|
|
151
|
+
const bodyRows = table.querySelectorAll('tbody tr');
|
|
152
|
+
bodyRows.forEach(row => {
|
|
153
|
+
const tds = row.querySelectorAll('td');
|
|
154
|
+
tds.forEach((td, i) => {
|
|
155
|
+
if (hiddenIndices.has(i)) {
|
|
156
|
+
(td as HTMLElement).style.display = 'none';
|
|
157
|
+
td.setAttribute('data-sidekick-hidden', 'true');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Add column headers
|
|
165
|
+
const headerRow = table.querySelector('thead tr');
|
|
166
|
+
if (headerRow) {
|
|
167
|
+
for (const col of columns) {
|
|
168
|
+
const th = document.createElement('th');
|
|
169
|
+
th.className = 'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider';
|
|
170
|
+
th.textContent = col.header;
|
|
171
|
+
th.setAttribute('data-sidekick-injected', 'true');
|
|
172
|
+
headerRow.appendChild(th);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Add cell containers to each body row
|
|
177
|
+
const bodyRows = table.querySelectorAll('tbody tr');
|
|
178
|
+
const targets: Array<{ element: HTMLElement; taskIndex: number; colIndex: number }> = [];
|
|
179
|
+
bodyRows.forEach((row, rowIndex) => {
|
|
180
|
+
for (let colIndex = 0; colIndex < columns.length; colIndex++) {
|
|
181
|
+
const td = document.createElement('td');
|
|
182
|
+
td.className = 'px-6 py-4 whitespace-nowrap text-sm text-gray-500';
|
|
183
|
+
td.setAttribute('data-sidekick-injected', 'true');
|
|
184
|
+
row.appendChild(td);
|
|
185
|
+
targets.push({ element: td, taskIndex: rowIndex, colIndex });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
setCellTargets(targets);
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
table.querySelectorAll('[data-sidekick-injected]').forEach(el => el.remove());
|
|
193
|
+
table.querySelectorAll('[data-sidekick-hidden]').forEach(el => {
|
|
194
|
+
(el as HTMLElement).style.display = '';
|
|
195
|
+
el.removeAttribute('data-sidekick-hidden');
|
|
196
|
+
});
|
|
197
|
+
setCellTargets([]);
|
|
198
|
+
};
|
|
199
|
+
}, [tasks]);
|
|
200
|
+
|
|
201
|
+
// Render the original component with filtered tasks, then portal cell content into injected TDs
|
|
202
|
+
const filteredProps = (rowFilters && rowFilters.length > 0)
|
|
203
|
+
? { ...props, tasks }
|
|
204
|
+
: props;
|
|
205
|
+
return React.createElement(React.Fragment, null,
|
|
206
|
+
React.createElement('div', { ref: containerRef },
|
|
207
|
+
React.createElement(Original, filteredProps as Record<string, unknown>)
|
|
208
|
+
),
|
|
209
|
+
...cellTargets.map((target, idx) => {
|
|
210
|
+
const task = tasks[target.taskIndex];
|
|
211
|
+
const col = columns[target.colIndex];
|
|
212
|
+
if (!task || !col) return null;
|
|
213
|
+
|
|
214
|
+
const accessor = col.accessor;
|
|
215
|
+
const value = typeof accessor === 'function'
|
|
216
|
+
? accessor(task)
|
|
217
|
+
: task[accessor as string];
|
|
218
|
+
|
|
219
|
+
const content = col.render
|
|
220
|
+
? col.render(value, task)
|
|
221
|
+
: React.createElement('span', null, String(value ?? '-'));
|
|
222
|
+
|
|
223
|
+
return createPortal(content, target.element, `sidekick-col-${idx}`);
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
AutoColumnInjector.displayName = `AutoColumnInjector(${(Original as { displayName?: string; name?: string }).displayName || (Original as { name?: string }).name || 'Component'})`;
|
|
228
|
+
return AutoColumnInjector as unknown as ComponentType;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get a component's display name from its type (function, forwardRef, memo, lazy, etc.).
|
|
234
|
+
* Handles React.lazy types used by Next.js for client components referenced from server components.
|
|
235
|
+
*/
|
|
236
|
+
function getComponentName(type: unknown): string | null {
|
|
237
|
+
if (!type) return null;
|
|
238
|
+
if (typeof type === 'function') {
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
|
+
const fn = type as any;
|
|
241
|
+
return fn.displayName || fn.name || null;
|
|
242
|
+
}
|
|
243
|
+
if (typeof type === 'object' && type !== null) {
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
+
const obj = type as any;
|
|
246
|
+
if (obj.displayName) return obj.displayName;
|
|
247
|
+
// React.lazy: { $$typeof: Symbol(react.lazy), _payload, _init }
|
|
248
|
+
// When resolved, _payload.status === 'fulfilled' and _payload.value is the actual component
|
|
249
|
+
if (obj.$$typeof?.toString() === 'Symbol(react.lazy)') {
|
|
250
|
+
const payload = obj._payload;
|
|
251
|
+
if (payload && payload.status === 'fulfilled' && payload.value) {
|
|
252
|
+
return getComponentName(payload.value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (obj.$$typeof) {
|
|
256
|
+
if (obj.type) return getComponentName(obj.type);
|
|
257
|
+
if (obj.render) return getComponentName(obj.render);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Recursively walk a React element tree and apply wrappers/replacements
|
|
265
|
+
* to any component types that match registered overrides.
|
|
266
|
+
*
|
|
267
|
+
* This handles components whose JSX callsite is in a server component
|
|
268
|
+
* (and therefore bypasses the custom JSX runtime interception).
|
|
269
|
+
* The SidekickProvider is a client component, so it receives the
|
|
270
|
+
* server-rendered element tree as `children` — we transform it here
|
|
271
|
+
* before React renders it.
|
|
272
|
+
*/
|
|
273
|
+
function transformElementTree(
|
|
274
|
+
node: ReactNode,
|
|
275
|
+
wrappers: Map<string, WrappedComponent[]>,
|
|
276
|
+
replacements: Map<string, ComponentType>,
|
|
277
|
+
wrapCache: Map<unknown, ComponentType>
|
|
278
|
+
): ReactNode {
|
|
279
|
+
if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
|
|
280
|
+
return node;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (Array.isArray(node)) {
|
|
284
|
+
let changed = false;
|
|
285
|
+
const result = node.map(child => {
|
|
286
|
+
const transformed = transformElementTree(child, wrappers, replacements, wrapCache);
|
|
287
|
+
if (transformed !== child) changed = true;
|
|
288
|
+
return transformed;
|
|
289
|
+
});
|
|
290
|
+
return changed ? result : node;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!React.isValidElement(node)) return node;
|
|
294
|
+
|
|
295
|
+
const element = node;
|
|
296
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
297
|
+
const originalType = element.type as any;
|
|
298
|
+
let newType = originalType;
|
|
299
|
+
let typeChanged = false;
|
|
300
|
+
|
|
301
|
+
// Check if this element's type is a component that should be wrapped/replaced
|
|
302
|
+
if (typeof originalType === 'function' || (typeof originalType === 'object' && originalType !== null)) {
|
|
303
|
+
const name = getComponentName(originalType);
|
|
304
|
+
if (name) {
|
|
305
|
+
// Resolve the actual component from lazy types for wrapping
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
307
|
+
let resolvedType: ComponentType = originalType as any;
|
|
308
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
309
|
+
const maybeObj = originalType as any;
|
|
310
|
+
if (maybeObj.$$typeof?.toString() === 'Symbol(react.lazy)' &&
|
|
311
|
+
maybeObj._payload?.status === 'fulfilled' && maybeObj._payload?.value) {
|
|
312
|
+
resolvedType = maybeObj._payload.value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const replacement = replacements.get(name);
|
|
316
|
+
if (replacement) {
|
|
317
|
+
newType = replacement;
|
|
318
|
+
typeChanged = true;
|
|
319
|
+
} else {
|
|
320
|
+
const componentWrappers = wrappers.get(name);
|
|
321
|
+
if (componentWrappers && componentWrappers.length > 0) {
|
|
322
|
+
// Cache wrapped types to keep referential identity stable across renders
|
|
323
|
+
if (!wrapCache.has(resolvedType)) {
|
|
324
|
+
const wrapped = componentWrappers.reduce(
|
|
325
|
+
(comp: ComponentType, { wrapper, where: wherePredicate }) => {
|
|
326
|
+
if (wherePredicate) {
|
|
327
|
+
// Conditional wrapper: only apply when where(props) returns true
|
|
328
|
+
const Wrapped = wrapper(comp);
|
|
329
|
+
const ConditionalWrapper = (props: Record<string, unknown>) => {
|
|
330
|
+
if (wherePredicate(props)) {
|
|
331
|
+
return React.createElement(Wrapped, props);
|
|
332
|
+
}
|
|
333
|
+
return React.createElement(comp, props);
|
|
334
|
+
};
|
|
335
|
+
ConditionalWrapper.displayName = `ConditionalWrap(${(comp as unknown as { displayName?: string; name?: string }).displayName || (comp as unknown as { name?: string }).name || 'Component'})`;
|
|
336
|
+
return ConditionalWrapper as unknown as ComponentType;
|
|
337
|
+
}
|
|
338
|
+
return wrapper(comp);
|
|
339
|
+
},
|
|
340
|
+
resolvedType
|
|
341
|
+
);
|
|
342
|
+
wrapCache.set(resolvedType, wrapped);
|
|
343
|
+
}
|
|
344
|
+
newType = wrapCache.get(resolvedType)!;
|
|
345
|
+
typeChanged = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Recursively transform children prop
|
|
352
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
353
|
+
const childrenProp = (element.props as any).children;
|
|
354
|
+
const transformedChildren = childrenProp != null
|
|
355
|
+
? transformElementTree(childrenProp, wrappers, replacements, wrapCache)
|
|
356
|
+
: childrenProp;
|
|
357
|
+
const childrenChanged = transformedChildren !== childrenProp;
|
|
358
|
+
|
|
359
|
+
if (!typeChanged && !childrenChanged) return node;
|
|
360
|
+
|
|
361
|
+
// Rebuild the element with the new type and/or children
|
|
362
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
363
|
+
const { children: _children, ...restProps } = element.props as any;
|
|
364
|
+
return React.createElement(
|
|
365
|
+
newType,
|
|
366
|
+
{ ...restProps, key: element.key },
|
|
367
|
+
transformedChildren
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function createInitialState(): SidekickState {
|
|
372
|
+
return {
|
|
373
|
+
overrides: [],
|
|
374
|
+
// UI state
|
|
375
|
+
wrappers: new Map(),
|
|
376
|
+
replacements: new Map(),
|
|
377
|
+
styles: [],
|
|
378
|
+
columns: new Map(),
|
|
379
|
+
columnRenames: new Map(),
|
|
380
|
+
hiddenColumns: new Map(),
|
|
381
|
+
columnOrders: new Map(),
|
|
382
|
+
rowFilters: new Map(),
|
|
383
|
+
menuItems: new Map(),
|
|
384
|
+
tabs: new Map(),
|
|
385
|
+
propsModifiers: new Map(),
|
|
386
|
+
actions: new Map(),
|
|
387
|
+
validations: new Map(),
|
|
388
|
+
// Data state
|
|
389
|
+
computedFields: new Map(),
|
|
390
|
+
filters: new Map(),
|
|
391
|
+
transforms: new Map(),
|
|
392
|
+
interceptors: [],
|
|
393
|
+
sortOptions: new Map(),
|
|
394
|
+
groupByOptions: new Map(),
|
|
395
|
+
// Behavior state
|
|
396
|
+
eventHandlers: new Map(),
|
|
397
|
+
keyboardShortcuts: [],
|
|
398
|
+
routeModifiers: [],
|
|
399
|
+
// DOM state
|
|
400
|
+
domModifications: [],
|
|
401
|
+
injections: [],
|
|
402
|
+
domEventListeners: [],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function SidekickProvider({ children, overridesEndpoint }: SidekickProviderProps) {
|
|
407
|
+
const [state, setState] = useState<SidekickState>(createInitialState);
|
|
408
|
+
const [overrides, setOverrides] = useState<Override[]>([]);
|
|
409
|
+
const [isReady, setIsReady] = useState(false);
|
|
410
|
+
|
|
411
|
+
// Store refs for interceptor getters to avoid stale closures
|
|
412
|
+
const stateRef = useRef(state);
|
|
413
|
+
stateRef.current = state;
|
|
414
|
+
|
|
415
|
+
// Initialize overrides AND configure JSX runtime, then render children
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
const initializeOverrides = async () => {
|
|
418
|
+
const newState = createInitialState();
|
|
419
|
+
const loadedOverrides: Override[] = [];
|
|
420
|
+
|
|
421
|
+
if (overridesEndpoint) {
|
|
422
|
+
// Load overrides from API
|
|
423
|
+
console.log('[Sidekick] Fetching overrides from:', overridesEndpoint);
|
|
424
|
+
const apiOverrides = await fetchAndCompileOverrides(overridesEndpoint);
|
|
425
|
+
console.log('[Sidekick] Fetched overrides:', apiOverrides.length);
|
|
426
|
+
|
|
427
|
+
for (const override of apiOverrides) {
|
|
428
|
+
loadedOverrides.push({
|
|
429
|
+
id: override.id,
|
|
430
|
+
name: override.name,
|
|
431
|
+
description: override.description,
|
|
432
|
+
version: '1.0.0',
|
|
433
|
+
enabled: override.enabled,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Activate if enabled
|
|
437
|
+
if (override.enabled) {
|
|
438
|
+
console.log(`[Sidekick] Activating override: ${override.id}`);
|
|
439
|
+
const sdk: SDK = {
|
|
440
|
+
ui: createUIPrimitives(newState, override.id),
|
|
441
|
+
data: createDataPrimitives(newState, override.id),
|
|
442
|
+
behavior: createBehaviorPrimitives(newState, override.id),
|
|
443
|
+
};
|
|
444
|
+
try {
|
|
445
|
+
override.activate(sdk);
|
|
446
|
+
console.log(`[Sidekick] Activated override: ${override.id}`);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
console.error(`[Sidekick] Failed to activate override ${override.id}:`, error);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
// Load overrides from static registry (development mode)
|
|
454
|
+
const registeredOverrides = getRegisteredOverrides();
|
|
455
|
+
const flags = loadFlags();
|
|
456
|
+
|
|
457
|
+
for (const module of registeredOverrides) {
|
|
458
|
+
loadedOverrides.push({
|
|
459
|
+
id: module.manifest.id,
|
|
460
|
+
name: module.manifest.name,
|
|
461
|
+
description: module.manifest.description,
|
|
462
|
+
version: module.manifest.version,
|
|
463
|
+
enabled: flags[module.manifest.id] ?? false,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Activate enabled overrides
|
|
468
|
+
for (const module of registeredOverrides) {
|
|
469
|
+
const isEnabled = flags[module.manifest.id] ?? false;
|
|
470
|
+
if (isEnabled) {
|
|
471
|
+
console.log(`[Sidekick] Activating override: ${module.manifest.id}`);
|
|
472
|
+
const sdk: SDK = {
|
|
473
|
+
ui: createUIPrimitives(newState, module.manifest.id),
|
|
474
|
+
data: createDataPrimitives(newState, module.manifest.id),
|
|
475
|
+
behavior: createBehaviorPrimitives(newState, module.manifest.id),
|
|
476
|
+
};
|
|
477
|
+
await module.activate(sdk);
|
|
478
|
+
console.log(`[Sidekick] After activation, wrappers:`, Array.from(newState.wrappers.keys()));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
newState.overrides = loadedOverrides;
|
|
484
|
+
|
|
485
|
+
// Auto-create wrappers for tables with any modifications
|
|
486
|
+
// (added columns, renames, reorders, hidden columns, row filters)
|
|
487
|
+
// This enables addColumn/renameColumn/reorderColumns/hideColumn/filterRows to work
|
|
488
|
+
// without app components being Sidekick-aware
|
|
489
|
+
const tableIdsWithMods = new Set<string>();
|
|
490
|
+
for (const tableId of newState.columns.keys()) tableIdsWithMods.add(tableId);
|
|
491
|
+
for (const tableId of newState.columnRenames.keys()) tableIdsWithMods.add(tableId);
|
|
492
|
+
for (const tableId of newState.columnOrders.keys()) tableIdsWithMods.add(tableId);
|
|
493
|
+
for (const tableId of newState.hiddenColumns.keys()) tableIdsWithMods.add(tableId);
|
|
494
|
+
for (const tableId of newState.rowFilters.keys()) tableIdsWithMods.add(tableId);
|
|
495
|
+
|
|
496
|
+
for (const tableId of tableIdsWithMods) {
|
|
497
|
+
const cols = newState.columns.get(tableId) ?? [];
|
|
498
|
+
const tableRenames = newState.columnRenames.get(tableId) ?? [];
|
|
499
|
+
const tableOrder = newState.columnOrders.get(tableId);
|
|
500
|
+
const tableHidden = newState.hiddenColumns.get(tableId) ?? [];
|
|
501
|
+
const tableRowFilters = newState.rowFilters.get(tableId) ?? [];
|
|
502
|
+
|
|
503
|
+
// Skip if there are truly no modifications
|
|
504
|
+
if (cols.length === 0 && tableRenames.length === 0 && !tableOrder && tableHidden.length === 0 && tableRowFilters.length === 0) continue;
|
|
505
|
+
|
|
506
|
+
const componentName = tableIdToComponentName(tableId);
|
|
507
|
+
console.log(`[Sidekick] Auto-wrapping ${componentName} with ${cols.length} added columns, ${tableRenames.length} renames, ${tableOrder ? 'reorder' : 'no reorder'}, ${tableHidden.length} hidden, ${tableRowFilters.length} row filters`);
|
|
508
|
+
|
|
509
|
+
const wrapped: WrappedComponent = {
|
|
510
|
+
id: `auto-columns-${tableId}`,
|
|
511
|
+
overrideId: '__sidekick-internal__',
|
|
512
|
+
wrapper: createAutoColumnWrapper(cols, tableRenames, tableOrder, tableHidden, tableRowFilters),
|
|
513
|
+
priority: -1000, // Low priority - runs after user wrappers
|
|
514
|
+
};
|
|
515
|
+
const existing = newState.wrappers.get(componentName) ?? [];
|
|
516
|
+
existing.push(wrapped);
|
|
517
|
+
existing.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
518
|
+
newState.wrappers.set(componentName, existing);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
setState(newState);
|
|
522
|
+
stateRef.current = newState;
|
|
523
|
+
setOverrides(loadedOverrides);
|
|
524
|
+
|
|
525
|
+
// Configure JSX runtime AFTER overrides are loaded and state is set
|
|
526
|
+
const wrapperGetter = (name: string): ((Component: ComponentType) => ComponentType) | undefined => {
|
|
527
|
+
const wrappers = stateRef.current.wrappers.get(name);
|
|
528
|
+
if (!wrappers || wrappers.length === 0) return undefined;
|
|
529
|
+
return (Component: ComponentType) => {
|
|
530
|
+
return wrappers.reduce(
|
|
531
|
+
(wrapped, { wrapper, where: wherePredicate }) => {
|
|
532
|
+
if (wherePredicate) {
|
|
533
|
+
const Wrapped = wrapper(wrapped);
|
|
534
|
+
const ConditionalWrapper = (props: Record<string, unknown>) => {
|
|
535
|
+
if (wherePredicate(props)) {
|
|
536
|
+
return React.createElement(Wrapped, props);
|
|
537
|
+
}
|
|
538
|
+
return React.createElement(wrapped, props);
|
|
539
|
+
};
|
|
540
|
+
ConditionalWrapper.displayName = `ConditionalWrap(${(wrapped as unknown as { displayName?: string; name?: string }).displayName || (wrapped as unknown as { name?: string }).name || 'Component'})`;
|
|
541
|
+
return ConditionalWrapper as unknown as ComponentType;
|
|
542
|
+
}
|
|
543
|
+
return wrapper(wrapped);
|
|
544
|
+
},
|
|
545
|
+
Component
|
|
546
|
+
);
|
|
547
|
+
};
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const replacementGetter = (name: string): ComponentType | undefined => {
|
|
551
|
+
return stateRef.current.replacements.get(name);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
console.log('[Sidekick] Configuring JSX runtime with', newState.wrappers.size, 'wrappers,', newState.replacements.size, 'replacements,', newState.columns.size, 'column sets');
|
|
555
|
+
configureJsxRuntime(wrapperGetter, replacementGetter);
|
|
556
|
+
configureJsxDevRuntime(wrapperGetter, replacementGetter);
|
|
557
|
+
console.log('[Sidekick] JSX runtime configured');
|
|
558
|
+
|
|
559
|
+
// NOW allow children to render
|
|
560
|
+
setIsReady(true);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
initializeOverrides();
|
|
564
|
+
}, [overridesEndpoint]);
|
|
565
|
+
|
|
566
|
+
// Inject styles into document
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
if (!isReady || typeof document === 'undefined') return;
|
|
569
|
+
|
|
570
|
+
const styleId = 'sidekick-injected-styles';
|
|
571
|
+
let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
|
|
572
|
+
|
|
573
|
+
if (!styleEl) {
|
|
574
|
+
styleEl = document.createElement('style');
|
|
575
|
+
styleEl.id = styleId;
|
|
576
|
+
document.head.appendChild(styleEl);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
styleEl.textContent = state.styles.map((s) => s.css).join('\n');
|
|
580
|
+
|
|
581
|
+
return () => {
|
|
582
|
+
if (styleEl && styleEl.parentNode) {
|
|
583
|
+
styleEl.parentNode.removeChild(styleEl);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}, [state.styles, isReady]);
|
|
587
|
+
|
|
588
|
+
// Update JSX runtime getters when state changes (after initial load)
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
if (!isReady) return;
|
|
591
|
+
|
|
592
|
+
const wrapperGetter = (name: string): ((Component: ComponentType) => ComponentType) | undefined => {
|
|
593
|
+
const wrappers = state.wrappers.get(name);
|
|
594
|
+
if (!wrappers || wrappers.length === 0) return undefined;
|
|
595
|
+
|
|
596
|
+
return (Component: ComponentType) => {
|
|
597
|
+
return wrappers.reduce(
|
|
598
|
+
(wrapped, { wrapper, where: wherePredicate }) => {
|
|
599
|
+
if (wherePredicate) {
|
|
600
|
+
const Wrapped = wrapper(wrapped);
|
|
601
|
+
const ConditionalWrapper = (props: Record<string, unknown>) => {
|
|
602
|
+
if (wherePredicate(props)) {
|
|
603
|
+
return React.createElement(Wrapped, props);
|
|
604
|
+
}
|
|
605
|
+
return React.createElement(wrapped, props);
|
|
606
|
+
};
|
|
607
|
+
ConditionalWrapper.displayName = `ConditionalWrap(${(wrapped as unknown as { displayName?: string; name?: string }).displayName || (wrapped as unknown as { name?: string }).name || 'Component'})`;
|
|
608
|
+
return ConditionalWrapper as unknown as ComponentType;
|
|
609
|
+
}
|
|
610
|
+
return wrapper(wrapped);
|
|
611
|
+
},
|
|
612
|
+
Component
|
|
613
|
+
);
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const replacementGetter = (name: string): ComponentType | undefined => {
|
|
618
|
+
return state.replacements.get(name);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
configureJsxRuntime(wrapperGetter, replacementGetter);
|
|
622
|
+
configureJsxDevRuntime(wrapperGetter, replacementGetter);
|
|
623
|
+
}, [state.wrappers, state.replacements, isReady]);
|
|
624
|
+
|
|
625
|
+
// --- DOM Modifications (MutationObserver) ---
|
|
626
|
+
useEffect(() => {
|
|
627
|
+
if (!isReady || typeof document === 'undefined' || state.domModifications.length === 0) return;
|
|
628
|
+
|
|
629
|
+
// Track original values for cleanup
|
|
630
|
+
const originals = new Map<Element, { type: string; value: unknown }>();
|
|
631
|
+
|
|
632
|
+
function applyModifications() {
|
|
633
|
+
for (const mod of state.domModifications) {
|
|
634
|
+
let elements: NodeListOf<Element>;
|
|
635
|
+
try {
|
|
636
|
+
elements = document.querySelectorAll(mod.selector);
|
|
637
|
+
} catch {
|
|
638
|
+
console.warn(`[Sidekick] Invalid CSS selector: "${mod.selector}"`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
elements.forEach((el) => {
|
|
642
|
+
const htmlEl = el as HTMLElement;
|
|
643
|
+
// Store original value on first application
|
|
644
|
+
if (!originals.has(el)) {
|
|
645
|
+
switch (mod.type) {
|
|
646
|
+
case 'setText': {
|
|
647
|
+
// Collect original text node values (not textContent, which would lose references)
|
|
648
|
+
const textNodes: { node: Text; value: string }[] = [];
|
|
649
|
+
const tw = document.createTreeWalker(htmlEl, NodeFilter.SHOW_TEXT);
|
|
650
|
+
let tn: Text | null;
|
|
651
|
+
while ((tn = tw.nextNode() as Text | null)) {
|
|
652
|
+
textNodes.push({ node: tn, value: tn.nodeValue || '' });
|
|
653
|
+
}
|
|
654
|
+
originals.set(el, { type: 'setText', value: textNodes });
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case 'setAttribute': {
|
|
658
|
+
const { attr } = mod.value as { attr: string; value: string };
|
|
659
|
+
originals.set(el, { type: 'setAttribute', value: { attr, value: htmlEl.getAttribute(attr) } });
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
case 'setStyle':
|
|
663
|
+
originals.set(el, { type: 'setStyle', value: htmlEl.style.cssText });
|
|
664
|
+
break;
|
|
665
|
+
case 'addClass':
|
|
666
|
+
originals.set(el, { type: 'addClass', value: mod.value });
|
|
667
|
+
break;
|
|
668
|
+
case 'removeClass':
|
|
669
|
+
originals.set(el, { type: 'removeClass', value: mod.value });
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Apply the modification
|
|
674
|
+
switch (mod.type) {
|
|
675
|
+
case 'setText': {
|
|
676
|
+
// Modify existing text nodes in-place to avoid destroying React's DOM references.
|
|
677
|
+
// Using textContent would remove all child nodes, breaking React reconciliation.
|
|
678
|
+
const walker = document.createTreeWalker(htmlEl, NodeFilter.SHOW_TEXT);
|
|
679
|
+
const firstTextNode = walker.nextNode() as Text | null;
|
|
680
|
+
if (firstTextNode) {
|
|
681
|
+
firstTextNode.nodeValue = mod.value as string;
|
|
682
|
+
// Clear any remaining text nodes
|
|
683
|
+
let extra: Text | null;
|
|
684
|
+
while ((extra = walker.nextNode() as Text | null)) {
|
|
685
|
+
extra.nodeValue = '';
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'setAttribute': {
|
|
691
|
+
const { attr, value } = mod.value as { attr: string; value: string };
|
|
692
|
+
htmlEl.setAttribute(attr, value);
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case 'setStyle':
|
|
696
|
+
Object.assign(htmlEl.style, mod.value as Record<string, string>);
|
|
697
|
+
break;
|
|
698
|
+
case 'addClass':
|
|
699
|
+
htmlEl.classList.add(mod.value as string);
|
|
700
|
+
break;
|
|
701
|
+
case 'removeClass':
|
|
702
|
+
htmlEl.classList.remove(mod.value as string);
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
htmlEl.setAttribute('data-sidekick-dom-mod', 'true');
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
applyModifications();
|
|
711
|
+
|
|
712
|
+
const observer = new MutationObserver(() => {
|
|
713
|
+
applyModifications();
|
|
714
|
+
});
|
|
715
|
+
if (document.body) {
|
|
716
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return () => {
|
|
720
|
+
observer.disconnect();
|
|
721
|
+
// Restore original values
|
|
722
|
+
originals.forEach((original, el) => {
|
|
723
|
+
const htmlEl = el as HTMLElement;
|
|
724
|
+
switch (original.type) {
|
|
725
|
+
case 'setText': {
|
|
726
|
+
const textNodes = original.value as { node: Text; value: string }[];
|
|
727
|
+
for (const { node, value } of textNodes) {
|
|
728
|
+
try { node.nodeValue = value; } catch { /* node may have been removed by React */ }
|
|
729
|
+
}
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case 'setAttribute': {
|
|
733
|
+
const { attr, value } = original.value as { attr: string; value: string | null };
|
|
734
|
+
if (value === null) htmlEl.removeAttribute(attr);
|
|
735
|
+
else htmlEl.setAttribute(attr, value);
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
case 'setStyle':
|
|
739
|
+
htmlEl.style.cssText = original.value as string;
|
|
740
|
+
break;
|
|
741
|
+
case 'addClass':
|
|
742
|
+
htmlEl.classList.remove(original.value as string);
|
|
743
|
+
break;
|
|
744
|
+
case 'removeClass':
|
|
745
|
+
htmlEl.classList.add(original.value as string);
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
htmlEl.removeAttribute('data-sidekick-dom-mod');
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
}, [state.domModifications, isReady]);
|
|
752
|
+
|
|
753
|
+
// --- Keyboard Shortcuts (keydown listener) ---
|
|
754
|
+
useEffect(() => {
|
|
755
|
+
if (!isReady || typeof document === 'undefined' || state.keyboardShortcuts.length === 0) return;
|
|
756
|
+
|
|
757
|
+
function matchesKeys(event: KeyboardEvent, keys: string): boolean {
|
|
758
|
+
const parts = keys.toLowerCase().split('+').map(s => s.trim());
|
|
759
|
+
const keyName = parts[parts.length - 1];
|
|
760
|
+
const modifiers = new Set(parts.slice(0, -1));
|
|
761
|
+
|
|
762
|
+
const needsCtrl = modifiers.has('ctrl') || modifiers.has('cmd') || modifiers.has('meta');
|
|
763
|
+
const needsShift = modifiers.has('shift');
|
|
764
|
+
const needsAlt = modifiers.has('alt');
|
|
765
|
+
|
|
766
|
+
if (needsCtrl !== (event.ctrlKey || event.metaKey)) return false;
|
|
767
|
+
if (needsShift !== event.shiftKey) return false;
|
|
768
|
+
if (needsAlt !== event.altKey) return false;
|
|
769
|
+
|
|
770
|
+
return event.key.toLowerCase() === keyName || event.code.toLowerCase() === keyName;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
774
|
+
for (const shortcut of state.keyboardShortcuts) {
|
|
775
|
+
if (matchesKeys(event, shortcut.keys)) {
|
|
776
|
+
event.preventDefault();
|
|
777
|
+
try {
|
|
778
|
+
shortcut.action();
|
|
779
|
+
} catch (error) {
|
|
780
|
+
console.error(`[Sidekick] Keyboard shortcut error for "${shortcut.keys}":`, error);
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
document.addEventListener('keydown', handleKeydown);
|
|
788
|
+
return () => {
|
|
789
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
790
|
+
};
|
|
791
|
+
}, [state.keyboardShortcuts, isReady]);
|
|
792
|
+
|
|
793
|
+
// --- Fetch Interception (monkey-patch window.fetch) ---
|
|
794
|
+
useEffect(() => {
|
|
795
|
+
if (!isReady || typeof window === 'undefined' || state.interceptors.length === 0) return;
|
|
796
|
+
|
|
797
|
+
const originalFetch = window.fetch;
|
|
798
|
+
|
|
799
|
+
window.fetch = async function sidekickFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
800
|
+
const response = await originalFetch.call(window, input, init);
|
|
801
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
802
|
+
const method = init?.method || 'GET';
|
|
803
|
+
|
|
804
|
+
for (const interceptor of stateRef.current.interceptors) {
|
|
805
|
+
const pattern = interceptor.pathPattern;
|
|
806
|
+
const matches = typeof pattern === 'string'
|
|
807
|
+
? url.includes(pattern)
|
|
808
|
+
: pattern.test(url);
|
|
809
|
+
|
|
810
|
+
if (matches) {
|
|
811
|
+
try {
|
|
812
|
+
const cloned = response.clone();
|
|
813
|
+
const json = await cloned.json();
|
|
814
|
+
const modified = interceptor.handler(json, { path: url, method });
|
|
815
|
+
return new Response(JSON.stringify(modified), {
|
|
816
|
+
status: response.status,
|
|
817
|
+
statusText: response.statusText,
|
|
818
|
+
headers: response.headers,
|
|
819
|
+
});
|
|
820
|
+
} catch {
|
|
821
|
+
// If JSON parsing fails or handler errors, return original
|
|
822
|
+
return response;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return response;
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
return () => {
|
|
831
|
+
window.fetch = originalFetch;
|
|
832
|
+
};
|
|
833
|
+
}, [state.interceptors, isReady]);
|
|
834
|
+
|
|
835
|
+
// --- DOM Injections (MutationObserver + Portals) ---
|
|
836
|
+
const [injectionContainers, setInjectionContainers] = useState<Array<{ injection: InjectionPoint; container: HTMLElement }>>([]);
|
|
837
|
+
|
|
838
|
+
useEffect(() => {
|
|
839
|
+
if (!isReady || typeof document === 'undefined' || state.injections.length === 0) return;
|
|
840
|
+
|
|
841
|
+
const containers: Array<{ injection: InjectionPoint; container: HTMLElement }> = [];
|
|
842
|
+
|
|
843
|
+
function createContainers() {
|
|
844
|
+
// Clean up previous containers
|
|
845
|
+
document.querySelectorAll('[data-sidekick-injection]').forEach(el => el.remove());
|
|
846
|
+
containers.length = 0;
|
|
847
|
+
|
|
848
|
+
for (const injection of state.injections) {
|
|
849
|
+
let target: Element | null;
|
|
850
|
+
try {
|
|
851
|
+
target = document.querySelector(injection.selector);
|
|
852
|
+
} catch {
|
|
853
|
+
console.warn(`[Sidekick] Invalid CSS selector: "${injection.selector}"`);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (!target) continue;
|
|
857
|
+
|
|
858
|
+
const container = document.createElement('div');
|
|
859
|
+
container.setAttribute('data-sidekick-injection', injection.id);
|
|
860
|
+
|
|
861
|
+
switch (injection.position) {
|
|
862
|
+
case 'before':
|
|
863
|
+
target.parentNode?.insertBefore(container, target);
|
|
864
|
+
break;
|
|
865
|
+
case 'after':
|
|
866
|
+
target.parentNode?.insertBefore(container, target.nextSibling);
|
|
867
|
+
break;
|
|
868
|
+
case 'prepend':
|
|
869
|
+
target.insertBefore(container, target.firstChild);
|
|
870
|
+
break;
|
|
871
|
+
case 'append':
|
|
872
|
+
target.appendChild(container);
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
containers.push({ injection, container });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
setInjectionContainers([...containers]);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
createContainers();
|
|
883
|
+
|
|
884
|
+
const observer = new MutationObserver(() => {
|
|
885
|
+
// Re-check if any targets appeared
|
|
886
|
+
const needsUpdate = state.injections.some(inj => {
|
|
887
|
+
try {
|
|
888
|
+
const existing = document.querySelector(`[data-sidekick-injection="${inj.id}"]`);
|
|
889
|
+
if (existing) return false;
|
|
890
|
+
const target = document.querySelector(inj.selector);
|
|
891
|
+
return !!target;
|
|
892
|
+
} catch {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
if (needsUpdate) createContainers();
|
|
897
|
+
});
|
|
898
|
+
if (document.body) {
|
|
899
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return () => {
|
|
903
|
+
observer.disconnect();
|
|
904
|
+
document.querySelectorAll('[data-sidekick-injection]').forEach(el => el.remove());
|
|
905
|
+
setInjectionContainers([]);
|
|
906
|
+
};
|
|
907
|
+
}, [state.injections, isReady]);
|
|
908
|
+
|
|
909
|
+
// --- DOM Event Delegation ---
|
|
910
|
+
useEffect(() => {
|
|
911
|
+
if (!isReady || typeof document === 'undefined' || state.domEventListeners.length === 0) return;
|
|
912
|
+
|
|
913
|
+
const cleanups: Array<() => void> = [];
|
|
914
|
+
|
|
915
|
+
for (const listener of state.domEventListeners) {
|
|
916
|
+
const handler = (event: Event) => {
|
|
917
|
+
const target = event.target as Element | null;
|
|
918
|
+
if (!target) return;
|
|
919
|
+
let matched: Element | null;
|
|
920
|
+
try {
|
|
921
|
+
matched = target.matches(listener.selector) ? target : target.closest(listener.selector);
|
|
922
|
+
} catch {
|
|
923
|
+
return; // Invalid selector
|
|
924
|
+
}
|
|
925
|
+
if (matched) {
|
|
926
|
+
try {
|
|
927
|
+
listener.handler(event, matched);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.error(`[Sidekick] DOM event handler error for "${listener.selector}" ${listener.eventType}:`, error);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
document.addEventListener(listener.eventType, handler);
|
|
934
|
+
cleanups.push(() => document.removeEventListener(listener.eventType, handler));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return () => {
|
|
938
|
+
cleanups.forEach(cleanup => cleanup());
|
|
939
|
+
};
|
|
940
|
+
}, [state.domEventListeners, isReady]);
|
|
941
|
+
|
|
942
|
+
// --- Route Modification (history patch) ---
|
|
943
|
+
useEffect(() => {
|
|
944
|
+
if (!isReady || typeof window === 'undefined' || state.routeModifiers.length === 0) return;
|
|
945
|
+
|
|
946
|
+
const originalPushState = history.pushState.bind(history);
|
|
947
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
948
|
+
|
|
949
|
+
function interceptNavigation(
|
|
950
|
+
original: typeof history.pushState,
|
|
951
|
+
data: unknown,
|
|
952
|
+
unused: string,
|
|
953
|
+
url?: string | URL | null
|
|
954
|
+
) {
|
|
955
|
+
let finalUrl = url;
|
|
956
|
+
if (url) {
|
|
957
|
+
const urlStr = url.toString();
|
|
958
|
+
for (const modifier of stateRef.current.routeModifiers) {
|
|
959
|
+
const pattern = modifier.pathPattern;
|
|
960
|
+
const matches = typeof pattern === 'string'
|
|
961
|
+
? urlStr.includes(pattern)
|
|
962
|
+
: pattern.test(urlStr);
|
|
963
|
+
|
|
964
|
+
if (matches) {
|
|
965
|
+
try {
|
|
966
|
+
const result = modifier.handler({ path: urlStr, params: {} });
|
|
967
|
+
if (typeof result === 'string') {
|
|
968
|
+
finalUrl = result;
|
|
969
|
+
}
|
|
970
|
+
} catch (error) {
|
|
971
|
+
console.error(`[Sidekick] Route modifier error:`, error);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
original(data, unused, finalUrl);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
history.pushState = function(data: unknown, unused: string, url?: string | URL | null) {
|
|
980
|
+
interceptNavigation(originalPushState, data, unused, url);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
history.replaceState = function(data: unknown, unused: string, url?: string | URL | null) {
|
|
984
|
+
interceptNavigation(originalReplaceState, data, unused, url);
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const handlePopState = () => {
|
|
988
|
+
const currentPath = window.location.pathname;
|
|
989
|
+
for (const modifier of stateRef.current.routeModifiers) {
|
|
990
|
+
const pattern = modifier.pathPattern;
|
|
991
|
+
const matches = typeof pattern === 'string'
|
|
992
|
+
? currentPath.includes(pattern)
|
|
993
|
+
: pattern.test(currentPath);
|
|
994
|
+
|
|
995
|
+
if (matches) {
|
|
996
|
+
try {
|
|
997
|
+
modifier.handler({ path: currentPath, params: {} });
|
|
998
|
+
} catch (error) {
|
|
999
|
+
console.error(`[Sidekick] Route modifier error on popstate:`, error);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
window.addEventListener('popstate', handlePopState);
|
|
1006
|
+
|
|
1007
|
+
return () => {
|
|
1008
|
+
history.pushState = originalPushState;
|
|
1009
|
+
history.replaceState = originalReplaceState;
|
|
1010
|
+
window.removeEventListener('popstate', handlePopState);
|
|
1011
|
+
};
|
|
1012
|
+
}, [state.routeModifiers, isReady]);
|
|
1013
|
+
|
|
1014
|
+
const isOverrideEnabled = useCallback(
|
|
1015
|
+
(id: string) => {
|
|
1016
|
+
// For API-based overrides, check the loaded state
|
|
1017
|
+
const override = overrides.find(o => o.id === id);
|
|
1018
|
+
if (override) return override.enabled;
|
|
1019
|
+
// Fallback to localStorage
|
|
1020
|
+
return getFlag(id);
|
|
1021
|
+
},
|
|
1022
|
+
[overrides]
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
const toggleOverride = useCallback(
|
|
1026
|
+
(id: string) => {
|
|
1027
|
+
const currentEnabled = getFlag(id);
|
|
1028
|
+
saveFlag(id, !currentEnabled);
|
|
1029
|
+
// Trigger re-initialization
|
|
1030
|
+
window.location.reload();
|
|
1031
|
+
},
|
|
1032
|
+
[]
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
const getColumns = useCallback(
|
|
1036
|
+
(tableId: string): AddedColumn[] => {
|
|
1037
|
+
return state.columns.get(tableId) ?? [];
|
|
1038
|
+
},
|
|
1039
|
+
[state.columns]
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
const getColumnRenames = useCallback(
|
|
1043
|
+
(tableId: string): ColumnRename[] => {
|
|
1044
|
+
return state.columnRenames.get(tableId) ?? [];
|
|
1045
|
+
},
|
|
1046
|
+
[state.columnRenames]
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
const getHiddenColumns = useCallback(
|
|
1050
|
+
(tableId: string) => {
|
|
1051
|
+
return state.hiddenColumns.get(tableId) ?? [];
|
|
1052
|
+
},
|
|
1053
|
+
[state.hiddenColumns]
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
const getColumnOrder = useCallback(
|
|
1057
|
+
(tableId: string) => {
|
|
1058
|
+
return state.columnOrders.get(tableId);
|
|
1059
|
+
},
|
|
1060
|
+
[state.columnOrders]
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
const getMenuItems = useCallback(
|
|
1064
|
+
(menuId: string) => {
|
|
1065
|
+
return state.menuItems.get(menuId) ?? [];
|
|
1066
|
+
},
|
|
1067
|
+
[state.menuItems]
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const getTabs = useCallback(
|
|
1071
|
+
(tabGroupId: string) => {
|
|
1072
|
+
return state.tabs.get(tabGroupId) ?? [];
|
|
1073
|
+
},
|
|
1074
|
+
[state.tabs]
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
const getActions = useCallback(
|
|
1078
|
+
(actionBarId: string) => {
|
|
1079
|
+
return state.actions.get(actionBarId) ?? [];
|
|
1080
|
+
},
|
|
1081
|
+
[state.actions]
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
const getValidations = useCallback(
|
|
1085
|
+
(formId: string) => {
|
|
1086
|
+
return state.validations.get(formId) ?? [];
|
|
1087
|
+
},
|
|
1088
|
+
[state.validations]
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
const applyPropsModifiers = useCallback(
|
|
1092
|
+
(componentId: string, props: Record<string, unknown>) => {
|
|
1093
|
+
const modifiers = state.propsModifiers.get(componentId) ?? [];
|
|
1094
|
+
return modifiers.reduce((acc, { modifier }) => modifier(acc), props);
|
|
1095
|
+
},
|
|
1096
|
+
[state.propsModifiers]
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const getComputedValue = useCallback(
|
|
1100
|
+
<T,>(fieldName: string, data: Record<string, unknown>): T | undefined => {
|
|
1101
|
+
const field = state.computedFields.get(fieldName);
|
|
1102
|
+
if (!field) return undefined;
|
|
1103
|
+
return field.compute(data) as T;
|
|
1104
|
+
},
|
|
1105
|
+
[state.computedFields]
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const applyFilters = useCallback(
|
|
1109
|
+
<T,>(filterName: string, items: T[]): T[] => {
|
|
1110
|
+
const filter = state.filters.get(filterName);
|
|
1111
|
+
if (!filter) return items;
|
|
1112
|
+
return filter.filter(items as unknown[]) as T[];
|
|
1113
|
+
},
|
|
1114
|
+
[state.filters]
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
const applyTransform = useCallback(
|
|
1118
|
+
<T,>(dataKey: string, data: T): T => {
|
|
1119
|
+
const transform = state.transforms.get(dataKey);
|
|
1120
|
+
if (!transform) return data;
|
|
1121
|
+
return transform.transform(data) as T;
|
|
1122
|
+
},
|
|
1123
|
+
[state.transforms]
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
const getSortOptions = useCallback(
|
|
1127
|
+
(tableId: string) => {
|
|
1128
|
+
return state.sortOptions.get(tableId) ?? [];
|
|
1129
|
+
},
|
|
1130
|
+
[state.sortOptions]
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
const getGroupByOptions = useCallback(
|
|
1134
|
+
(tableId: string) => {
|
|
1135
|
+
return state.groupByOptions.get(tableId) ?? [];
|
|
1136
|
+
},
|
|
1137
|
+
[state.groupByOptions]
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
const getKeyboardShortcuts = useCallback(
|
|
1141
|
+
() => {
|
|
1142
|
+
return state.keyboardShortcuts;
|
|
1143
|
+
},
|
|
1144
|
+
[state.keyboardShortcuts]
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
const emitEvent = useCallback(
|
|
1148
|
+
(eventName: string, payload: Record<string, unknown>) => {
|
|
1149
|
+
const handlers = state.eventHandlers.get(eventName) ?? [];
|
|
1150
|
+
for (const handler of handlers) {
|
|
1151
|
+
try {
|
|
1152
|
+
handler.handler(payload);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
console.error(`[Sidekick] Event handler error for ${eventName}:`, error);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
[state.eventHandlers]
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
const getWrapper = useCallback(
|
|
1162
|
+
(componentName: string): ((Component: ComponentType) => ComponentType) | undefined => {
|
|
1163
|
+
const wrappers = state.wrappers.get(componentName);
|
|
1164
|
+
if (!wrappers || wrappers.length === 0) return undefined;
|
|
1165
|
+
|
|
1166
|
+
// Compose all wrappers into a single wrapper function
|
|
1167
|
+
return (Component: ComponentType) => {
|
|
1168
|
+
return wrappers.reduce(
|
|
1169
|
+
(wrapped, { wrapper }) => wrapper(wrapped),
|
|
1170
|
+
Component
|
|
1171
|
+
);
|
|
1172
|
+
};
|
|
1173
|
+
},
|
|
1174
|
+
[state.wrappers]
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
const getReplacement = useCallback(
|
|
1178
|
+
(componentName: string): ComponentType | undefined => {
|
|
1179
|
+
return state.replacements.get(componentName);
|
|
1180
|
+
},
|
|
1181
|
+
[state.replacements]
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
const contextValue: SidekickContextValue = useMemo(
|
|
1185
|
+
() => ({
|
|
1186
|
+
state,
|
|
1187
|
+
overrides,
|
|
1188
|
+
isOverrideEnabled,
|
|
1189
|
+
toggleOverride,
|
|
1190
|
+
// UI
|
|
1191
|
+
getColumns,
|
|
1192
|
+
getColumnRenames,
|
|
1193
|
+
getHiddenColumns,
|
|
1194
|
+
getColumnOrder,
|
|
1195
|
+
getMenuItems,
|
|
1196
|
+
getTabs,
|
|
1197
|
+
getActions,
|
|
1198
|
+
getValidations,
|
|
1199
|
+
getWrapper,
|
|
1200
|
+
getReplacement,
|
|
1201
|
+
applyPropsModifiers,
|
|
1202
|
+
// Data
|
|
1203
|
+
getComputedValue,
|
|
1204
|
+
applyFilters,
|
|
1205
|
+
applyTransform,
|
|
1206
|
+
getSortOptions,
|
|
1207
|
+
getGroupByOptions,
|
|
1208
|
+
// Behavior
|
|
1209
|
+
getKeyboardShortcuts,
|
|
1210
|
+
emitEvent,
|
|
1211
|
+
}),
|
|
1212
|
+
[
|
|
1213
|
+
state,
|
|
1214
|
+
overrides,
|
|
1215
|
+
isOverrideEnabled,
|
|
1216
|
+
toggleOverride,
|
|
1217
|
+
getColumns,
|
|
1218
|
+
getColumnRenames,
|
|
1219
|
+
getHiddenColumns,
|
|
1220
|
+
getColumnOrder,
|
|
1221
|
+
getMenuItems,
|
|
1222
|
+
getTabs,
|
|
1223
|
+
getActions,
|
|
1224
|
+
getValidations,
|
|
1225
|
+
getWrapper,
|
|
1226
|
+
getReplacement,
|
|
1227
|
+
applyPropsModifiers,
|
|
1228
|
+
getComputedValue,
|
|
1229
|
+
applyFilters,
|
|
1230
|
+
applyTransform,
|
|
1231
|
+
getSortOptions,
|
|
1232
|
+
getGroupByOptions,
|
|
1233
|
+
getKeyboardShortcuts,
|
|
1234
|
+
emitEvent,
|
|
1235
|
+
]
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
// Cache for wrapped component types — persisted across renders via ref
|
|
1239
|
+
// so React sees stable component identities and doesn't remount
|
|
1240
|
+
const wrapCacheRef = useRef(new Map<unknown, ComponentType>());
|
|
1241
|
+
|
|
1242
|
+
// Transform the children element tree to apply wrappers/replacements
|
|
1243
|
+
// to components whose JSX callsite is in a server component (and therefore
|
|
1244
|
+
// bypasses the custom JSX runtime interception).
|
|
1245
|
+
const transformedChildren = useMemo(() => {
|
|
1246
|
+
if (!isReady) return null;
|
|
1247
|
+
if (state.wrappers.size === 0 && state.replacements.size === 0) return children;
|
|
1248
|
+
return transformElementTree(children, state.wrappers, state.replacements, wrapCacheRef.current);
|
|
1249
|
+
}, [children, isReady, state.wrappers, state.replacements]);
|
|
1250
|
+
|
|
1251
|
+
// Don't render children until overrides are loaded and JSX runtime is configured
|
|
1252
|
+
return (
|
|
1253
|
+
<SidekickContext.Provider value={contextValue}>
|
|
1254
|
+
{isReady ? transformedChildren : null}
|
|
1255
|
+
{injectionContainers.map(({ injection, container }) =>
|
|
1256
|
+
createPortal(
|
|
1257
|
+
React.createElement(injection.component, {}),
|
|
1258
|
+
container,
|
|
1259
|
+
`sidekick-injection-${injection.id}`
|
|
1260
|
+
)
|
|
1261
|
+
)}
|
|
1262
|
+
</SidekickContext.Provider>
|
|
1263
|
+
);
|
|
1264
|
+
}
|