Skip to content

Commit

Permalink
Update class list command (#6506)
Browse files Browse the repository at this point in the history
## 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
bkrmendy authored Oct 9, 2024
1 parent 8314461 commit 0ff29ca
Show file tree
Hide file tree
Showing 7 changed files with 451 additions and 3 deletions.
1 change: 1 addition & 0 deletions editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"@types/w3c-css-typed-object-model-level-1": "20180410.0.5",
"@use-it/interval": "0.1.3",
"@vercel/stega": "0.1.0",
"@xengine/tailwindcss-class-parser": "1.1.17",
"ajv": "6.4.0",
"anser": "2.1.0",
"antd": "4.3.5",
Expand Down
15 changes: 15 additions & 0 deletions editor/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion editor/src/components/canvas/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
runShowGridControlsCommand,
type ShowGridControlsCommand,
} from './show-grid-controls-command'
import type { UpdateClassList } from './update-class-list-command'
import { runUpdateClassList } from './update-class-list-command'

export interface CommandFunctionResult {
editorStatePatches: Array<EditorStatePatch>
Expand Down Expand Up @@ -135,6 +137,7 @@ export type CanvasCommand =
| SetActiveFrames
| UpdateBulkProperties
| ShowGridControlsCommand
| UpdateClassList

export function runCanvasCommand(
editorState: EditorState,
Expand Down Expand Up @@ -178,7 +181,6 @@ export function runCanvasCommand(
return runPushIntendedBoundsAndUpdateGroups(editorState, command, commandLifecycle)
case 'PUSH_INTENDED_BOUNDS_AND_UPDATE_HUGGING_ELEMENTS':
return runPushIntendedBoundsAndUpdateHuggingElements(editorState, command)

case 'DELETE_PROPERTIES':
return runDeleteProperties(editorState, command)
case 'SET_PROPERTY':
Expand Down Expand Up @@ -219,6 +221,8 @@ export function runCanvasCommand(
return runSetActiveFrames(editorState, command)
case 'SHOW_GRID_CONTROLS':
return runShowGridControlsCommand(editorState, command)
case 'UPDATE_CLASS_LIST':
return runUpdateClassList(editorState, command)
default:
const _exhaustiveCheck: never = command
throw new Error(`Unhandled canvas command ${JSON.stringify(command)}`)
Expand Down
108 changes: 108 additions & 0 deletions editor/src/components/canvas/commands/update-class-list-command.ts
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 editor/src/core/tailwind/tailwind-class-list-utils.spec.ts
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]"`,
)
})
})
})
Loading

0 comments on commit 0ff29ca

Please sign in to comment.