@staffbase/design 18.8.1 → 19.0.1

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Jscodeshift codemod: Migrate deprecated Button components to the new unified Button.
3
+ *
4
+ * Transforms:
5
+ * Button (old) → Button (new API)
6
+ * ButtonDeprecated → Button (new API)
7
+ * GhostButton → Button variant="ghost"
8
+ * IconButton → Button iconOnly
9
+ * IconGhostButton → Button variant="ghost" iconOnly
10
+ *
11
+ * Old variant prop mapping:
12
+ * variant="primary" → (omit – default)
13
+ * variant="secondary" → color="neutral"
14
+ * variant="critical" → color="critical"
15
+ *
16
+ * Old icon/iconPosition props are moved into children:
17
+ * icon={<X />} iconPosition="leading" → <Button><X />text</Button>
18
+ * icon={<X />} iconPosition="trailing" → <Button>text<X /></Button>
19
+ *
20
+ * Usage:
21
+ * npx jscodeshift@latest \
22
+ * --transform node_modules/@staffbase/design/codemods/v19-migrate-button/transform.js \
23
+ * --extensions tsx,ts \
24
+ * --parser tsx \
25
+ * src/
26
+ */
27
+
28
+ const DESIGN_PKG = '@staffbase/design';
29
+
30
+ // Each deprecated component and how it maps to the new Button.
31
+ const DEPRECATED = {
32
+ /** Old solid Button. Only transformed when old-style props are detected. */
33
+ Button: {},
34
+ ButtonDeprecated: {},
35
+ GhostButton: {addVariant: 'ghost'},
36
+ IconButton: {addIconOnly: true, iconContentOnly: true},
37
+ IconGhostButton: {addVariant: 'ghost', addIconOnly: true, iconContentOnly: true},
38
+ };
39
+
40
+ // Old variant value → new color value. null = default primary, omit the prop.
41
+ const VARIANT_TO_COLOR = {
42
+ primary: null,
43
+ secondary: 'neutral',
44
+ critical: 'critical',
45
+ };
46
+
47
+ // Presence of any of these props on <Button> indicates v18 API usage.
48
+ const OLD_API_PROPS = new Set(['icon', 'iconPosition']);
49
+ const OLD_API_VARIANT_VALUES = new Set(['primary', 'secondary', 'critical']);
50
+
51
+ // ── Entry point ──────────────────────────────────────────────────────────────
52
+
53
+ export default function transform(file, api) {
54
+ const j = api.jscodeshift;
55
+ const root = j(file.source);
56
+ let dirty = false;
57
+
58
+ // 1. Find @staffbase/design import.
59
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
60
+ if (!designImports.length) return undefined;
61
+
62
+ // 2. Collect deprecated component imports: localName → { importedName, config }
63
+ const deprecated = new Map();
64
+ let buttonAlreadyImported = false;
65
+
66
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
67
+ const importedName = spec.node.imported.name;
68
+ const localName = spec.node.local ? spec.node.local.name : importedName;
69
+ const config = DEPRECATED[importedName];
70
+
71
+ if (config !== undefined) {
72
+ deprecated.set(localName, {importedName, config});
73
+ } else if (importedName === 'Button') {
74
+ buttonAlreadyImported = true;
75
+ }
76
+ });
77
+
78
+ if (!deprecated.size) return undefined;
79
+
80
+ // 3. Transform JSX usages.
81
+ deprecated.forEach(({importedName, config}, localName) => {
82
+ root.find(j.JSXElement, {openingElement: {name: {name: localName}}}).forEach((path) => {
83
+ if (migrateElement(j, path, importedName, config)) dirty = true;
84
+ });
85
+ });
86
+
87
+ if (!dirty) return undefined;
88
+
89
+ // 4. Update import specifiers.
90
+ // Remove deprecated ones (except old "Button" which maps to the new Button).
91
+ const toRemove = new Set();
92
+ deprecated.forEach(({importedName}) => {
93
+ if (importedName !== 'Button') toRemove.add(importedName);
94
+ });
95
+
96
+ if (toRemove.size) {
97
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
98
+ if (toRemove.has(spec.node.imported.name)) j(spec).remove();
99
+ });
100
+ }
101
+
102
+ // Add Button specifier if none of the migrated components already provide it.
103
+ const buttonWasMigrated = deprecated.has('Button') || deprecated.has('ButtonDeprecated');
104
+ if (!buttonAlreadyImported && !buttonWasMigrated) {
105
+ designImports.get().node.specifiers.push(j.importSpecifier(j.identifier('Button')));
106
+ }
107
+
108
+ // 5. Remove type imports for deprecated types (TypeScript projects).
109
+ const deprecatedTypes = new Set([
110
+ 'ButtonDeprecatedProps',
111
+ 'BaseButtonProps',
112
+ 'ButtonVariant',
113
+ 'ButtonIconPosition',
114
+ 'GhostButtonProps',
115
+ 'BaseGhostButtonProps',
116
+ 'GhostButtonVariant',
117
+ 'GhostButtonIconPosition',
118
+ 'IconGhostButtonProps',
119
+ 'IconButtonProps',
120
+ ]);
121
+
122
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
123
+ if (deprecatedTypes.has(spec.node.imported.name)) j(spec).remove();
124
+ });
125
+
126
+ return root.toSource({quote: 'single'});
127
+ }
128
+
129
+ // ── Element migration ────────────────────────────────────────────────────────
130
+
131
+ function migrateElement(j, path, importedName, config) {
132
+ const {openingElement, closingElement} = path.node;
133
+ const existingAttrs = openingElement.attributes;
134
+
135
+ // For the old solid `Button`, only migrate if old-style props are present.
136
+ // This avoids touching any usages that are already on the new API.
137
+ if (importedName === 'Button') {
138
+ const hasOldStyleProps = existingAttrs.some((attr) => {
139
+ if (attr.type !== 'JSXAttribute') return false;
140
+ const name = attr.name.name;
141
+ if (OLD_API_PROPS.has(name)) return true;
142
+ if (name === 'variant') {
143
+ const val = getStringValue(attr.value);
144
+ return val !== null && OLD_API_VARIANT_VALUES.has(val);
145
+ }
146
+ return false;
147
+ });
148
+ if (!hasOldStyleProps) return false;
149
+ }
150
+
151
+ // Index attributes by name.
152
+ const attrByName = new Map();
153
+ const spreadAttrs = [];
154
+
155
+ for (const attr of existingAttrs) {
156
+ if (attr.type === 'JSXSpreadAttribute') {
157
+ spreadAttrs.push(attr);
158
+ } else {
159
+ attrByName.set(attr.name.name, attr);
160
+ }
161
+ }
162
+
163
+ const variantAttr = attrByName.get('variant');
164
+ const iconAttr = attrByName.get('icon');
165
+ const iconPositionAttr = attrByName.get('iconPosition');
166
+
167
+ // Resolve new color prop.
168
+ let newColor = null; // null = use default primary, omit prop
169
+ let dynamicVariant = false;
170
+
171
+ if (variantAttr) {
172
+ const staticVal = getStringValue(variantAttr.value);
173
+ if (staticVal !== null && Object.prototype.hasOwnProperty.call(VARIANT_TO_COLOR, staticVal)) {
174
+ newColor = VARIANT_TO_COLOR[staticVal];
175
+ } else if (variantAttr.value && variantAttr.value.type === 'JSXExpressionContainer') {
176
+ // Dynamic variant — preserve the original attr and let TypeScript surface the error.
177
+ dynamicVariant = true;
178
+ }
179
+ }
180
+
181
+ // Resolve icon placement.
182
+ const iconPosition =
183
+ getStringValue(iconPositionAttr ? iconPositionAttr.value : null) === 'trailing' ? 'trailing' : 'leading';
184
+
185
+ // Build new attribute list.
186
+ const newAttrs = [];
187
+
188
+ if (config.addVariant) {
189
+ newAttrs.push(j.jsxAttribute(j.jsxIdentifier('variant'), j.stringLiteral(config.addVariant)));
190
+ }
191
+
192
+ if (newColor) {
193
+ newAttrs.push(j.jsxAttribute(j.jsxIdentifier('color'), j.stringLiteral(newColor)));
194
+ }
195
+
196
+ if (config.addIconOnly) {
197
+ newAttrs.push(j.jsxAttribute(j.jsxIdentifier('iconOnly'), null));
198
+ }
199
+
200
+ // Pass all other props through unchanged.
201
+ const DROP = new Set(['variant', 'icon', 'iconPosition']);
202
+ for (const [name, attr] of attrByName) {
203
+ if (!DROP.has(name)) newAttrs.push(attr);
204
+ }
205
+
206
+ // Preserve spread attributes.
207
+ newAttrs.push(...spreadAttrs);
208
+
209
+ // Dynamic variant: keep the original attr so TS surfaces the error to the developer.
210
+ if (dynamicVariant && variantAttr) newAttrs.push(variantAttr);
211
+
212
+ // Build new children.
213
+ let newChildren = [...path.node.children];
214
+
215
+ if (iconAttr) {
216
+ const iconNode = unwrapIcon(j, iconAttr);
217
+ if (iconNode) {
218
+ if (config.iconContentOnly) {
219
+ newChildren = [iconNode];
220
+ } else if (iconPosition === 'trailing') {
221
+ newChildren = [...newChildren, iconNode];
222
+ } else {
223
+ newChildren = [iconNode, ...newChildren];
224
+ }
225
+ }
226
+ }
227
+
228
+ // Apply changes.
229
+ openingElement.name.name = 'Button';
230
+ openingElement.attributes = newAttrs;
231
+
232
+ if (newChildren.length === 0) {
233
+ openingElement.selfClosing = true;
234
+ path.node.closingElement = null;
235
+ } else {
236
+ openingElement.selfClosing = false;
237
+ path.node.children = newChildren;
238
+ if (closingElement) {
239
+ closingElement.name.name = 'Button';
240
+ } else {
241
+ // Was self-closing (IconButton / IconGhostButton) — add closing tag.
242
+ path.node.closingElement = j.jsxClosingElement(j.jsxIdentifier('Button'));
243
+ }
244
+ }
245
+
246
+ return true;
247
+ }
248
+
249
+ // ── Helpers ──────────────────────────────────────────────────────────────────
250
+
251
+ /** Returns the string value if node is a string literal, otherwise null. */
252
+ function getStringValue(node) {
253
+ if (!node) return null;
254
+ // variant="primary"
255
+ if (node.type === 'StringLiteral' || node.type === 'Literal') return node.value;
256
+ // variant={"primary"}
257
+ if (node.type === 'JSXExpressionContainer') {
258
+ const expr = node.expression;
259
+ if (expr.type === 'StringLiteral' || expr.type === 'Literal') return expr.value;
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Extracts the icon from an `icon={…}` JSX attribute as a JSX child node.
266
+ * Returns null if the value cannot be resolved.
267
+ */
268
+ function unwrapIcon(j, iconAttr) {
269
+ const val = iconAttr.value;
270
+ if (!val || val.type !== 'JSXExpressionContainer') return null;
271
+
272
+ const expr = val.expression;
273
+ // icon={<SomeIcon />} — expression is a JSXElement, use it directly as child.
274
+ if (expr.type === 'JSXElement' || expr.type === 'JSXFragment') return expr;
275
+ // icon={someVariable} — wrap in expression container so it stays valid as a child.
276
+ return j.jsxExpressionContainer(expr);
277
+ }