-
Notifications
You must be signed in to change notification settings - Fork 172
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## Problem In order to add/remove/update tailwind classes on an element, we need a way to edit the `className` property in a granular way. ## Constraints/considerations - we can't treat the className property as a string, because we want to edit the list of classnames in it - we can't treat the className property as an array of strings, because Tailwind classes have their own syntax (negative values, selectors) that we need to respect - not all classes in className property are parseable, Tailwind class name updates should leave the non-tailwind classes alone ## Fix This PR introduces `tailwind-class-list-utils`, which provides a toolkit to edit the Tailwind classes in the className property. On top of that, that PR adds a command that provides a high-level wrapper for `tailwind-class-list-utils` that the rest of the editor can use
- Loading branch information
Showing
7 changed files
with
451 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
108 changes: 108 additions & 0 deletions
108
editor/src/components/canvas/commands/update-class-list-command.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import { mapDropNulls } from '../../../core/shared/array-utils' | ||
import * as EP from '../../../core/shared/element-path' | ||
import { emptyComments, jsExpressionValue } from '../../../core/shared/element-template' | ||
import type { ElementPath } from '../../../core/shared/project-file-types' | ||
import * as PP from '../../../core/shared/property-path' | ||
import type { PropertiesToUpdate } from '../../../core/tailwind/tailwind-class-list-utils' | ||
import { | ||
addNewClasses, | ||
getClassListFromParsedClassList, | ||
getParsedClassList, | ||
removeClasses, | ||
updateExistingClasses, | ||
} from '../../../core/tailwind/tailwind-class-list-utils' | ||
import { getTailwindConfigCached } from '../../../core/tailwind/tailwind-compilation' | ||
import { getClassNameAttribute } from '../../../core/tailwind/tailwind-options' | ||
import { getElementFromProjectContents, type EditorState } from '../../editor/store/editor-state' | ||
import { applyValuesAtPath } from './adjust-number-command' | ||
import type { BaseCommand, CommandFunction, WhenToRun } from './commands' | ||
|
||
export interface UpdateClassList extends BaseCommand { | ||
type: 'UPDATE_CLASS_LIST' | ||
element: ElementPath | ||
classNameUpdates: ClassListUpdate[] | ||
} | ||
|
||
export type ClassListUpdate = | ||
| { type: 'add'; property: string; value: string } | ||
| { type: 'remove'; property: string } | ||
|
||
export const add = ({ property, value }: { property: string; value: string }): ClassListUpdate => ({ | ||
type: 'add', | ||
property: property, | ||
value: value, | ||
}) | ||
export const remove = (property: string): ClassListUpdate => ({ | ||
type: 'remove', | ||
property: property, | ||
}) | ||
|
||
export function updateClassListCommand( | ||
whenToRun: WhenToRun, | ||
element: ElementPath, | ||
classNameUpdates: ClassListUpdate[], | ||
): UpdateClassList { | ||
return { | ||
type: 'UPDATE_CLASS_LIST', | ||
whenToRun: whenToRun, | ||
element: element, | ||
classNameUpdates: classNameUpdates, | ||
} | ||
} | ||
|
||
export const runUpdateClassList: CommandFunction<UpdateClassList> = ( | ||
editorState: EditorState, | ||
command: UpdateClassList, | ||
) => { | ||
const { element, classNameUpdates } = command | ||
|
||
const currentClassNameAttribute = getClassNameAttribute( | ||
getElementFromProjectContents(element, editorState.projectContents), | ||
)?.value | ||
|
||
if (currentClassNameAttribute == null) { | ||
return { | ||
editorStatePatches: [], | ||
commandDescription: `Update class list for ${EP.toUid(element)} with ${classNameUpdates}`, | ||
} | ||
} | ||
|
||
const parsedClassList = getParsedClassList( | ||
currentClassNameAttribute, | ||
getTailwindConfigCached(editorState), | ||
) | ||
|
||
const propertiesToRemove = mapDropNulls( | ||
(update) => (update.type !== 'remove' ? null : update.property), | ||
classNameUpdates, | ||
) | ||
|
||
const propertiesToUpdate: PropertiesToUpdate = classNameUpdates.reduce( | ||
(acc: { [property: string]: string }, val) => | ||
val.type === 'remove' ? acc : { ...acc, [val.property]: val.value }, | ||
{}, | ||
) | ||
|
||
const updatedClassList = [ | ||
removeClasses(propertiesToRemove), | ||
updateExistingClasses(propertiesToUpdate), | ||
addNewClasses(propertiesToUpdate), | ||
].reduce((classList, fn) => fn(classList), parsedClassList) | ||
|
||
const newClassList = getClassListFromParsedClassList( | ||
updatedClassList, | ||
getTailwindConfigCached(editorState), | ||
) | ||
|
||
const { editorStatePatch } = applyValuesAtPath(editorState, element, [ | ||
{ | ||
path: PP.create('className'), | ||
value: jsExpressionValue(newClassList, emptyComments), | ||
}, | ||
]) | ||
|
||
return { | ||
editorStatePatches: [editorStatePatch], | ||
commandDescription: `Update class list for ${EP.toUid(element)} to ${newClassList}`, | ||
} | ||
} |
194 changes: 194 additions & 0 deletions
194
editor/src/core/tailwind/tailwind-class-list-utils.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,194 @@ | ||
import type { TailwindClassParserResult } from './tailwind-class-list-utils' | ||
import { | ||
addNewClasses, | ||
getClassListFromParsedClassList, | ||
getParsedClassList, | ||
removeClasses, | ||
updateExistingClasses, | ||
} from './tailwind-class-list-utils' | ||
|
||
describe('tailwind class list utils', () => { | ||
describe('class list parsing / writing', () => { | ||
function classNameAstSlice(ast: TailwindClassParserResult) { | ||
switch (ast.type) { | ||
case 'unparsed': | ||
return ast | ||
case 'parsed': | ||
const slice: Record<string, unknown> = { | ||
type: ast.type, | ||
property: ast.ast.property, | ||
value: ast.ast.value, | ||
} | ||
if (ast.ast.negative) { | ||
slice.negative = true | ||
} | ||
return slice | ||
} | ||
} | ||
it('can parse the class list', () => { | ||
const classList = getParsedClassList( | ||
'p-4 m-4 lg:w-4 hover:text-red-100 -top-4 flex flex-row fancyButton', | ||
null, | ||
) | ||
expect(classList.map(classNameAstSlice)).toMatchInlineSnapshot(` | ||
Array [ | ||
Object { | ||
"property": "padding", | ||
"type": "parsed", | ||
"value": "1rem", | ||
}, | ||
Object { | ||
"property": "margin", | ||
"type": "parsed", | ||
"value": "1rem", | ||
}, | ||
Object { | ||
"property": "width", | ||
"type": "parsed", | ||
"value": "1rem", | ||
}, | ||
Object { | ||
"property": "textColor", | ||
"type": "parsed", | ||
"value": "#fee2e2", | ||
}, | ||
Object { | ||
"negative": true, | ||
"property": "positionTop", | ||
"type": "parsed", | ||
"value": "1rem", | ||
}, | ||
Object { | ||
"property": "display", | ||
"type": "parsed", | ||
"value": "flex", | ||
}, | ||
Object { | ||
"property": "flexDirection", | ||
"type": "parsed", | ||
"value": "row", | ||
}, | ||
Object { | ||
"className": "fancyButton", | ||
"type": "unparsed", | ||
}, | ||
] | ||
`) | ||
}) | ||
|
||
it('respects the tailwind config when parsing the class list', () => { | ||
const classList = getParsedClassList('flex gap-huge', { | ||
content: [], | ||
theme: { extend: { gap: { huge: '123px' } } }, | ||
}) | ||
expect(classList.map(classNameAstSlice)).toMatchInlineSnapshot(` | ||
Array [ | ||
Object { | ||
"property": "display", | ||
"type": "parsed", | ||
"value": "flex", | ||
}, | ||
Object { | ||
"property": "gap", | ||
"type": "parsed", | ||
"value": "123px", | ||
}, | ||
] | ||
`) | ||
}) | ||
|
||
it('can stringify class list', () => { | ||
const classList = getClassListFromParsedClassList( | ||
[ | ||
{ | ||
type: 'parsed', | ||
ast: { property: 'padding', value: '2rem', variants: [], negative: false }, | ||
}, | ||
{ | ||
type: 'parsed', | ||
ast: { property: 'positionTop', value: '-14px', variants: [], negative: false }, | ||
}, | ||
{ | ||
type: 'parsed', | ||
ast: { property: 'display', value: 'flex', variants: [], negative: false }, | ||
}, | ||
{ | ||
type: 'parsed', | ||
ast: { property: 'gap', value: '123px', variants: [], negative: false }, | ||
}, | ||
{ type: 'unparsed', className: 'highlight-button' }, | ||
], | ||
{ | ||
content: [], | ||
theme: { extend: { gap: { huge: '123px' } } }, | ||
}, | ||
) | ||
|
||
expect(classList).toMatchInlineSnapshot(`"p-8 -top-[14px] flex gap-huge highlight-button"`) | ||
}) | ||
|
||
it('stringifying a parsed class list yields the same class list string', () => { | ||
const startingClassList = | ||
'p-4 m-2 w-4 lg:w-8 text-black hover:text-red-200 flex flex-row fancy-button' | ||
const classList = getClassListFromParsedClassList( | ||
getParsedClassList(startingClassList, null), | ||
null, | ||
) | ||
expect(classList).toEqual(startingClassList) | ||
}) | ||
}) | ||
|
||
describe('removing classes', () => { | ||
it('can remove property', () => { | ||
const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) | ||
const updatedClassList = removeClasses(['padding', 'textColor'])(classList) | ||
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( | ||
`"m-2 w-4 flex flex-row"`, | ||
) | ||
}) | ||
it('does not remove property with selector', () => { | ||
const classList = getParsedClassList( | ||
'p-4 m-2 text-white hover:text-red-100 w-4 flex flex-row', | ||
null, | ||
) | ||
const updatedClassList = removeClasses(['padding', 'textColor'])(classList) | ||
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( | ||
`"m-2 hover:text-red-100 w-4 flex flex-row"`, | ||
) | ||
}) | ||
}) | ||
|
||
describe('updating classes', () => { | ||
it('can update class in class list', () => { | ||
const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) | ||
const updatedClassList = updateExistingClasses({ | ||
flexDirection: 'column', | ||
width: '23px', | ||
})(classList) | ||
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( | ||
`"p-4 m-2 text-white w-[23px] flex flex-col"`, | ||
) | ||
}) | ||
it('does not remove property with selector', () => { | ||
const classList = getParsedClassList('p-4 hover:p-6 m-2 text-white w-4 flex flex-row', null) | ||
const updatedClassList = updateExistingClasses({ padding: '8rem' })(classList) | ||
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( | ||
`"p-32 hover:p-6 m-2 text-white w-4 flex flex-row"`, | ||
) | ||
}) | ||
}) | ||
|
||
describe('adding new classes', () => { | ||
it('can add new class to class list', () => { | ||
const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) | ||
const updatedClassList = addNewClasses({ | ||
backgroundColor: 'white', | ||
justifyContent: 'space-between', | ||
positionLeft: '-20px', | ||
})(classList) | ||
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( | ||
`"p-4 m-2 text-white w-4 flex flex-row bg-white justify-between -left-[20px]"`, | ||
) | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.