Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure scene id props exist #4786

Merged
merged 12 commits into from
Jan 24, 2024
162 changes: 157 additions & 5 deletions editor/src/components/editor/store/dispatch.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PERFORMANCE_MARKS_ALLOWED, PRODUCTION_ENV } from '../../../common/env-vars'
import { isParseSuccess, isTextFile } from '../../../core/shared/project-file-types'
import { IS_TEST_ENVIRONMENT, PERFORMANCE_MARKS_ALLOWED } from '../../../common/env-vars'
import type { TextFile } from '../../../core/shared/project-file-types'
import {
codeNeedsParsing,
codeNeedsPrinting,
Expand Down Expand Up @@ -41,6 +41,7 @@ import type {
EditorStoreUnpatched,
} from './editor-state'
import {
StoryboardFilePath,
deriveState,
persistentModelFromEditorModel,
reconstructJSXMetadata,
Expand All @@ -54,17 +55,17 @@ import {
runLocalEditorAction,
runUpdateProjectServerState,
} from './editor-update'
import { fastForEach, isBrowserEnvironment } from '../../../core/shared/utils'
import { assertNever, isBrowserEnvironment } from '../../../core/shared/utils'
import type { UiJsxCanvasContextData } from '../../canvas/ui-jsx-canvas'
import type { ProjectContentTreeRoot } from '../../assets'
import { treeToContents, walkContentsTree } from '../../assets'
import { addFileToProjectContents, getContentsTreeFromPath, treeToContents } from '../../assets'
import { isSendPreviewModel, restoreDerivedState, UPDATE_FNS } from '../actions/actions'
import { getTransitiveReverseDependencies } from '../../../core/shared/project-contents-dependencies'
import {
reduxDevtoolsSendActions,
reduxDevtoolsUpdateState,
} from '../../../core/shared/redux-devtools'
import { isEmptyObject, pick } from '../../../core/shared/object-utils'
import { isEmptyObject, objectMap, pick } from '../../../core/shared/object-utils'
import type { ProjectChanges } from './vscode-changes'
import {
emptyProjectChanges,
Expand All @@ -91,6 +92,21 @@ import { maybeClearPseudoInsertMode } from '../canvas-toolbar-states'
import { isSteganographyEnabled } from '../../../core/shared/stegano-text'
import { updateCollaborativeProjectContents } from './collaborative-editing'
import { updateProjectServerStateInStore } from './project-server-state'
import {
jsExpressionValue,
type ElementsWithin,
type JSXElement,
type JSXElementChild,
emptyComments,
getJSXAttribute,
} from '../../../core/shared/element-template'
import {
isRemixSceneAgainstImports,
isSceneAgainstImports,
} from '../../../core/model/project-file-utils'
import * as PP from '../../../core/shared/property-path'
import { setJSXValueAtPath } from '../../../core/shared/jsx-attributes'
import { isLeft } from '../../../core/shared/either'

type DispatchResultFields = {
nothingChanged: boolean
Expand Down Expand Up @@ -798,6 +814,141 @@ function applyProjectChangesToEditor(
}
}

function walkJSXElementChild(
element: JSXElementChild,
bkrmendy marked this conversation as resolved.
Show resolved Hide resolved
transform: (_: JSXElementChild) => JSXElementChild,
): JSXElementChild {
switch (element.type) {
case 'ATTRIBUTE_FUNCTION_CALL':
case 'ATTRIBUTE_NESTED_ARRAY':
case 'ATTRIBUTE_NESTED_OBJECT':
case 'ATTRIBUTE_OTHER_JAVASCRIPT':
case 'ATTRIBUTE_VALUE':
case 'JSX_TEXT_BLOCK':
return element
case 'JSX_CONDITIONAL_EXPRESSION':
const whenTrue = transform(element.whenTrue)
const whenFalse = transform(element.whenTrue)
return transform({ ...element, whenTrue, whenFalse })
case 'JSX_FRAGMENT': {
const children = element.children.map((c) => transform(c))
return transform({ ...element, children })
}
case 'JSX_MAP_EXPRESSION':
let elementsWithin: ElementsWithin = {}
for (const [key, value] of Object.entries(element.elementsWithin)) {
elementsWithin[key] = transform(value) as JSXElement
}
return transform({ ...element, elementsWithin })
case 'JSX_ELEMENT':
const children = element.children.map((c) => transform(c))
return transform({ ...element, children })
default:
assertNever(element)
}
}

const IdPropName = 'id'

function getIdPropFromJSXElement(element: JSXElement): string | null {
const maybeIdProp = getJSXAttribute(element.props, IdPropName)
if (
maybeIdProp == null ||
maybeIdProp.type !== 'ATTRIBUTE_VALUE' ||
typeof maybeIdProp.value !== 'string'
) {
return null
}
return maybeIdProp.value
}

function setIdPropOnJSXElement(element: JSXElement, idPropValueToUse: string): JSXElement | null {
const updatedProps = setJSXValueAtPath(
element.props,
PP.create(IdPropName),
jsExpressionValue(idPropValueToUse, emptyComments),
)

if (IS_TEST_ENVIRONMENT || isLeft(updatedProps)) {
return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put the IS_TEST_ENVIRONMENT as the first clause in the function, to make it clear it's a separate thing

}
return { ...element, props: updatedProps.value }
}

function ensureSceneIdsExist(editor: EditorState): EditorState {
const storyboardFile = getContentsTreeFromPath(editor.projectContents, StoryboardFilePath)
if (
storyboardFile == null ||
storyboardFile.type !== 'PROJECT_CONTENT_FILE' ||
storyboardFile.content.type !== 'TEXT_FILE' ||
storyboardFile.content.fileContents.parsed.type !== 'PARSE_SUCCESS'
) {
return editor
}

let seenIdProps: Set<string> = new Set()
let anyIdPropUpdated = false
const imports = storyboardFile.content.fileContents.parsed.imports

const nextToplevelElements = storyboardFile.content.fileContents.parsed.topLevelElements.map(
(e) => {
if (e.type !== 'UTOPIA_JSX_COMPONENT') {
return e
}

const nextRootElement = walkJSXElementChild(e.rootElement, (child) => {
const isConsideredScene =
isSceneAgainstImports(child, imports) || isRemixSceneAgainstImports(child, imports)

if (child.type !== 'JSX_ELEMENT' || !isConsideredScene) {
return child
}

const idPropValue = getIdPropFromJSXElement(child)

if (idPropValue != null && !seenIdProps.has(idPropValue)) {
seenIdProps.add(idPropValue)
return child
}

const idPropValueToUse = child.uid
const updatedChild = setIdPropOnJSXElement(child, idPropValueToUse)
if (updatedChild == null) {
return child
}

seenIdProps.add(idPropValueToUse)
anyIdPropUpdated = true
return updatedChild
})

return { ...e, rootElement: nextRootElement }
},
)

if (!anyIdPropUpdated) {
return editor
}

const nextStoryboardFile: TextFile = {
...storyboardFile.content,
fileContents: {
...storyboardFile.content.fileContents,
parsed: {
...storyboardFile.content.fileContents.parsed,
topLevelElements: nextToplevelElements,
},
},
}

const nextProjectContents = addFileToProjectContents(
editor.projectContents,
StoryboardFilePath,
nextStoryboardFile,
)
return { ...editor, projectContents: nextProjectContents }
}

export const UTOPIA_IRRECOVERABLE_ERROR_MESSAGE = `Utopia has suffered from an irrecoverable error, please reload the editor.`
function editorDispatchInner(
boundDispatch: EditorDispatch,
Expand All @@ -819,6 +970,7 @@ function editorDispatchInner(
if (dispatchedActions.length > 0) {
// Run everything in a big chain.
let result = processActions(boundDispatch, storedState, dispatchedActions, spyCollector)
result.unpatchedEditor = ensureSceneIdsExist(result.unpatchedEditor)

const anyUndoOrRedo = dispatchedActions.some(isUndoOrRedo)

Expand Down
54 changes: 49 additions & 5 deletions editor/src/core/third-party/utopia-api-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
defaultElementStyle,
} from '../../components/editor/defaults'
import type { JSExpression } from '../shared/element-template'
import { emptyComments, jsxAttributesEntry, jsxElementWithoutUID } from '../shared/element-template'
import {
emptyComments,
jsExpressionValue,
jsxAttributesEntry,
jsxElementWithoutUID,
} from '../shared/element-template'

const BasicUtopiaComponentDescriptor = (
name: string,
Expand Down Expand Up @@ -45,14 +50,53 @@ const BasicUtopiaComponentDescriptor = (
}
}

const BasicUtopiaSceneDescriptor = (
name: string,
supportsChildren: boolean,
styleProp: () => JSExpression,
): ComponentDescriptor => {
return {
properties: {
style: {
control: 'style-controls',
},
},
supportsChildren: supportsChildren,
variants: [
{
insertMenuLabel: name,
importsToAdd: {
'utopia-api': {
importedWithName: null,
importedFromWithin: [
{
name: name,
alias: name,
},
],
importedAs: null,
},
},
elementToInsert: () =>
jsxElementWithoutUID(
name,
[
jsxAttributesEntry('id', jsExpressionValue('scene', emptyComments), emptyComments),
jsxAttributesEntry('style', styleProp(), emptyComments),
],
[],
),
},
],
}
}

export const UtopiaApiComponents: ComponentDescriptorsForFile = {
Ellipse: BasicUtopiaComponentDescriptor('Ellipse', false, defaultElementStyle),
Rectangle: BasicUtopiaComponentDescriptor('Rectangle', false, defaultRectangleElementStyle),
View: BasicUtopiaComponentDescriptor('View', true, defaultElementStyle),
FlexRow: BasicUtopiaComponentDescriptor('FlexRow', true, defaultFlexRowOrColStyle),
FlexCol: BasicUtopiaComponentDescriptor('FlexCol', true, defaultFlexRowOrColStyle),
Scene: BasicUtopiaComponentDescriptor('Scene', true, () => defaultSceneElementStyle(null)),
RemixScene: BasicUtopiaComponentDescriptor('RemixScene', false, () =>
defaultSceneElementStyle(null),
),
Scene: BasicUtopiaSceneDescriptor('Scene', true, () => defaultSceneElementStyle(null)),
RemixScene: BasicUtopiaSceneDescriptor('RemixScene', false, () => defaultSceneElementStyle(null)),
}
Loading