Browse Source

Merge branch 'dev' into add-init-modules

add-init-modules^2
Artur Arseniev 10 months ago
committed by GitHub
parent
commit
ff9be4d752
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      CONTRIBUTING.md
  2. 7
      docs/README.md
  3. 6
      docs/getting-started.md
  4. 4
      docs/modules/Assets.md
  5. 4
      docs/modules/Blocks.md
  6. 4
      docs/modules/Components-js.md
  7. 4
      docs/modules/Components.md
  8. 3
      docs/modules/Pages.md
  9. 4
      docs/modules/Plugins.md
  10. 4
      docs/modules/Storage.md
  11. 4
      docs/modules/Style-manager.md
  12. 4
      docs/modules/Traits.md
  13. 4
      package.json
  14. 2
      packages/cli/package.json
  15. 5
      packages/cli/src/build.ts
  16. 2
      packages/core/src/canvas/index.ts
  17. 35
      packages/core/src/commands/config/config.ts
  18. 4
      packages/core/src/commands/index.ts
  19. 623
      packages/core/src/commands/view/ComponentDrag.ts
  20. 40
      packages/core/src/data_sources/model/ComponentDataVariable.ts
  21. 19
      packages/core/src/data_sources/model/DataResolverListener.ts
  22. 2
      packages/core/src/data_sources/model/DataVariable.ts
  23. 124
      packages/core/src/data_sources/model/conditional_variables/ComponentDataCondition.ts
  24. 19
      packages/core/src/data_sources/model/conditional_variables/ConditionalOutputBase.ts
  25. 196
      packages/core/src/data_sources/model/conditional_variables/DataCondition.ts
  26. 15
      packages/core/src/data_sources/model/conditional_variables/DataConditionEvaluator.ts
  27. 2
      packages/core/src/data_sources/model/conditional_variables/constants.ts
  28. 9
      packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts
  29. 67
      packages/core/src/data_sources/model/utils.ts
  30. 91
      packages/core/src/data_sources/utils.ts
  31. 44
      packages/core/src/data_sources/view/ComponentDataConditionView.ts
  32. 17
      packages/core/src/dom_components/index.ts
  33. 3
      packages/core/src/dom_components/model/Component.ts
  34. 8
      packages/core/src/dom_components/model/ComponentResolverWatcher.ts
  35. 2
      packages/core/src/domain_abstract/model/StyleableModel.ts
  36. 2
      packages/core/src/utils/Dragger.ts
  37. 60
      packages/core/test/common.ts
  38. 34
      packages/core/test/specs/commands/index.ts
  39. 6
      packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap
  40. 12
      packages/core/test/specs/data_sources/__snapshots__/storage.ts.snap
  41. 9
      packages/core/test/specs/data_sources/jsonplaceholder.ts
  42. 15
      packages/core/test/specs/data_sources/model/ComponentDataVariable.getters-setters.ts
  43. 28
      packages/core/test/specs/data_sources/model/ComponentDataVariable.ts
  44. 147
      packages/core/test/specs/data_sources/model/conditional_variables/ComponentDataCondition.getters-setters.ts
  45. 318
      packages/core/test/specs/data_sources/model/conditional_variables/ComponentDataCondition.ts
  46. 12
      packages/core/test/specs/data_sources/serialization.ts
  47. 8
      packages/core/test/specs/data_sources/storage.ts
  48. 6
      packages/core/test/specs/data_sources/transformers.ts
  49. 664
      pnpm-lock.yaml

10
CONTRIBUTING.md

@ -37,13 +37,19 @@ Thank you for your interest in contributing to GrapesJS! We welcome all types of
pnpm install
```
5. Start the development server:
5. Run the build script:
```bash
pnpm run build
```
6. Start the development server:
```bash
pnpm start
```
6. Open `http://localhost:8080/` in your browser to see the editor in action.
7. Open `http://localhost:8080/` in your browser to see the editor in action.
## Development Workflow

7
docs/README.md

@ -2,6 +2,13 @@
[[toc]]
::: tip
Supercharge your web builder with the [Grapes Studio SDK](https://app.grapesjs.com/docs-sdk/overview/getting-started) — a customizable GrapesJS experience, complete with a polished UI that’s ready to embed.
:::
## What is GrapesJS?
At first glance one might think this is just another page/HTML builder, but it's something more. GrapesJS is a multi-purpose, Web Builder Framework, which means it allows you to easily create a drag & drop enabled builder of "things". By "things" we mean anything with HTML-like structure, which entails much more than web pages. We use HTML-like structure basically everywhere: Newsletters (eg. [MJML](https://mjml.io/)), Native Mobile Applications (eg. [React Native](https://github.com/facebook/react-native)), Native Desktop Applications (eg. [Vuido](https://vuido.mimec.org)), PDFs (eg. [React PDF](https://github.com/diegomura/react-pdf)), etc. So, for everything you can imagine as a set of elements like `<tag some="attribute">... other nested elements ...</tag>` you can create easily a GrapesJS builder around it and then use it independently in your applications.

6
docs/getting-started.md

@ -10,6 +10,12 @@ meta:
This is a step-by-step guide for anyone who wants to create their own builder with GrapesJS. This is not a comprehensive guide, just a concise overview of the most common modules. Follow along to create a page builder from scratch. Skip to the end of this page to see the [final result](#final-result).
::: tip
Looking for a customizable version of GrapesJS with an embeddable, production-ready UI? [Explore the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/overview/getting-started)
:::
## Import the library
Before you start using GrapesJS, you'll have to import it. Let's import the latest version:

4
docs/modules/Assets.md

@ -8,6 +8,10 @@ title: Asset Manager
In this section, you will see how to setup and take the full advantage of built-in Asset Manager in GrapesJS. The Asset Manager is lightweight and implements just an `image` in its core, but as you'll see next it's easy to extend and create your own asset types.
::: tip
Want an asset manager that looks great out of the box? [Try the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/configuration/assets/overview?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Configuration

4
docs/modules/Blocks.md

@ -16,6 +16,10 @@ To get a better understanding of the content in this guide, we recommend reading
This guide is referring to GrapesJS v0.17.27 or higher
:::
::: tip
Need a sleek block UI that’s easy to extend and customize? [Explore the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/configuration/blocks?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Configuration

4
docs/modules/Components-js.md

@ -11,6 +11,10 @@ This guide is referring to GrapesJS v0.16.34 or higher.<br><br>
To get a better understanding of the content in this guide, we recommend reading [Components](Components.html) and [Traits] first
:::
::: tip
Prefer a modern UI that's production-ready? [Get started with the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/configuration/components/overview?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Basic scripts

4
docs/modules/Components.md

@ -10,6 +10,10 @@ The Component is a base element of the template. It might be something simple an
This guide is referring to GrapesJS v0.15.8 or higher
:::
::: tip
Skip the boilerplate—use a refined component editor out of the box. [Checkout the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/configuration/components/overview?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## How Components work?

3
docs/modules/Pages.md

@ -10,6 +10,9 @@ The Pages module in GrapesJS allows you to create a project with multiple pages.
This guide is referring to GrapesJS v0.21.1 or higher
:::
::: tip
Want pages to just work, with a polished UI? [See how the Grapes Studio SDK does it!](https://app.grapesjs.com/docs-sdk/configuration/pages?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Initialization

4
docs/modules/Plugins.md

@ -10,6 +10,10 @@ Creating plugins in GrapesJS is pretty straightforward and here you'll get how t
This guide is referring to GrapesJS v0.21.2 or higher
:::
::: tip
Looking for plugins that are tested, verified, and built to scale? [Browse them all in the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/plugins/overview?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Basic plugin

4
docs/modules/Storage.md

@ -10,6 +10,10 @@ The Storage Manager is a built-in module that allows the persistence of your pro
This guide requires GrapesJS v0.19.\* or higher
:::
::: tip
Need more powerful and customizable storage options? [The Grapes Studio SDK has you covered.](https://app.grapesjs.com/docs-sdk/configuration/projects?utm_source=grapesjs-docs&utm_medium=tip#storage)
:::
[[toc]]
## Configuration

4
docs/modules/Style-manager.md

@ -16,6 +16,10 @@ To get a better understanding of the content in this guide, we recommend reading
This guide is referring to GrapesJS v0.18.1 or higher
:::
::: tip
Looking for a UI that is easy and ready to customize? [Checkout the Grapes Studio SDK!](https://app.grapesjs.com/docs-sdk/configuration/components/overview?utm_source=grapesjs-docs&utm_medium=tip)
:::
[[toc]]
## Configuration

4
docs/modules/Traits.md

@ -11,6 +11,10 @@ This guide is referring to GrapesJS v0.21.9 or higher.<br><br>
To get a better understanding of the content in this guide we recommend reading [Components](Components.html) first
:::
::: tip
Want traits that look great and work out of the box? [See how the Grapes Studio SDK handles it.](https://app.grapesjs.com/docs-sdk/configuration/components/properties?utm_source=grapesjs-docs&utm_medium=tip#traits)
:::
[[toc]]
## Add Traits to Components

4
package.json

@ -47,14 +47,14 @@
"eslint-config-standard-with-typescript": "43.0.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-n": "17.10.2",
"eslint-plugin-n": "17.17.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-promise": "7.1.0",
"eslint-plugin-react-hooks": "4.6.2",
"jest": "29.7.0",
"prettier": "3.3.3",
"ts-jest": "29.2.4",
"ts-loader": "9.5.1",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"typescript": "5.5.4"
},

2
packages/cli/package.json

@ -29,7 +29,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "7.25.2",
"@babel/plugin-transform-runtime": "7.25.4",
"@babel/plugin-transform-runtime": "7.26.10",
"@babel/preset-env": "7.25.4",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",

5
packages/cli/src/build.ts

@ -52,10 +52,15 @@ export const buildLocale = async (opts: BuildOptions = {}) => {
const babelOpts = { ...babelConfig(buildWebpackArgs(opts) as any) };
fs.readdirSync(localDst).forEach((file) => {
const filePath = `${localDst}/${file}`;
const esModuleFileName = filePath.replace(/\.[^.]+$/, '.mjs');
fs.copyFileSync(filePath, esModuleFileName);
const compiled = transformFileSync(filePath, babelOpts).code;
fs.writeFileSync(filePath, compiled);
});
// Remove the index.mjs as it is useless
fs.unlinkSync(`${localDst}/index.mjs`);
printRow('Locale files building completed successfully!');
};

2
packages/core/src/canvas/index.ts

@ -515,7 +515,7 @@ export default class CanvasModule extends Module<CanvasConfig> {
* @return {Object}
* @private
*/
getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts: any) {
getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts?: Record<string, unknown>) {
const zoom = this.getZoomDecimal();
const canvasView = this.getCanvasView();
const canvasPos = canvasView.getPosition(opts) ?? { top: 0, left: 0 };

35
packages/core/src/commands/config/config.ts

@ -1,4 +1,9 @@
import { CommandObject } from '../view/CommandAbstract';
import type { CommandObject, CommandOptions } from '../view/CommandAbstract';
interface CommandConfigDefaultOptions {
run?: (options: CommandOptions) => CommandOptions;
stop?: (options: CommandOptions) => CommandOptions;
}
export interface CommandsConfig {
/**
@ -19,12 +24,40 @@ export interface CommandsConfig {
* @default true
*/
strict?: boolean;
/**
* Default options for commands
* These options will be merged with the options passed when the command is run.
* This allows you to define common behavior for commands in one place.
* @default {}
* @example
* defaultOptions: {
* 'core:component-drag': {
* run: (options: Record<string, unknown>) => ({
* ...options,
* skipGuidesRender: true,
* addStyle({ component, styles, partial }) {
* component.addStyle(styles, { partial });
* },
* }),
* stop: (options: Record<string, unknown>) => ({
* ...options,
* * skipGuidesRender: true,
* addStyle({ component, styles, partial }) {
* component.addStyle(styles, { partial });
* },
* }),
* }
* }
*/
defaultOptions?: Record<string, CommandConfigDefaultOptions>;
}
const config: () => CommandsConfig = () => ({
stylePrefix: 'com-',
defaults: {},
strict: true,
defaultOptions: {},
});
export default config;

4
packages/core/src/commands/index.ts

@ -389,6 +389,8 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const editor = em.Editor;
if (!this.isActive(id) || options.force || !config.strict) {
const defaultOptionsRunFn = config.defaultOptions?.[id]?.run;
isFunction(defaultOptionsRunFn) && (options = defaultOptionsRunFn(options));
result = editor && (command as any).callRun(editor, options);
}
}
@ -412,6 +414,8 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const editor = em.Editor;
if (this.isActive(id) || options.force || !config.strict) {
const defaultOptionsStopFn = config.defaultOptions?.[id]?.stop;
isFunction(defaultOptionsStopFn) && (options = defaultOptionsStopFn(options));
result = (command as any).callStop(editor, options);
}
}

623
packages/core/src/commands/view/ComponentDrag.ts

@ -1,23 +1,18 @@
import { keys, bindAll, each, isUndefined, debounce } from 'underscore';
import Dragger from '../../utils/Dragger';
import { CommandObject } from './CommandAbstract';
type Rect = { left: number; width: number; top: number; height: number };
type OrigRect = { left: number; width: number; top: number; height: number; rect: Rect };
type Guide = {
type: string;
y: number;
x: number;
origin: HTMLElement;
originRect: OrigRect;
guide: HTMLElement;
};
import Dragger, { DraggerOptions } from '../../utils/Dragger';
import type { CommandObject } from './CommandAbstract';
import type Editor from '../../editor';
import type Component from '../../dom_components/model/Component';
import type EditorModel from '../../editor/model/Editor';
import { getComponentModel, getComponentView } from '../../utils/mixins';
import type ComponentView from '../../dom_components/view/ComponentView';
const evName = 'dmode';
// TODO: check setZoom, setCoords
export default {
run(editor, sender, opts = {}) {
run(editor, _sender, opts = {} as ComponentDragOpts) {
bindAll(
this,
'setPosition',
@ -29,28 +24,30 @@ export default {
'renderGuide',
'getGuidesTarget',
);
const { target, event, mode, dragger = {} } = opts;
const el = target.getEl();
if (!opts.target) throw new Error('Target option is required');
const config = {
doc: el.ownerDocument,
doc: opts.target.getEl()?.ownerDocument,
onStart: this.onStart,
onEnd: this.onEnd,
onDrag: this.onDrag,
getPosition: this.getPosition,
setPosition: this.setPosition,
guidesStatic: () => this.guidesStatic,
guidesTarget: () => this.guidesTarget,
...dragger,
guidesStatic: () => this.guidesStatic ?? [],
guidesTarget: () => this.guidesTarget ?? [],
...(opts.dragger ?? {}),
};
this.setupGuides();
this.opts = opts;
this.editor = editor;
this.em = editor.getModel();
this.target = target;
this.isTran = mode == 'translate';
this.target = opts.target;
this.isTran = opts.mode == 'translate';
this.guidesContainer = this.getGuidesContainer();
this.guidesTarget = this.getGuidesTarget();
this.guidesStatic = this.getGuidesStatic();
let drg = this.dragger;
if (!drg) {
@ -60,19 +57,22 @@ export default {
drg.setOptions(config);
}
event && drg.start(event);
this.toggleDrag(1);
opts.event && drg.start(opts.event);
this.toggleDrag(true);
this.em.trigger(`${evName}:start`, this.getEventOpts());
return drg;
},
getEventOpts() {
const guidesActive = this.guidesTarget?.filter((item) => item.active) ?? [];
return {
mode: this.opts.mode,
component: this.target,
target: this.target,
guidesTarget: this.guidesTarget,
guidesStatic: this.guidesStatic,
guidesMatched: this.getGuidesMatched(guidesActive),
};
},
@ -81,9 +81,9 @@ export default {
},
setupGuides() {
(this.guides || []).forEach((item: any) => {
(this.guides ?? []).forEach((item) => {
const { guide } = item;
guide && guide.parentNode.removeChild(guide);
guide?.parentNode?.removeChild(guide);
});
this.guides = [];
},
@ -93,7 +93,7 @@ export default {
if (!guidesEl) {
const { editor, em, opts } = this;
const pfx = editor.getConfig().stylePrefix;
const pfx = editor.getConfig().stylePrefix ?? '';
const elInfoX = document.createElement('div');
const elInfoY = document.createElement('div');
const guideContent = `<div class="${pfx}guide-info__line ${pfx}danger-bg">
@ -107,18 +107,18 @@ export default {
elInfoY.innerHTML = guideContent;
guidesEl.appendChild(elInfoX);
guidesEl.appendChild(elInfoY);
editor.Canvas.getGlobalToolsEl().appendChild(guidesEl);
editor.Canvas.getGlobalToolsEl()?.appendChild(guidesEl);
this.guidesEl = guidesEl;
this.elGuideInfoX = elInfoX;
this.elGuideInfoY = elInfoY;
this.elGuideInfoContentX = elInfoX.querySelector(`.${pfx}guide-info__content`);
this.elGuideInfoContentY = elInfoY.querySelector(`.${pfx}guide-info__content`);
this.elGuideInfoContentX = elInfoX.querySelector(`.${pfx}guide-info__content`) ?? undefined;
this.elGuideInfoContentY = elInfoY.querySelector(`.${pfx}guide-info__content`) ?? undefined;
em.on(
'canvas:update frame:scroll',
debounce(() => {
this.updateGuides();
opts.debug && this.guides?.forEach((item: any) => this.renderGuide(item));
opts.debug && this.guides?.forEach((item) => this.renderGuide(item));
}, 200),
);
}
@ -127,32 +127,39 @@ export default {
},
getGuidesStatic() {
let result: any = [];
let result: Guide[] = [];
const el = this.target.getEl();
const { parentNode = {} } = el;
each(parentNode.children, (item) => (result = result.concat(el !== item ? this.getElementGuides(item) : [])));
const parentNode = el?.parentElement;
if (!parentNode) return [];
each(
parentNode.children,
(item) => (result = result.concat(el !== item ? this.getElementGuides(item as HTMLElement) : [])),
);
return result.concat(this.getElementGuides(parentNode));
},
getGuidesTarget() {
return this.getElementGuides(this.target.getEl());
return this.getElementGuides(this.target.getEl()!);
},
updateGuides(guides: any) {
let lastEl: any;
let lastPos: any;
(guides || this.guides).forEach((item: any) => {
updateGuides(guides) {
let lastEl: HTMLElement;
let lastPos: ComponentOrigRect;
const guidesToUpdate = guides ?? this.guides ?? [];
guidesToUpdate.forEach((item) => {
const { origin } = item;
const pos = lastEl === origin ? lastPos : this.getElementPos(origin);
lastEl = origin;
lastPos = pos;
each(this.getGuidePosUpdate(item, pos), (val, key) => (item[key] = val));
each(this.getGuidePosUpdate(item, pos), (val, key) => {
(item as Record<string, unknown>)[key] = val;
});
item.originRect = pos;
});
},
getGuidePosUpdate(item: any, rect: any) {
getGuidePosUpdate(item, rect) {
const result: { x?: number; y?: number } = {};
const { top, height, left, width } = rect;
@ -180,16 +187,17 @@ export default {
return result;
},
renderGuide(item: any = {}) {
const el = item.guide || document.createElement('div');
renderGuide(item) {
if (this.opts.skipGuidesRender) return;
const el = item.guide ?? document.createElement('div');
const un = 'px';
const guideSize = item.active ? 2 : 1;
let numEl = el.children[0];
el.style = `position: absolute; background-color: ${item.active ? 'green' : 'red'};`;
el.style.cssText = `position: absolute; background-color: ${item.active ? 'green' : 'red'};`;
if (!el.children.length) {
numEl = document.createElement('div');
numEl.style = 'position: absolute; color: red; padding: 5px; top: 0; left: 0;';
const numEl = document.createElement('div');
numEl.style.cssText = 'position: absolute; color: red; padding: 5px; top: 0; left: 0;';
el.appendChild(numEl);
}
@ -197,7 +205,7 @@ export default {
el.style.width = '100%';
el.style.height = `${guideSize}${un}`;
el.style.top = `${item.y}${un}`;
el.style.left = 0;
el.style.left = '0';
} else {
el.style.width = `${guideSize}${un}`;
el.style.height = '100%';
@ -205,38 +213,52 @@ export default {
el.style.top = `0${un}`;
}
!item.guide && this.guidesContainer.appendChild(el);
!item.guide && this.guidesContainer?.appendChild(el);
return el;
},
getElementPos(el: HTMLElement) {
getElementPos(el) {
return this.editor.Canvas.getElementPos(el, { noScroll: 1 });
},
getElementGuides(el: HTMLElement) {
getElementGuides(el) {
const { opts } = this;
const origin = el;
const originRect = this.getElementPos(el);
const component = getComponentModel(el);
const componentView = getComponentView(el);
const { top, height, left, width } = originRect;
// @ts-ignore
const guides: Guide[] = [
const guidePoints: { type: string; x?: number; y?: number }[] = [
{ type: 't', y: top }, // Top
{ type: 'b', y: top + height }, // Bottom
{ type: 'l', x: left }, // Left
{ type: 'r', x: left + width }, // Right
{ type: 'x', x: left + width / 2 }, // Mid x
{ type: 'y', y: top + height / 2 }, // Mid y
].map((item) => ({
...item,
origin: el,
originRect,
guide: opts.debug && this.renderGuide(item),
}));
guides.forEach((item) => this.guides?.push(item));
];
const guides = guidePoints.map((guidePoint) => {
const guide = opts.debug ? this.renderGuide(guidePoint) : undefined;
return {
...guidePoint,
component,
componentView,
componentEl: origin,
origin,
componentElRect: originRect,
originRect,
guideEl: guide,
guide,
};
}) as Guide[];
guides.forEach((guidePoint) => this.guides?.push(guidePoint));
return guides;
},
getTranslate(transform: string, axis = 'x') {
getTranslate(transform, axis = 'x') {
let result = 0;
(transform || '').split(' ').forEach((item) => {
const itemStr = item.trim();
@ -246,7 +268,7 @@ export default {
return result;
},
setTranslate(transform: string, axis: string, value: string) {
setTranslate(transform, axis, value) {
const fn = `translate${axis.toUpperCase()}(`;
const val = `${fn}${value})`;
let result = (transform || '')
@ -264,35 +286,39 @@ export default {
getPosition() {
const { target, isTran } = this;
const { left, top, transform } = target.getStyle();
const targetStyle = target.getStyle();
const transform = targetStyle.transform as string | undefined;
const left = targetStyle.left as string | undefined;
const top = targetStyle.top as string | undefined;
let x = 0;
let y = 0;
if (isTran) {
if (isTran && transform) {
x = this.getTranslate(transform);
y = this.getTranslate(transform, 'y');
} else {
x = parseFloat(left || 0);
y = parseFloat(top || 0);
x = parseFloat(left ?? '0');
y = parseFloat(top ?? '0');
}
return { x, y };
},
setPosition({ x, y, end, position, width, height }: any) {
const { target, isTran, em } = this;
setPosition({ x, y, end, position, width, height }) {
const { target, isTran, em, opts } = this;
const unit = 'px';
const __p = !end; // Indicate if partial change
const left = `${parseInt(x, 10)}${unit}`;
const top = `${parseInt(y, 10)}${unit}`;
const left = `${parseInt(`${x}`, 10)}${unit}`;
const top = `${parseInt(`${y}`, 10)}${unit}`;
let styleUp = {};
if (isTran) {
let transform = target.getStyle()['transform'] || '';
let transform = (target.getStyle()?.transform ?? '') as string;
transform = this.setTranslate(transform, 'x', left);
transform = this.setTranslate(transform, 'y', top);
styleUp = { transform, __p };
target.addStyle(styleUp, { avoidStore: !end });
} else {
const adds: any = { position, width, height };
const style: any = { left, top, __p };
@ -301,10 +327,15 @@ export default {
if (prop) style[add] = prop;
});
styleUp = style;
}
if (opts.addStyle) {
opts.addStyle({ component: target, styles: styleUp, partial: !end });
} else {
target.addStyle(styleUp, { avoidStore: !end });
}
em?.Styles.__emitCmpStyleUpdate(styleUp, { components: em.getSelected() });
em.Styles.__emitCmpStyleUpdate(styleUp, { components: em.getSelected() });
},
_getDragData() {
@ -316,35 +347,37 @@ export default {
};
},
onStart(event: Event) {
onStart(event) {
const { target, editor, isTran, opts } = this;
const { center, onStart } = opts;
const { Canvas } = editor;
const style = target.getStyle();
const position = 'absolute';
const relPos = [position, 'relative'];
onStart && onStart(this._getDragData());
opts.onStart?.(this._getDragData());
if (isTran) return;
if (style.position !== position) {
let { left, top, width, height } = Canvas.offset(target.getEl());
let { left, top, width, height } = Canvas.offset(target.getEl()!);
let parent = target.parent();
let parentRel;
let parentRel = null;
// Check for the relative parent
do {
const pStyle = parent.getStyle();
parentRel = relPos.indexOf(pStyle.position) >= 0 ? parent : null;
parent = parent.parent();
const pStyle = parent?.getStyle();
const position = pStyle?.position as string | undefined;
if (position) {
parentRel = relPos.indexOf(position) >= 0 ? parent : null;
}
parent = parent?.parent();
} while (parent && !parentRel);
// Center the target to the pointer position (used in Droppable for Blocks)
if (center) {
const { x, y } = Canvas.getMouseRelativeCanvas(event);
if (opts.center) {
const { x, y } = Canvas.getMouseRelativeCanvas(event as MouseEvent);
left = x;
top = y;
} else if (parentRel) {
const offsetP = Canvas.offset(parentRel.getEl());
const offsetP = Canvas.offset(parentRel.getEl()!);
left = left - offsetP.left;
top = top - offsetP.top;
}
@ -357,102 +390,167 @@ export default {
position,
});
}
// Recalculate guides to avoid issues with the new position durin the first drag
this.guidesStatic = this.getGuidesStatic();
},
onDrag(...args: any) {
onDrag() {
const { guidesTarget, opts } = this;
const { onDrag } = opts;
this.updateGuides(guidesTarget);
opts.debug && guidesTarget.forEach((item: any) => this.renderGuide(item));
opts.guidesInfo && this.renderGuideInfo(guidesTarget.filter((item: any) => item.active));
onDrag && onDrag(this._getDragData());
opts.debug && guidesTarget?.forEach((item) => this.renderGuide(item));
opts.guidesInfo && this.renderGuideInfo(guidesTarget?.filter((item) => item.active) ?? []);
opts.onDrag?.(this._getDragData());
this.em.trigger(`${evName}:move`, this.getEventOpts());
},
onEnd(ev: Event, dragger: any, opt = {}) {
onEnd(ev, _dragger, opt) {
const { editor, opts, id } = this;
const { onEnd } = opts;
onEnd && onEnd(ev, opt, { event: ev, ...opt, ...this._getDragData() });
editor.stopCommand(id);
opts.onEnd?.(ev, opt, { event: ev, ...opt, ...this._getDragData() });
editor.stopCommand(`${id}`);
this.hideGuidesInfo();
this.em.trigger(`${evName}:end`, this.getEventOpts());
},
hideGuidesInfo() {
['X', 'Y'].forEach((item) => {
const guide = this[`elGuideInfo${item}`];
const guide = this[`elGuideInfo${item}` as ElGuideInfoKey];
if (guide) guide.style.display = 'none';
});
},
/**
* Render guides with spacing information
*/
renderGuideInfo(guides: Guide[] = []) {
const { guidesStatic } = this;
renderGuideInfo(guides = []) {
this.hideGuidesInfo();
guides.forEach((item) => {
const { origin, x } = item;
const rectOrigin = this.getElementPos(origin);
const axis = isUndefined(x) ? 'y' : 'x';
const isY = axis === 'y';
const origEdge1 = rectOrigin[isY ? 'left' : 'top'];
const origEdge1Raw = rectOrigin.rect[isY ? 'left' : 'top'];
const origEdge2 = isY ? origEdge1 + rectOrigin.width : origEdge1 + rectOrigin.height;
const origEdge2Raw = isY ? origEdge1Raw + rectOrigin.rect.width : origEdge1Raw + rectOrigin.rect.height;
const elGuideInfo = this[`elGuideInfo${axis.toUpperCase()}`];
const elGuideInfoCnt = this[`elGuideInfoContent${axis.toUpperCase()}`];
const guideInfoStyle = elGuideInfo.style;
// Find the nearest element
const res = guidesStatic
?.filter((stat) => stat.type === item.type)
.map((stat) => {
const { left, width, top, height } = stat.originRect;
const guidesMatched = this.getGuidesMatched(guides);
guidesMatched.forEach((guideMatched) => {
if (!this.opts.skipGuidesRender) {
this.renderSingleGuideInfo(guideMatched);
}
this.em.trigger(`${evName}:active`, {
...this.getEventOpts(),
...guideMatched,
});
});
},
renderSingleGuideInfo(guideMatched) {
const { posFirst, posSecond, size, sizeRaw, guide, elGuideInfo, elGuideInfoCnt } = guideMatched;
const axis = isUndefined(guide.x) ? 'y' : 'x';
const isY = axis === 'y';
const guideInfoStyle = elGuideInfo.style;
guideInfoStyle.display = '';
guideInfoStyle[isY ? 'top' : 'left'] = `${posFirst}px`;
guideInfoStyle[isY ? 'left' : 'top'] = `${posSecond}px`;
guideInfoStyle[isY ? 'width' : 'height'] = `${size}px`;
elGuideInfoCnt.innerHTML = `${Math.round(sizeRaw)}px`;
},
getGuidesMatched(guides = []) {
const { guidesStatic = [] } = this;
return guides
.map((guide) => {
const { origin, x } = guide;
const rectOrigin = this.getElementPos(origin);
const axis = isUndefined(x) ? 'y' : 'x';
const isY = axis === 'y';
// Calculate the edges of the element
const origEdge1 = rectOrigin[isY ? 'left' : 'top'];
const origEdge1Raw = rectOrigin.rect[isY ? 'left' : 'top'];
const origEdge2 = isY ? origEdge1 + rectOrigin.width : origEdge1 + rectOrigin.height;
const origEdge2Raw = isY ? origEdge1Raw + rectOrigin.rect.width : origEdge1Raw + rectOrigin.rect.height;
// Find the nearest element
const guidesMatched = guidesStatic
.filter((guideStatic) => {
// Define complementary guide types
const complementaryTypes: Record<string, string[]> = {
l: ['r', 'x'], // Left can match with Right or Middle (horizontal)
r: ['l', 'x'], // Right can match with Left or Middle (horizontal)
x: ['l', 'r'], // Middle (horizontal) can match with Left or Right
t: ['b', 'y'], // Top can match with Bottom or Middle (vertical)
b: ['t', 'y'], // Bottom can match with Top or Middle (vertical)
y: ['t', 'b'], // Middle (vertical) can match with Top or Bottom
};
// Check if the guide type matches or is complementary
return guideStatic.type === guide.type || complementaryTypes[guide.type]?.includes(guideStatic.type);
})
.map((guideStatic) => {
const { left, width, top, height } = guideStatic.originRect;
const statEdge1 = isY ? left : top;
const statEdge2 = isY ? left + width : top + height;
return {
gap: statEdge2 < origEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2,
guide: guideStatic,
};
})
.filter((item) => item.gap > 0)
.sort((a, b) => a.gap - b.gap)
.map((item) => item.guide)
// Filter the guides that don't match the position of the dragged element
.filter((item) => {
switch (guide.type) {
case 'l':
case 'r':
case 'x':
return Math.abs(item.x - guide.x) < 1;
case 't':
case 'b':
case 'y':
return Math.abs(item.y - guide.y) < 1;
default:
return false;
}
});
// TODO: consider supporting multiple guides
const firstGuideMatched = guidesMatched[0];
if (firstGuideMatched) {
const { left, width, top, height, rect } = firstGuideMatched.originRect;
const isEdge1 = isY ? left < rectOrigin.left : top < rectOrigin.top;
const statEdge1 = isY ? left : top;
const statEdge1Raw = isY ? rect.left : rect.top;
const statEdge2 = isY ? left + width : top + height;
const statEdge2Raw = isY ? rect.left + rect.width : rect.top + rect.height;
const posFirst = isY ? guide.y : guide.x;
const posSecond = isEdge1 ? statEdge2 : origEdge2;
const size = isEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2;
const sizeRaw = isEdge1 ? origEdge1Raw - statEdge2Raw : statEdge1Raw - origEdge2Raw;
const elGuideInfo = this[`elGuideInfo${axis.toUpperCase()}` as ElGuideInfoKey]!;
const elGuideInfoCnt = this[`elGuideInfoContent${axis.toUpperCase()}` as ElGuideInfoContentKey]!;
return {
gap: statEdge2 < origEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2,
guide: stat,
guide,
guidesStatic,
matched: firstGuideMatched,
posFirst,
posSecond,
size,
sizeRaw,
elGuideInfo,
elGuideInfoCnt,
};
})
.filter((item) => item.gap > 0)
.sort((a, b) => a.gap - b.gap)
.map((item) => item.guide)[0];
if (res) {
const { left, width, top, height, rect } = res.originRect;
const isEdge1 = isY ? left < rectOrigin.left : top < rectOrigin.top;
const statEdge1 = isY ? left : top;
const statEdge1Raw = isY ? rect.left : rect.top;
const statEdge2 = isY ? left + width : top + height;
const statEdge2Raw = isY ? rect.left + rect.width : rect.top + rect.height;
const posFirst = isY ? item.y : item.x;
const posSecond = isEdge1 ? statEdge2 : origEdge2;
const pos2 = `${posFirst}px`;
const size = isEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2;
const sizeRaw = isEdge1 ? origEdge1Raw - statEdge2Raw : statEdge1Raw - origEdge2Raw;
guideInfoStyle.display = '';
guideInfoStyle[isY ? 'top' : 'left'] = pos2;
guideInfoStyle[isY ? 'left' : 'top'] = `${posSecond}px`;
guideInfoStyle[isY ? 'width' : 'height'] = `${size}px`;
elGuideInfoCnt.innerHTML = `${Math.round(sizeRaw)}px`;
this.em.trigger(`${evName}:active`, {
...this.getEventOpts(),
guide: item,
guidesStatic,
matched: res,
posFirst,
posSecond,
size,
sizeRaw,
elGuideInfo,
elGuideInfoCnt,
});
}
});
} else {
return null;
}
})
.filter(Boolean) as GuideMatched[];
},
toggleDrag(enable: boolean) {
toggleDrag(enable) {
const { ppfx, editor } = this;
const methodCls = enable ? 'add' : 'remove';
const classes = [`${ppfx}is__grabbing`];
@ -461,11 +559,206 @@ export default {
classes.forEach((cls) => body.classList[methodCls](cls));
Canvas[enable ? 'startAutoscroll' : 'stopAutoscroll']();
},
} as CommandObject<
any,
{
guidesStatic?: Guide[];
guides?: Guide[];
[k: string]: any;
}
>;
// These properties values are set in the run method, they need to be initialized here to avoid TS errors
editor: undefined as unknown as Editor,
em: undefined as unknown as EditorModel,
opts: undefined as unknown as ComponentDragOpts,
target: undefined as unknown as Component,
} as CommandObject<ComponentDragOpts, ComponentDragProps>;
interface ComponentDragProps {
editor: Editor;
em?: EditorModel;
guides?: Guide[];
guidesContainer?: HTMLElement;
guidesEl?: HTMLElement;
guidesStatic?: Guide[];
guidesTarget?: Guide[];
isTran?: boolean;
opts: ComponentDragOpts;
target: Component;
elGuideInfoX?: HTMLElement;
elGuideInfoY?: HTMLElement;
elGuideInfoContentX?: HTMLElement;
elGuideInfoContentY?: HTMLElement;
dragger?: Dragger;
getEventOpts: () => ComponentDragEventProps;
stop: () => void;
setupGuides: () => void;
getGuidesContainer: () => HTMLElement;
getGuidesStatic: () => Guide[];
getGuidesTarget: () => Guide[];
updateGuides: (guides?: Guide[]) => void;
getGuidePosUpdate: (item: Guide, rect: ComponentOrigRect) => { x?: number; y?: number };
renderGuide: (item: { active?: boolean; guide?: HTMLElement; x?: number; y?: number }) => HTMLElement;
getElementPos: (el: HTMLElement) => ComponentOrigRect;
getElementGuides: (el: HTMLElement) => Guide[];
getTranslate: (transform: string, axis?: string) => number;
setTranslate: (transform: string, axis: string, value: string) => string;
getPosition: DraggerOptions['getPosition'];
setPosition: (data: any) => void; // TODO: fix any
_getDragData: () => { target: Component; parent?: Component; index?: number };
onStart: DraggerOptions['onStart'];
onDrag: DraggerOptions['onDrag'];
onEnd: DraggerOptions['onEnd'];
hideGuidesInfo: () => void;
renderGuideInfo: (guides?: Guide[]) => void;
renderSingleGuideInfo: (guideMatched: GuideMatched) => void;
getGuidesMatched: (guides?: Guide[]) => GuideMatched[];
toggleDrag: (enable?: boolean) => void;
}
type ComponentDragOpts = {
target: Component;
center?: number;
debug?: boolean;
dragger?: DraggerOptions;
event?: Event;
guidesInfo?: number;
mode?: 'absolute' | 'translate';
skipGuidesRender?: boolean;
addStyle?: (data: { component?: Component; styles?: Record<string, unknown>; partial?: boolean }) => void;
onStart?: (data: any) => Editor;
onDrag?: (data: any) => Editor;
onEnd?: (ev: Event, opt: any, data: any) => void;
};
/**
* Represents the properties of the drag events.
*/
type ComponentDragEventProps = {
/**
* The mode of the drag (absolute or translate).
*/
mode: ComponentDragOpts['mode'];
/**
* The component being dragged.
* @deprecated Use `component` instead.
*/
target: Component;
/**
* The component being dragged.
*/
component: Component;
/**
* The guides of the component being dragged.
* @deprecated Use `guidesMatched` instead.
*/
guidesTarget: Guide[];
/**
* All the guides except the ones of the component being dragged.
* @deprecated Use `guidesMatched` instead.
*/
guidesStatic: Guide[];
/**
* The guides that are being matched.
*/
guidesMatched: GuideMatched[];
};
/**
* Represents a guide used during component dragging.
*/
type Guide = {
/**
* The type of the guide (e.g., 't', 'b', 'l', 'r', 'x', 'y').
*/
type: string;
/**
* The vertical position of the guide.
*/
y: number;
/**
* The horizontal position of the guide.
*/
x: number;
/**
* The component associated with the guide.
*/
component: Component;
/**
* The view of the component associated with the guide.
*/
componentView: ComponentView;
/**
* The HTML element associated with the guide.
* @deprecated Use `componentEl` instead.
*/
origin: HTMLElement;
/**
* The HTML element associated with the guide.
*/
componentEl: HTMLElement;
/**
* The rectangle (position and dimensions) of the guide's element.
* @deprecated Use `componentElRect` instead.
*/
originRect: ComponentOrigRect;
/**
* The rectangle (position and dimensions) of the guide's element.
*/
componentElRect: ComponentOrigRect;
/**
* The HTML element representing the guide.
* @deprecated Use `guideEl` instead.
*/
guide?: HTMLElement;
/**
* The HTML element representing the guide.
*/
guideEl?: HTMLElement;
/**
* Indicates whether the guide is active.
* @todo The `active` property is not set in the code, but the value is changing.
*/
active?: boolean;
};
/**
* Represents a matched guide during component dragging.
*/
type GuideMatched = {
/**
* The static guides used for matching.
*/
guidesStatic: Guide[];
/**
* The origin component guide.
*/
guide: Guide;
/**
* The matched component guide.
*/
matched: Guide;
/**
* The primary position of the guide (either x or y depending on the axis).
*/
posFirst: number;
/**
* The secondary position of the guide (the opposite axis of posFirst).
*/
posSecond: number;
/**
* The distance between the two matched guides in pixels.
*/
size: number;
/**
* The raw distance between the two matched guides in pixels.
*/
sizeRaw: number;
/**
* The HTML element representing the guide info (line between the guides).
*/
elGuideInfo: HTMLElement;
/**
* The container element for the guide info (text content of the line).
*/
elGuideInfoCnt: HTMLElement;
};
type ComponentRect = { left: number; width: number; top: number; height: number };
type ComponentOrigRect = ComponentRect & { rect: ComponentRect };
type ElGuideInfoKey = 'elGuideInfoX' | 'elGuideInfoY';
type ElGuideInfoContentKey = 'elGuideInfoContentX' | 'elGuideInfoContentY';

40
packages/core/src/data_sources/model/ComponentDataVariable.ts

@ -1,8 +1,14 @@
import { ObjectAny } from '../../common';
import Component from '../../dom_components/model/Component';
import { ComponentOptions } from '../../dom_components/model/types';
import { ComponentDefinition, ComponentOptions, ComponentProperties } from '../../dom_components/model/types';
import { toLowerCase } from '../../utils/mixins';
import DataVariable, { DataVariableProps, DataVariableType } from './DataVariable';
export interface ComponentDataVariableProps extends ComponentProperties {
type: typeof DataVariableType;
dataResolver: DataVariableProps;
}
export default class ComponentDataVariable extends Component {
dataResolver: DataVariable;
@ -10,17 +16,20 @@ export default class ComponentDataVariable extends Component {
return {
// @ts-ignore
...super.defaults,
type: DataVariableType,
path: '',
defaultValue: '',
droppable: false,
type: DataVariableType,
dataResolver: {
path: '',
defaultValue: '',
},
};
}
constructor(props: DataVariableProps, opt: ComponentOptions) {
constructor(props: ComponentDataVariableProps, opt: ComponentOptions) {
super(props, opt);
const { type, path, defaultValue } = props;
this.dataResolver = new DataVariable({ type, path, defaultValue }, opt);
this.dataResolver = new DataVariable(props.dataResolver, opt);
this.listenToPropsChange();
}
getPath() {
@ -47,6 +56,23 @@ export default class ComponentDataVariable extends Component {
this.dataResolver.set('defaultValue', newValue);
}
private listenToPropsChange() {
this.on('change:dataResolver', () => {
this.dataResolver.set(this.get('dataResolver'));
});
}
toJSON(opts?: ObjectAny): ComponentDefinition {
const json = super.toJSON(opts);
const dataResolver = this.dataResolver.toJSON();
delete dataResolver.type;
return {
...json,
dataResolver,
};
}
static isComponent(el: HTMLElement) {
return toLowerCase(el.tagName) === DataVariableType;
}

19
packages/core/src/data_sources/model/DataResolverListener.ts

@ -4,7 +4,11 @@ import { Model } from '../../common';
import EditorModel from '../../editor/model/Editor';
import DataVariable, { DataVariableType } from './DataVariable';
import { DataResolver } from '../types';
import { DataCondition, DataConditionType } from './conditional_variables/DataCondition';
import {
DataCondition,
DataConditionOutputChangedEvent,
DataConditionType,
} from './conditional_variables/DataCondition';
import { DataCollectionVariableType } from './data_collection/constants';
import DataCollectionVariable from './data_collection/DataCollectionVariable';
@ -64,12 +68,13 @@ export default class DataResolverListener {
}
private listenToConditionalVariable(dataVariable: DataCondition): ListenerWithCallback[] {
const { em } = this;
const dataListeners = dataVariable.getDependentDataVariables().flatMap((dataVariable) => {
return this.listenToDataVariable(new DataVariable(dataVariable, { em }));
});
return dataListeners;
return [
{
obj: dataVariable,
event: DataConditionOutputChangedEvent,
callback: this.onChange,
},
];
}
private listenToDataVariable(dataVariable: DataVariable): ListenerWithCallback[] {

2
packages/core/src/data_sources/model/DataVariable.ts

@ -4,7 +4,7 @@ import EditorModel from '../../editor/model/Editor';
export const DataVariableType = 'data-variable' as const;
export interface DataVariableProps {
type: typeof DataVariableType;
type?: typeof DataVariableType;
path: string;
defaultValue?: string;
}

124
packages/core/src/data_sources/model/conditional_variables/ComponentDataCondition.ts

@ -1,61 +1,123 @@
import Component from '../../../dom_components/model/Component';
import { ComponentDefinition, ComponentOptions } from '../../../dom_components/model/types';
import {
ComponentDefinition as ComponentProperties,
ComponentDefinitionDefined,
ComponentOptions,
ToHTMLOptions,
ComponentAddType,
} from '../../../dom_components/model/types';
import { toLowerCase } from '../../../utils/mixins';
import { DataCondition, DataConditionProps, DataConditionType } from './DataCondition';
import { DataCondition, DataConditionOutputChangedEvent, DataConditionProps, DataConditionType } from './DataCondition';
import { ConditionProps } from './DataConditionEvaluator';
import { StringOperation } from './operators/StringOperator';
import { ObjectAny } from '../../../common';
import { DataConditionIfTrueType, DataConditionIfFalseType } from './constants';
export type DataConditionDisplayType = typeof DataConditionIfTrueType | typeof DataConditionIfFalseType;
export interface ComponentDataConditionProps extends ComponentProperties {
type: typeof DataConditionType;
dataResolver: DataConditionProps;
}
export default class ComponentDataCondition extends Component {
dataResolver: DataCondition;
constructor(props: DataConditionProps, opt: ComponentOptions) {
const dataConditionInstance = new DataCondition(props, { em: opt.em });
super(
{
...props,
type: DataConditionType,
components: dataConditionInstance.getDataValue(),
droppable: false,
get defaults(): ComponentDefinitionDefined {
return {
// @ts-ignore
...super.defaults,
droppable: false,
type: DataConditionType,
dataResolver: {
condition: {
left: '',
operator: StringOperation.equalsIgnoreCase,
right: '',
},
},
opt,
);
this.dataResolver = dataConditionInstance;
this.dataResolver.onValueChange = this.handleConditionChange.bind(this);
components: [
{
type: DataConditionIfTrueType,
},
{
type: DataConditionIfFalseType,
},
],
};
}
getCondition() {
return this.dataResolver.getCondition();
constructor(props: ComponentDataConditionProps, opt: ComponentOptions) {
// @ts-ignore
super(props, opt);
const { condition } = props.dataResolver;
this.dataResolver = new DataCondition({ condition }, { em: opt.em });
this.listenToPropsChange();
}
getIfTrue() {
return this.dataResolver.getIfTrue();
isTrue() {
return this.dataResolver.isTrue();
}
getIfFalse() {
return this.dataResolver.getIfFalse();
getCondition() {
return this.dataResolver.getCondition();
}
private handleConditionChange() {
this.components(this.dataResolver.getDataValue());
getIfTrueContent(): Component | undefined {
return this.components().at(0);
}
static isComponent(el: HTMLElement) {
return toLowerCase(el.tagName) === DataConditionType;
getIfFalseContent(): Component | undefined {
return this.components().at(1);
}
getOutputContent(): Component | undefined {
return this.isTrue() ? this.getIfTrueContent() : this.getIfFalseContent();
}
setCondition(newCondition: ConditionProps) {
this.dataResolver.setCondition(newCondition);
}
setIfTrue(newIfTrue: any) {
this.dataResolver.setIfTrue(newIfTrue);
setIfTrueComponents(content: ComponentAddType) {
this.setComponentsAtIndex(0, content);
}
setIfFalseComponents(content: ComponentAddType) {
this.setComponentsAtIndex(1, content);
}
getInnerHTML(opts?: ToHTMLOptions): string {
return this.getOutputContent()?.getInnerHTML(opts) ?? '';
}
private setComponentsAtIndex(index: number, newContent: ComponentAddType) {
const component = this.components().at(index);
component?.components(newContent);
}
private listenToPropsChange() {
this.on('change:dataResolver', () => {
this.dataResolver.set(this.get('dataResolver'));
});
}
setIfFalse(newIfFalse: any) {
this.dataResolver.setIfFalse(newIfFalse);
toJSON(opts?: ObjectAny): ComponentProperties {
const json = super.toJSON(opts);
const dataResolver = this.dataResolver.toJSON();
delete dataResolver.type;
delete dataResolver.ifTrue;
delete dataResolver.ifFalse;
return {
...json,
dataResolver,
};
}
toJSON(): ComponentDefinition {
return this.dataResolver.toJSON();
static isComponent(el: HTMLElement) {
return toLowerCase(el.tagName) === DataConditionType;
}
}

19
packages/core/src/data_sources/model/conditional_variables/ConditionalOutputBase.ts

@ -0,0 +1,19 @@
import Component from '../../../dom_components/model/Component';
import { ComponentDefinitionDefined, ToHTMLOptions } from '../../../dom_components/model/types';
import { toLowerCase } from '../../../utils/mixins';
import { isDataConditionDisplayType } from '../../utils';
export default class ConditionalOutputBase extends Component {
get defaults(): ComponentDefinitionDefined {
return {
// @ts-ignore
...super.defaults,
removable: false,
draggable: false,
};
}
static isComponent(el: HTMLElement) {
return isDataConditionDisplayType(toLowerCase(el.tagName));
}
}

196
packages/core/src/data_sources/model/conditional_variables/DataCondition.ts

@ -2,7 +2,7 @@ import { Model } from '../../../common';
import EditorModel from '../../../editor/model/Editor';
import DataVariable, { DataVariableProps } from '../DataVariable';
import DataResolverListener from '../DataResolverListener';
import { evaluateVariable, isDataVariable } from '../utils';
import { resolveDynamicValue, isDataVariable } from '../../utils';
import { DataConditionEvaluator, ConditionProps } from './DataConditionEvaluator';
import { AnyTypeOperation } from './operators/AnyTypeOperator';
import { BooleanOperation } from './operators/BooleanOperator';
@ -10,12 +10,14 @@ import { NumberOperation } from './operators/NumberOperator';
import { StringOperation } from './operators/StringOperator';
import { isUndefined } from 'underscore';
export const DataConditionType = 'data-condition';
export const DataConditionType = 'data-condition' as const;
export const DataConditionEvaluationChangedEvent = 'data-condition-evaluation-changed';
export const DataConditionOutputChangedEvent = 'data-condition-output-changed';
export interface ExpressionProps {
left: any;
operator: AnyTypeOperation | StringOperation | NumberOperation;
right: any;
left?: any;
operator?: AnyTypeOperation | StringOperation | NumberOperation;
right?: any;
}
export interface LogicGroupProps {
@ -24,129 +26,159 @@ export interface LogicGroupProps {
}
export interface DataConditionProps {
type: typeof DataConditionType;
type?: typeof DataConditionType;
condition: ConditionProps;
ifTrue: any;
ifFalse: any;
ifTrue?: any;
ifFalse?: any;
}
interface DataConditionPropsDefined extends Omit<DataConditionProps, 'condition'> {
condition: DataConditionEvaluator;
}
export class DataCondition extends Model<DataConditionPropsDefined> {
export class DataCondition extends Model<DataConditionProps> {
private em: EditorModel;
private resolverListeners: DataResolverListener[] = [];
private _onValueChange?: () => void;
constructor(
props: {
condition: ConditionProps;
ifTrue: any;
ifFalse: any;
},
opts: { em: EditorModel; onValueChange?: () => void },
) {
private _previousEvaluationResult: boolean | null = null;
private _conditionEvaluator: DataConditionEvaluator;
defaults() {
return {
type: DataConditionType,
condition: {
left: '',
operator: StringOperation.equalsIgnoreCase,
right: '',
},
ifTrue: {},
ifFalse: {},
};
}
constructor(props: DataConditionProps, opts: { em: EditorModel; onValueChange?: () => void }) {
if (isUndefined(props.condition)) {
opts.em.logError('No condition was provided to a conditional component.');
}
const conditionInstance = new DataConditionEvaluator({ condition: props.condition }, { em: opts.em });
super({
type: DataConditionType,
...props,
condition: conditionInstance,
});
// @ts-ignore
super(props, opts);
this.em = opts.em;
this.listenToDataVariables();
this._onValueChange = opts.onValueChange;
this.on('change:condition change:ifTrue change:ifFalse', () => {
this.listenToDataVariables();
this._onValueChange?.();
});
}
private get conditionEvaluator() {
return this.get('condition')!;
const { condition = {} } = props;
const instance = new DataConditionEvaluator({ condition }, { em: this.em });
this._conditionEvaluator = instance;
this.listenToDataVariables();
this.listenToPropsChange();
}
getCondition(): ConditionProps {
return this.get('condition')?.get('condition')!;
return this._conditionEvaluator.get('condition')!;
}
getIfTrue() {
return this.get('ifTrue')!;
return this.get('ifTrue');
}
getIfFalse() {
return this.get('ifFalse')!;
return this.get('ifFalse');
}
setCondition(condition: ConditionProps) {
this._conditionEvaluator.set('condition', condition);
this.trigger(DataConditionOutputChangedEvent, this.getDataValue());
}
setIfTrue(newIfTrue: any) {
this.set('ifTrue', newIfTrue);
}
setIfFalse(newIfFalse: any) {
this.set('ifFalse', newIfFalse);
}
isTrue(): boolean {
return this.conditionEvaluator.evaluate();
return this._conditionEvaluator.evaluate();
}
getDataValue(skipDynamicValueResolution: boolean = false): any {
const ifTrue = this.get('ifTrue');
const ifFalse = this.get('ifFalse');
const ifTrue = this.getIfTrue();
const ifFalse = this.getIfFalse();
const isConditionTrue = this.isTrue();
if (skipDynamicValueResolution) {
return isConditionTrue ? ifTrue : ifFalse;
}
return isConditionTrue ? evaluateVariable(ifTrue, this.em) : evaluateVariable(ifFalse, this.em);
return isConditionTrue ? resolveDynamicValue(ifTrue, this.em) : resolveDynamicValue(ifFalse, this.em);
}
set onValueChange(newFunction: () => void) {
this._onValueChange = newFunction;
private listenToPropsChange() {
this.on('change:condition', this.handleConditionChange.bind(this));
this.on('change:condition change:ifTrue change:ifFalse', () => {
this.listenToDataVariables();
});
}
setCondition(newCondition: ConditionProps) {
const newConditionInstance = new DataConditionEvaluator({ condition: newCondition }, { em: this.em });
this.set('condition', newConditionInstance);
private handleConditionChange() {
this.setCondition(this.get('condition')!);
}
setIfTrue(newIfTrue: any) {
this.set('ifTrue', newIfTrue);
}
private listenToDataVariables() {
// Clear previous listeners to avoid memory leaks
this.cleanupListeners();
setIfFalse(newIfFalse: any) {
this.set('ifFalse', newIfFalse);
this.setupConditionDataVariableListeners();
this.setupOutputDataVariableListeners();
}
private listenToDataVariables() {
const { em } = this;
if (!em) return;
private setupConditionDataVariableListeners() {
this._conditionEvaluator.getDependentDataVariables().forEach((variable) => {
this.addListener(variable, () => {
this.emitConditionEvaluationChange();
});
});
}
// Clear previous listeners to avoid memory leaks
this.cleanupListeners();
private setupOutputDataVariableListeners() {
const isConditionTrue = this.isTrue();
const dataVariables = this.getDependentDataVariables();
this.setupOutputVariableListener(this.getIfTrue(), isConditionTrue);
this.setupOutputVariableListener(this.getIfFalse(), !isConditionTrue);
}
dataVariables.forEach((variable) => {
const listener = new DataResolverListener({
em,
resolver: new DataVariable(variable, { em: this.em }),
onUpdate: (() => {
this._onValueChange?.();
}).bind(this),
/**
* Sets up a listener for an output variable (ifTrue or ifFalse).
* @param outputVariable - The output variable to listen to.
* @param isConditionTrue - Whether the condition is currently true.
*/
private setupOutputVariableListener(outputVariable: any, isConditionTrue: boolean) {
if (isDataVariable(outputVariable)) {
this.addListener(outputVariable, () => {
if (isConditionTrue) {
this.trigger(DataConditionOutputChangedEvent, outputVariable);
}
});
}
}
this.resolverListeners.push(listener);
private addListener(variable: DataVariableProps, onUpdate: () => void) {
const listener = new DataResolverListener({
em: this.em,
resolver: new DataVariable(variable, { em: this.em }),
onUpdate,
});
this.resolverListeners.push(listener);
}
getDependentDataVariables() {
const dataVariables: DataVariableProps[] = this.conditionEvaluator.getDependentDataVariables();
const ifTrue = this.get('ifTrue');
const ifFalse = this.get('ifFalse');
if (isDataVariable(ifTrue)) dataVariables.push(ifTrue);
if (isDataVariable(ifFalse)) dataVariables.push(ifFalse);
private emitConditionEvaluationChange() {
const currentEvaluationResult = this.isTrue();
if (this._previousEvaluationResult !== currentEvaluationResult) {
this._previousEvaluationResult = currentEvaluationResult;
this.trigger(DataConditionEvaluationChangedEvent, currentEvaluationResult);
this.emitOutputValueChange();
}
}
return dataVariables;
private emitOutputValueChange() {
const currentOutputValue = this.getDataValue();
this.trigger(DataConditionOutputChangedEvent, currentOutputValue);
}
private cleanupListeners() {
@ -154,13 +186,13 @@ export class DataCondition extends Model<DataConditionPropsDefined> {
this.resolverListeners = [];
}
toJSON() {
const ifTrue = this.get('ifTrue');
const ifFalse = this.get('ifFalse');
toJSON(): DataConditionProps {
const ifTrue = this.getIfTrue();
const ifFalse = this.getIfFalse();
return {
type: DataConditionType,
condition: this.conditionEvaluator,
condition: this._conditionEvaluator.toJSON(),
ifTrue,
ifFalse,
};

15
packages/core/src/data_sources/model/conditional_variables/DataConditionEvaluator.ts

@ -1,6 +1,6 @@
import { DataVariableProps } from '../DataVariable';
import EditorModel from '../../../editor/model/Editor';
import { evaluateVariable, isDataVariable } from '../utils';
import { resolveDynamicValue, isDataVariable } from '../../utils';
import { ExpressionProps, LogicGroupProps } from './DataCondition';
import { LogicalGroupEvaluator } from './LogicalGroupEvaluator';
import { Operator } from './operators/BaseOperator';
@ -39,9 +39,10 @@ export class DataConditionEvaluator extends Model<DataConditionEvaluatorProps> {
if (this.isExpression(condition)) {
const { left, operator, right } = condition;
const evaluateLeft = evaluateVariable(left, this.em);
const evaluateRight = evaluateVariable(right, this.em);
const evaluateLeft = resolveDynamicValue(left, this.em);
const evaluateRight = resolveDynamicValue(right, this.em);
const op = this.getOperator(evaluateLeft, operator);
if (!op) return false;
const evaluated = op.evaluate(evaluateLeft, evaluateRight);
return evaluated;
@ -54,7 +55,7 @@ export class DataConditionEvaluator extends Model<DataConditionEvaluatorProps> {
/**
* Factory method for creating operators based on the data type.
*/
private getOperator(left: any, operator: string): Operator<DataConditionOperation> {
private getOperator(left: any, operator: string | undefined): Operator<DataConditionOperation> | undefined {
const em = this.em;
if (this.isOperatorInEnum(operator, AnyTypeOperation)) {
@ -64,7 +65,9 @@ export class DataConditionEvaluator extends Model<DataConditionEvaluatorProps> {
} else if (typeof left === 'string') {
return new StringOperator(operator as StringOperation, { em });
}
throw new Error(`Unsupported data type: ${typeof left}`);
this.em?.logError(`Unsupported data type: ${typeof left}`);
return;
}
getDependentDataVariables(): DataVariableProps[] {
@ -95,7 +98,7 @@ export class DataConditionEvaluator extends Model<DataConditionEvaluatorProps> {
return condition && typeof condition.left !== 'undefined' && typeof condition.operator === 'string';
}
private isOperatorInEnum(operator: string, enumObject: any): boolean {
private isOperatorInEnum(operator: string | undefined, enumObject: any): boolean {
return Object.values(enumObject).includes(operator);
}

2
packages/core/src/data_sources/model/conditional_variables/constants.ts

@ -0,0 +1,2 @@
export const DataConditionIfTrueType = 'data-condition-true-content';
export const DataConditionIfFalseType = 'data-condition-false-content';

9
packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts

@ -7,7 +7,7 @@ import { isObject, serialize, toLowerCase } from '../../../utils/mixins';
import DataResolverListener from '../DataResolverListener';
import DataSource from '../DataSource';
import DataVariable, { DataVariableProps, DataVariableType } from '../DataVariable';
import { isDataVariable } from '../utils';
import { ensureComponentInstance, isDataVariable } from '../../utils';
import { DataCollectionType, keyCollectionDefinition, keyCollectionsStateMap, keyIsCollectionItem } from './constants';
import {
ComponentDataCollectionProps,
@ -200,12 +200,9 @@ export default class ComponentDataCollection extends Component {
};
if (isFirstItem) {
const componentType = (componentDef?.type as string) || 'default';
let type = this.em.Components.getType(componentType) || this.em.Components.getType('default');
const Model = type.model;
symbolMain = new Model(
symbolMain = ensureComponentInstance(
{
...serialize(componentDef),
...componentDef,
draggable: false,
removable: false,
},

67
packages/core/src/data_sources/model/utils.ts

@ -1,67 +0,0 @@
import EditorModel from '../../editor/model/Editor';
import { DataResolver, DataResolverProps } from '../types';
import { DataCollectionStateMap } from './data_collection/types';
import DataCollectionVariable from './data_collection/DataCollectionVariable';
import { DataCollectionVariableType } from './data_collection/constants';
import { DataConditionType, DataCondition } from './conditional_variables/DataCondition';
import DataVariable, { DataVariableProps, DataVariableType } from './DataVariable';
export function isDataResolverProps(value: any): value is DataResolverProps {
return (
typeof value === 'object' && [DataVariableType, DataConditionType, DataCollectionVariableType].includes(value?.type)
);
}
export function isDataResolver(value: any): value is DataResolver {
return value instanceof DataVariable || value instanceof DataCondition;
}
export function isDataVariable(variable: any): variable is DataVariableProps {
return variable?.type === DataVariableType;
}
export function isDataCondition(variable: any) {
return variable?.type === DataConditionType;
}
export function evaluateVariable(variable: any, em: EditorModel) {
return isDataVariable(variable) ? new DataVariable(variable, { em }).getDataValue() : variable;
}
export function getDataResolverInstance(
resolverProps: DataResolverProps,
options: { em: EditorModel; collectionsStateMap?: DataCollectionStateMap },
): DataResolver {
const { type } = resolverProps;
let resolver: DataResolver;
switch (type) {
case DataVariableType:
resolver = new DataVariable(resolverProps, options);
break;
case DataConditionType: {
resolver = new DataCondition(resolverProps, options);
break;
}
case DataCollectionVariableType: {
resolver = new DataCollectionVariable(resolverProps, options);
break;
}
default:
throw new Error(`Unsupported dynamic type: ${type}`);
}
return resolver;
}
export function getDataResolverInstanceValue(
resolverProps: DataResolverProps,
options: {
em: EditorModel;
collectionsStateMap?: DataCollectionStateMap;
},
) {
const resolver = getDataResolverInstance(resolverProps, options);
return resolver.getDataValue();
}

91
packages/core/src/data_sources/utils.ts

@ -0,0 +1,91 @@
import EditorModel from '../editor/model/Editor';
import { DataResolver, DataResolverProps } from './types';
import { DataConditionDisplayType } from './model/conditional_variables/ComponentDataCondition';
import { DataCollectionStateMap } from './model/data_collection/types';
import DataCollectionVariable from './model/data_collection/DataCollectionVariable';
import { DataCollectionVariableType } from './model/data_collection/constants';
import { DataConditionType, DataCondition } from './model/conditional_variables/DataCondition';
import DataVariable, { DataVariableProps, DataVariableType } from './model/DataVariable';
import Component from '../dom_components/model/Component';
import { ComponentDefinition, ComponentOptions } from '../dom_components/model/types';
import { serialize } from '../utils/mixins';
import { DataConditionIfFalseType, DataConditionIfTrueType } from './model/conditional_variables/constants';
export function isDataResolverProps(value: any): value is DataResolverProps {
return (
typeof value === 'object' && [DataVariableType, DataConditionType, DataCollectionVariableType].includes(value?.type)
);
}
export function isDataResolver(value: any): value is DataResolver {
return value instanceof DataVariable || value instanceof DataCondition;
}
export function isDataVariable(variable: any): variable is DataVariableProps {
return variable?.type === DataVariableType;
}
export function isDataCondition(variable: any) {
return variable?.type === DataConditionType;
}
export function resolveDynamicValue(variable: any, em: EditorModel) {
return isDataResolverProps(variable) ? getDataResolverInstanceValue(variable, { em }) : variable;
}
export function getDataResolverInstance(
resolverProps: DataResolverProps,
options: { em: EditorModel; collectionsStateMap?: DataCollectionStateMap },
) {
const { type } = resolverProps;
let resolver: DataResolver;
switch (type) {
case DataVariableType:
resolver = new DataVariable(resolverProps, options);
break;
case DataConditionType: {
resolver = new DataCondition(resolverProps, options);
break;
}
case DataCollectionVariableType: {
resolver = new DataCollectionVariable(resolverProps, options);
break;
}
default:
options.em?.logError(`Unsupported dynamic type: ${type}`);
return;
}
return resolver;
}
export function getDataResolverInstanceValue(
resolverProps: DataResolverProps,
options: {
em: EditorModel;
collectionsStateMap?: DataCollectionStateMap;
},
) {
const resolver = getDataResolverInstance(resolverProps, options);
return resolver?.getDataValue();
}
export const ensureComponentInstance = (
cmp: Component | ComponentDefinition | undefined,
opt: ComponentOptions,
): Component => {
if (cmp instanceof Component) return cmp;
const componentType = (cmp?.type as string) ?? 'default';
const defaultModel = opt.em.Components.getType('default');
const type = opt.em.Components.getType(componentType) ?? defaultModel;
const Model = type.model;
return new Model(serialize(cmp ?? {}), opt);
};
export const isDataConditionDisplayType = (type: string | undefined): type is DataConditionDisplayType => {
return !!type && [DataConditionIfTrueType, DataConditionIfFalseType].includes(type);
};

44
packages/core/src/data_sources/view/ComponentDataConditionView.ts

@ -1,4 +1,46 @@
import ComponentView from '../../dom_components/view/ComponentView';
import ComponentDataCondition from '../model/conditional_variables/ComponentDataCondition';
import DataResolverListener from '../model/DataResolverListener';
export default class ComponentDataConditionView extends ComponentView<ComponentDataCondition> {}
export default class ComponentDataConditionView extends ComponentView<ComponentDataCondition> {
dataResolverListener!: DataResolverListener;
initialize(opt = {}) {
super.initialize(opt);
this.postRender = this.postRender.bind(this);
this.listenTo(this.model.components(), 'reset', this.postRender);
this.dataResolverListener = new DataResolverListener({
em: this.em,
resolver: this.model.dataResolver,
onUpdate: this.postRender,
});
}
renderDataResolver() {
const componentTrue = this.model.getIfTrueContent();
const componentFalse = this.model.getIfFalseContent();
const elTrue = componentTrue?.getEl();
const elFalse = componentFalse?.getEl();
const isTrue = this.model.isTrue();
if (elTrue) {
elTrue.style.display = isTrue ? '' : 'none';
}
if (elFalse) {
elFalse.style.display = isTrue ? 'none' : '';
}
}
postRender() {
this.renderDataResolver();
super.postRender();
}
remove() {
this.stopListening(this.model.components(), 'reset', this.postRender);
this.dataResolverListener.destroy();
return super.remove();
}
}

17
packages/core/src/dom_components/index.ts

@ -126,13 +126,18 @@ import ComponentDataVariable from '../data_sources/model/ComponentDataVariable';
import ComponentDataVariableView from '../data_sources/view/ComponentDataVariableView';
import { DataVariableType } from '../data_sources/model/DataVariable';
import { DataConditionType } from '../data_sources/model/conditional_variables/DataCondition';
import ComponentDataCondition from '../data_sources/model/conditional_variables/ComponentDataCondition';
import ComponentDataConditionView from '../data_sources/view/ComponentDataConditionView';
import ComponentDataCollection from '../data_sources/model/data_collection/ComponentDataCollection';
import { DataCollectionType, DataCollectionVariableType } from '../data_sources/model/data_collection/constants';
import ComponentDataCollectionVariable from '../data_sources/model/data_collection/ComponentDataCollectionVariable';
import ComponentDataCollectionVariableView from '../data_sources/view/ComponentDataCollectionVariableView';
import ComponentDataCollectionView from '../data_sources/view/ComponentDataCollectionView';
import ComponentDataCondition from '../data_sources/model/conditional_variables/ComponentDataCondition';
import {
DataConditionIfFalseType,
DataConditionIfTrueType,
} from '../data_sources/model/conditional_variables/constants';
import ConditionalOutputBase from '../data_sources/model/conditional_variables/ConditionalOutputBase';
export type ComponentEvent =
| 'component:create'
@ -198,6 +203,16 @@ export interface CanMoveResult {
export default class ComponentManager extends ItemManagerModule<DomComponentsConfig, any> {
componentTypes: ComponentStackItem[] = [
{
id: DataConditionIfTrueType,
model: ConditionalOutputBase,
view: ComponentView,
},
{
id: DataConditionIfFalseType,
model: ConditionalOutputBase,
view: ComponentView,
},
{
id: DataCollectionVariableType,
model: ComponentDataCollectionVariable,

3
packages/core/src/dom_components/model/Component.ts

@ -1378,8 +1378,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
cloned.set(keySymbol, 0);
cloned.set(keySymbols, 0);
} else if (symbol) {
const mainSymbolInstances = getSymbolInstances(symbol) ?? [];
// Contains already a reference to a symbol
symbol.set(keySymbols, [...getSymbolInstances(symbol)!, cloned]);
symbol.set(keySymbols, [...mainSymbolInstances!, cloned]);
initSymbol(cloned);
} else if (opt.symbol) {
// Request to create a symbol

8
packages/core/src/dom_components/model/ComponentResolverWatcher.ts

@ -2,11 +2,7 @@ import { ObjectAny } from '../../common';
import { DataCollectionVariableType } from '../../data_sources/model/data_collection/constants';
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import {
getDataResolverInstance,
getDataResolverInstanceValue,
isDataResolverProps,
} from '../../data_sources/model/utils';
import { getDataResolverInstance, getDataResolverInstanceValue, isDataResolverProps } from '../../data_sources/utils';
import EditorModel from '../../editor/model/Editor';
import { DataResolverProps } from '../../data_sources/types';
import Component from './Component';
@ -94,7 +90,7 @@ export class ComponentResolverWatcher {
continue;
}
const resolver = getDataResolverInstance(resolverProps, { em, collectionsStateMap });
const resolver = getDataResolverInstance(resolverProps, { em, collectionsStateMap })!;
this.resolverListeners[key] = new DataResolverListener({
em,
resolver,

2
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -15,7 +15,7 @@ import {
getDataResolverInstanceValue,
isDataResolver,
isDataResolverProps,
} from '../../data_sources/model/utils';
} from '../../data_sources/utils';
import { DataResolver } from '../../data_sources/types';
export type StyleProps = Record<string, string | string[] | DataVariableProps | DataConditionProps>;

2
packages/core/src/utils/Dragger.ts

@ -13,7 +13,7 @@ type Guide = {
active?: boolean;
};
interface DraggerOptions {
export interface DraggerOptions {
/**
* Element on which the drag will be executed. By default, the document will be used
*/

60
packages/core/test/common.ts

@ -1,4 +1,11 @@
import { DataSourceManager } from '../src';
import CanvasEvents from '../src/canvas/types';
import { ObjectAny } from '../src/common';
import {
DataConditionIfFalseType,
DataConditionIfTrueType,
} from '../src/data_sources/model/conditional_variables/constants';
import { NumberOperation } from '../src/data_sources/model/conditional_variables/operators/NumberOperator';
import Editor from '../src/editor';
import { EditorConfig } from '../src/editor/config/config';
import EditorModel from '../src/editor/model/Editor';
@ -97,3 +104,56 @@ export function filterObjectForSnapshot(obj: any, parentKey: string = ''): any {
return result;
}
const baseComponent = {
type: 'text',
tagName: 'h1',
};
const createContent = (content: string) => ({
...baseComponent,
content,
});
/**
* Creates a component definition for a conditional component (ifTrue or ifFalse).
* @param type - The component type (e.g., DataConditionIfTrueType).
* @param content - The text content.
* @returns The component definition.
*/
const createConditionalComponentDef = (type: string, content: string) => ({
type,
components: [createContent(content)],
});
export const ifTrueText = 'true text';
export const newIfTrueText = 'new true text';
export const ifFalseText = 'false text';
export const newIfFalseText = 'new false text';
export const ifTrueComponentDef = createConditionalComponentDef(DataConditionIfTrueType, ifTrueText);
export const newIfTrueComponentDef = createConditionalComponentDef(DataConditionIfTrueType, newIfTrueText);
export const ifFalseComponentDef = createConditionalComponentDef(DataConditionIfFalseType, ifFalseText);
export const newIfFalseComponentDef = createConditionalComponentDef(DataConditionIfFalseType, newIfFalseText);
export function isObjectContained(received: ObjectAny, expected: ObjectAny): boolean {
return Object.keys(expected).every((key) => {
if (typeof expected[key] === 'object' && expected[key] !== null) {
return isObjectContained(received[key], expected[key]);
}
return received?.[key] === expected?.[key];
});
}
export const TRUE_CONDITION = {
left: 1,
operator: NumberOperation.greaterThan,
right: 0,
};
export const FALSE_CONDITION = {
left: 0,
operator: NumberOperation.lessThan,
right: -1,
};

34
packages/core/test/specs/commands/index.ts

@ -1,6 +1,6 @@
import Commands from '../../../src/commands';
import EditorModel from '../../../src/editor/model/Editor';
import { Command, CommandFunction } from '../../../src/commands/view/CommandAbstract';
import type Commands from '../../../src/commands';
import type { Command, CommandFunction, CommandOptions } from '../../../src/commands/view/CommandAbstract';
describe('Commands', () => {
describe('Main', () => {
@ -94,5 +94,35 @@ describe('Commands', () => {
expect(obj.isActive(commName)).toBe(false);
expect(Object.keys(obj.getActive()).length).toBe(0);
});
test('Run command and check if none, custom, and default options are passed', () => {
const customOptions = { customValue: 'customValue' };
const defaultOptions = { defaultValue: 'defaultValue' };
// Create a function that returns the options
const runFn = (_editor: any, _sender: any, options: any) => options;
// Add the command
obj.add(commName, { run: runFn });
// Run the command without custom options
let result = obj.run(commName);
expect(result).toEqual({});
// Run the command with custom options
result = obj.run(commName, customOptions);
expect(result).toEqual(customOptions);
// Set default options for the command
obj.config.defaultOptions = {
[commName]: {
run: (options: CommandOptions) => ({ ...options, ...defaultOptions }),
},
};
// Run the command with default options
result = obj.run(commName, customOptions);
expect(result).toEqual({ ...customOptions, ...defaultOptions });
});
});
});

6
packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap

@ -13,8 +13,10 @@ exports[`DataSource Serialization .getProjectData ComponentDataVariable 1`] = `
{
"components": [
{
"defaultValue": "default",
"path": "component-serialization.id1.content",
"dataResolver": {
"defaultValue": "default",
"path": "component-serialization.id1.content",
},
"type": "data-variable",
},
],

12
packages/core/test/specs/data_sources/__snapshots__/storage.ts.snap

@ -23,8 +23,10 @@ exports[`DataSource Storage .getProjectData ComponentDataVariable 1`] = `
{
"components": [
{
"defaultValue": "default",
"path": "component-storage.id1.content",
"dataResolver": {
"defaultValue": "default",
"path": "component-storage.id1.content",
},
"type": "data-variable",
},
],
@ -84,8 +86,10 @@ exports[`DataSource Storage .loadProjectData ComponentDataVariable 1`] = `
{
"components": [
{
"defaultValue": "default",
"path": "component-storage.id1.content",
"dataResolver": {
"defaultValue": "default",
"path": "component-storage.id1.content",
},
"type": "data-variable",
},
],

9
packages/core/test/specs/data_sources/jsonplaceholder.ts

@ -89,8 +89,7 @@ describe('JsonPlaceholder Usage', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `comments.${record?.id}.name`,
dataResolver: { defaultValue: 'default', path: `comments.${record?.id}.name` },
},
],
},
@ -99,8 +98,7 @@ describe('JsonPlaceholder Usage', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `comments.${record?.id}.id`,
dataResolver: { defaultValue: 'default', path: `comments.${record?.id}.id` },
},
],
},
@ -109,8 +107,7 @@ describe('JsonPlaceholder Usage', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `comments.${record?.id}.body`,
dataResolver: { defaultValue: 'default', path: `comments.${record?.id}.body` },
},
],
},

15
packages/core/test/specs/data_sources/model/ComponentDataVariable.getters-setters.ts

@ -30,8 +30,10 @@ describe('ComponentDataVariable - setPath and setDefaultValue', () => {
test('component updates when path is changed using setPath', () => {
const cmp = cmpRoot.append({
type: DataVariableType,
defaultValue: 'default',
path: 'ds_id.id1.name',
dataResolver: {
defaultValue: 'default',
path: 'ds_id.id1.name',
},
})[0] as ComponentDataVariable;
expect(cmp.getEl()?.innerHTML).toContain('Name1');
@ -45,8 +47,7 @@ describe('ComponentDataVariable - setPath and setDefaultValue', () => {
test('component updates when default value is changed using setDefaultValue', () => {
const cmp = cmpRoot.append({
type: DataVariableType,
defaultValue: 'default',
path: 'unknown.id1.name',
dataResolver: { defaultValue: 'default', path: 'unknown.id1.name' },
})[0] as ComponentDataVariable;
expect(cmp.getEl()?.innerHTML).toContain('default');
@ -60,8 +61,7 @@ describe('ComponentDataVariable - setPath and setDefaultValue', () => {
test('component updates correctly after path and default value are changed', () => {
const cmp = cmpRoot.append({
type: DataVariableType,
defaultValue: 'default',
path: 'ds_id.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds_id.id1.name' },
})[0] as ComponentDataVariable;
expect(cmp.getEl()?.innerHTML).toContain('Name1');
@ -78,8 +78,7 @@ describe('ComponentDataVariable - setPath and setDefaultValue', () => {
test('component updates correctly after path is changed and data is updated', () => {
const cmp = cmpRoot.append({
type: DataVariableType,
defaultValue: 'default',
path: 'ds_id.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds_id.id1.name' },
})[0] as ComponentDataVariable;
expect(cmp.getEl()?.innerHTML).toContain('Name1');

28
packages/core/test/specs/data_sources/model/ComponentDataVariable.ts

@ -1,7 +1,6 @@
import DataSourceManager from '../../../../src/data_sources';
import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper';
import { DataVariableType } from '../../../../src/data_sources/model/DataVariable';
import { DataSourceProps } from '../../../../src/data_sources/types';
import { setupTestEditor } from '../../../common';
import EditorModel from '../../../../src/editor/model/Editor';
@ -31,8 +30,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'ds1.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds1.id1.name' },
},
],
})[0];
@ -54,8 +52,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'ds2.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds2.id1.name' },
},
],
})[0];
@ -77,8 +74,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'unknown.id1.name',
dataResolver: { defaultValue: 'default', path: 'unknown.id1.name' },
},
],
})[0];
@ -99,8 +95,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'ds3.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds3.id1.name' },
},
],
})[0];
@ -126,8 +121,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `${dataSource.id}.id1.name`,
dataResolver: { defaultValue: 'default', path: `${dataSource.id}.id1.name` },
},
],
})[0];
@ -155,8 +149,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'ds4.id1.name',
dataResolver: { defaultValue: 'default', path: 'ds4.id1.name' },
},
],
})[0];
@ -191,8 +184,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'dsNestedObject.id1.nestedObject.name',
dataResolver: { defaultValue: 'default', path: 'dsNestedObject.id1.nestedObject.name' },
},
],
})[0];
@ -232,8 +224,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'dsNestedArray.id1.items.0.nestedObject.name',
dataResolver: { defaultValue: 'default', path: 'dsNestedArray.id1.items.0.nestedObject.name' },
},
],
})[0];
@ -268,8 +259,7 @@ describe('ComponentDataVariable', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `${dataSource.id}.id1.content`,
dataResolver: { defaultValue: 'default', path: `${dataSource.id}.id1.content` },
},
],
style: {

147
packages/core/test/specs/data_sources/model/conditional_variables/ComponentDataCondition.getters-setters.ts

@ -1,22 +1,32 @@
import { Component, DataSourceManager, Editor } from '../../../../../src';
import { DataSourceManager } from '../../../../../src';
import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable';
import ComponentDataCondition from '../../../../../src/data_sources/model/conditional_variables/ComponentDataCondition';
import { DataConditionType } from '../../../../../src/data_sources/model/conditional_variables/DataCondition';
import { AnyTypeOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/AnyTypeOperator';
import { NumberOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/NumberOperator';
import ComponentDataConditionView from '../../../../../src/data_sources/view/ComponentDataConditionView';
import ComponentWrapper from '../../../../../src/dom_components/model/ComponentWrapper';
import EditorModel from '../../../../../src/editor/model/Editor';
import { setupTestEditor } from '../../../../common';
import {
ifFalseText,
setupTestEditor,
ifTrueComponentDef,
ifFalseComponentDef,
newIfTrueText,
ifTrueText,
FALSE_CONDITION,
TRUE_CONDITION,
newIfFalseText,
newIfTrueComponentDef,
newIfFalseComponentDef,
} from '../../../../common';
describe('ComponentDataCondition Setters', () => {
let editor: Editor;
let em: EditorModel;
let dsm: DataSourceManager;
let cmpRoot: ComponentWrapper;
beforeEach(() => {
({ editor, em, dsm, cmpRoot } = setupTestEditor());
({ em, dsm, cmpRoot } = setupTestEditor());
});
afterEach(() => {
@ -26,66 +36,42 @@ describe('ComponentDataCondition Setters', () => {
it('should update the condition using setCondition', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: '<h1>some text</h1>',
ifFalse: '<h1>false text</h1>',
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const newCondition = {
left: 1,
operator: NumberOperation.lessThan,
right: 0,
};
component.setCondition(newCondition);
expect(component.getCondition()).toEqual(newCondition);
expect(component.getInnerHTML()).toBe('<h1>false text</h1>');
component.setCondition(FALSE_CONDITION);
expect(component.getCondition()).toEqual(FALSE_CONDITION);
expect(component.getInnerHTML()).toContain(ifFalseText);
expect(component.getEl()?.innerHTML).toContain(ifFalseText);
});
it('should update the ifTrue value using setIfTrue', () => {
it('should update the ifTrue value using setIfTrueComponents', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: '<h1>some text</h1>',
ifFalse: '<h1>false text</h1>',
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const newIfTrue = '<h1>new true text</h1>';
component.setIfTrue(newIfTrue);
expect(component.getIfTrue()).toEqual(newIfTrue);
expect(component.getInnerHTML()).toBe(newIfTrue);
component.setIfTrueComponents(newIfTrueComponentDef.components);
expect(JSON.parse(JSON.stringify(component.getIfTrueContent()))).toEqual(newIfTrueComponentDef);
expect(component.getInnerHTML()).toContain(newIfTrueText);
expect(component.getEl()?.innerHTML).toContain(newIfTrueText);
});
it('should update the ifFalse value using setIfFalse', () => {
it('should update the ifFalse value using setIfFalseComponents', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: '<h1>some text</h1>',
ifFalse: '<h1>false text</h1>',
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const newIfFalse = '<h1>new false text</h1>';
component.setIfFalse(newIfFalse);
expect(component.getIfFalse()).toEqual(newIfFalse);
component.setIfFalseComponents(newIfFalseComponentDef.components);
expect(JSON.parse(JSON.stringify(component.getIfFalseContent()))).toEqual(newIfFalseComponentDef);
component.setCondition({
left: 0,
operator: NumberOperation.lessThan,
right: -1,
});
expect(component.getInnerHTML()).toBe(newIfFalse);
component.setCondition(FALSE_CONDITION);
expect(component.getInnerHTML()).toContain(newIfFalseText);
expect(component.getEl()?.innerHTML).toContain(newIfFalseText);
});
it('should update the data sources and re-evaluate the condition', () => {
@ -100,57 +86,54 @@ describe('ComponentDataCondition Setters', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
dataResolver: {
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
},
},
},
ifTrue: '<h1>True value</h1>',
ifFalse: '<h1>False value</h1>',
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
expect(component.getInnerHTML()).toBe('<h1>True value</h1>');
expect(component.getInnerHTML()).toContain(ifTrueText);
changeDataSourceValue(dsm, 'Different value');
expect(component.getInnerHTML()).toBe('<h1>False value</h1>');
expect(component.getInnerHTML()).toContain(ifFalseText);
expect(component.getEl()?.innerHTML).toContain(ifFalseText);
changeDataSourceValue(dsm, 'Name1');
expect(component.getInnerHTML()).toBe('<h1>True value</h1>');
expect(component.getInnerHTML()).toContain(ifTrueText);
expect(component.getEl()?.innerHTML).toContain(ifTrueText);
});
it('should re-render the component when condition, ifTrue, or ifFalse changes', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: '<h1>some text</h1>',
ifFalse: '<h1>false text</h1>',
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const componentView = component.getView() as ComponentDataConditionView;
component.setIfTrue('<h1>new true text</h1>');
expect(componentView.el.innerHTML).toContain('new true text');
component.setIfTrueComponents(newIfTrueComponentDef);
expect(component.getInnerHTML()).toContain(newIfTrueText);
expect(componentView.el.innerHTML).toContain(newIfTrueText);
component.setIfFalse('<h1>new false text</h1>');
component.setCondition({
left: 0,
operator: NumberOperation.lessThan,
right: -1,
});
expect(componentView.el.innerHTML).toContain('new false text');
component.setIfFalseComponents(newIfFalseComponentDef);
component.setCondition(FALSE_CONDITION);
expect(component.getInnerHTML()).toContain(newIfFalseText);
expect(componentView.el.innerHTML).toContain(newIfFalseText);
});
});
function changeDataSourceValue(dsm: DataSourceManager, newValue: string) {
export const changeDataSourceValue = (dsm: DataSourceManager, newValue: string) => {
dsm.get('ds1').getRecord('left_id')?.set('left', newValue);
}
};

318
packages/core/test/specs/data_sources/model/conditional_variables/ComponentDataCondition.ts

@ -1,14 +1,23 @@
import { Component, DataSourceManager, Editor } from '../../../../../src';
import { Component, Components, ComponentView, DataSourceManager, Editor } from '../../../../../src';
import { DataConditionIfTrueType } from '../../../../../src/data_sources/model/conditional_variables/constants';
import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable';
import { DataConditionType } from '../../../../../src/data_sources/model/conditional_variables/DataCondition';
import { AnyTypeOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/AnyTypeOperator';
import { NumberOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/NumberOperator';
import ComponentDataConditionView from '../../../../../src/data_sources/view/ComponentDataConditionView';
import ComponentWrapper from '../../../../../src/dom_components/model/ComponentWrapper';
import ComponentTableView from '../../../../../src/dom_components/view/ComponentTableView';
import ComponentTextView from '../../../../../src/dom_components/view/ComponentTextView';
import EditorModel from '../../../../../src/editor/model/Editor';
import { setupTestEditor } from '../../../../common';
import {
FALSE_CONDITION,
ifFalseComponentDef,
ifFalseText,
ifTrueComponentDef,
ifTrueText,
isObjectContained,
setupTestEditor,
TRUE_CONDITION,
} from '../../../../common';
import ComponentDataCondition from '../../../../../src/data_sources/model/conditional_variables/ComponentDataCondition';
describe('ComponentDataCondition', () => {
let editor: Editor;
@ -24,60 +33,34 @@ describe('ComponentDataCondition', () => {
em.destroy();
});
it('should add a component with a condition that evaluates a component definition', () => {
it('should add a component with a condition', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: {
tagName: 'h1',
type: 'text',
content: 'some text',
},
})[0];
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef],
})[0] as ComponentDataCondition;
expect(component).toBeDefined();
expect(component.get('type')).toBe(DataConditionType);
expect(component.getInnerHTML()).toBe('<h1>some text</h1>');
const componentView = component.getView();
expect(componentView).toBeInstanceOf(ComponentDataConditionView);
expect(componentView?.el.textContent).toBe('some text');
const childComponent = getFirstChild(component);
const childView = getFirstChildView(component);
expect(childComponent).toBeDefined();
expect(childComponent.get('type')).toBe('text');
expect(childComponent.getInnerHTML()).toBe('some text');
expect(childView).toBeInstanceOf(ComponentTextView);
expect(childView?.el.innerHTML).toBe('some text');
expect(component.getInnerHTML()).toContain(ifTrueText);
expect(component.getEl()?.innerHTML).toContain(ifTrueText);
const ifTrueContent = component.getIfTrueContent()!;
expect(ifTrueContent.getInnerHTML()).toContain(ifTrueText);
expect(ifTrueContent.getEl()?.textContent).toBe(ifTrueText);
expect(ifTrueContent.getEl()?.style.display).toBe('');
});
it('should add a component with a condition that evaluates a string', () => {
it('ComponentDataCondition getIfTrueContent and getIfFalseContent', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: '<h1>some text</h1>',
})[0];
expect(component).toBeDefined();
expect(component.get('type')).toBe(DataConditionType);
expect(component.getInnerHTML()).toBe('<h1>some text</h1>');
const componentView = component.getView();
expect(componentView).toBeInstanceOf(ComponentDataConditionView);
expect(componentView?.el.textContent).toBe('some text');
const childComponent = getFirstChild(component);
const childView = getFirstChildView(component);
expect(childComponent).toBeDefined();
expect(childComponent.get('type')).toBe('text');
expect(childComponent.getInnerHTML()).toBe('some text');
expect(childView).toBeInstanceOf(ComponentTextView);
expect(childView?.el.innerHTML).toBe('some text');
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
expect(JSON.parse(JSON.stringify(component.getIfTrueContent()!))).toEqual(ifTrueComponentDef);
expect(JSON.parse(JSON.stringify(component.getIfFalseContent()!))).toEqual(ifFalseComponentDef);
});
it('should test component variable with data-source', () => {
@ -92,41 +75,49 @@ describe('ComponentDataCondition', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
dataResolver: {
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
},
},
},
ifTrue: {
tagName: 'h1',
type: 'text',
content: 'Some value',
},
ifFalse: {
tagName: 'h1',
type: 'text',
content: 'False value',
},
})[0];
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
expect(component.getInnerHTML()).toContain(ifTrueText);
expect(component.getEl()?.innerHTML).toContain(ifTrueText);
const ifTrueContent = component.getIfTrueContent()!;
expect(ifTrueContent.getInnerHTML()).toContain(ifTrueText);
expect(ifTrueContent.getEl()?.textContent).toBe(ifTrueText);
expect(ifTrueContent.getEl()?.style.display).toBe('');
const childComponent = getFirstChild(component);
expect(childComponent).toBeDefined();
expect(childComponent.get('type')).toBe('text');
expect(childComponent.getInnerHTML()).toBe('Some value');
expect(component.getInnerHTML()).not.toContain(ifFalseText);
expect(component.getEl()?.innerHTML).toContain(ifFalseText);
const ifFalseContent = component.getIfFalseContent()!;
expect(ifFalseContent.getInnerHTML()).toContain(ifFalseText);
expect(ifFalseContent.getEl()?.textContent).toBe(ifFalseText);
expect(ifFalseContent.getEl()?.style.display).toBe('none');
/* Test changing datasources */
changeDataSourceValue(dsm, 'Diffirent value');
expect(getFirstChild(component).getInnerHTML()).toBe('False value');
expect(getFirstChildView(component)?.el.innerHTML).toBe('False value');
changeDataSourceValue(dsm, 'Name1');
expect(getFirstChild(component).getInnerHTML()).toBe('Some value');
expect(getFirstChildView(component)?.el.innerHTML).toBe('Some value');
const WrongValue = 'Diffirent value';
changeDataSourceValue(dsm, WrongValue);
expect(component.getEl()?.innerHTML).toContain(ifTrueText);
expect(component.getEl()?.innerHTML).toContain(ifFalseText);
expect(ifTrueContent.getEl()?.style.display).toBe('none');
expect(ifFalseContent.getEl()?.style.display).toBe('');
const CorrectValue = 'Name1';
changeDataSourceValue(dsm, CorrectValue);
expect(component.getEl()?.innerHTML).toContain(ifTrueText);
expect(component.getEl()?.innerHTML).toContain(ifFalseText);
expect(ifTrueContent.getEl()?.style.display).toBe('');
expect(ifFalseContent.getEl()?.style.display).toBe('none');
});
it('should test a conditional component with a child that is also a conditional component', () => {
@ -141,65 +132,54 @@ describe('ComponentDataCondition', () => {
const component = cmpRoot.append({
type: DataConditionType,
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
dataResolver: {
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
},
},
},
ifTrue: {
tagName: 'div',
components: [
{
components: [
{
type: DataConditionIfTrueType,
components: {
type: DataConditionType,
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
dataResolver: {
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: AnyTypeOperation.equals,
right: {
type: DataVariableType,
path: 'ds1.right_id.right',
},
},
},
ifTrue: {
tagName: 'table',
type: 'table',
},
components: ifTrueComponentDef,
},
],
},
})[0];
const innerComponent = getFirstChild(getFirstChild(component));
const innerComponentView = getFirstChildView(innerComponent);
const innerHTML = '<table><tbody><tr class="row"><td class="cell"></td></tr></tbody></table>';
expect(innerComponent.getInnerHTML()).toBe(innerHTML);
expect(innerComponentView).toBeInstanceOf(ComponentTableView);
expect(innerComponentView?.el.tagName).toBe('TABLE');
},
ifFalseComponentDef,
],
})[0] as ComponentDataCondition;
const ifTrueContent = component.getIfTrueContent()!;
expect(ifTrueContent.getInnerHTML()).toContain(ifTrueText);
expect(ifTrueContent.getEl()?.textContent).toBe(ifTrueText);
expect(ifTrueContent.getEl()?.style.display).toBe('');
});
it('should store conditional components', () => {
const conditionalCmptDef = {
type: DataConditionType,
condition: {
left: 0,
operator: NumberOperation.greaterThan,
right: -1,
},
ifTrue: [
{
tagName: 'h1',
type: 'text',
content: 'some text',
},
],
dataResolver: { condition: FALSE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
};
cmpRoot.append(conditionalCmptDef)[0];
@ -208,18 +188,84 @@ describe('ComponentDataCondition', () => {
const page = projectData.pages[0];
const frame = page.frames[0];
const storageCmptDef = frame.component.components[0];
expect(storageCmptDef).toEqual(conditionalCmptDef);
expect(isObjectContained(storageCmptDef, conditionalCmptDef)).toBe(true);
});
});
function changeDataSourceValue(dsm: DataSourceManager, newValue: string) {
dsm.get('ds1').getRecord('left_id')?.set('left', newValue);
}
it('should dynamically display ifTrue, ifFalse components in the correct order', () => {
const component = cmpRoot.append({
type: DataConditionType,
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const el = component.getEl()!;
const ifTrueEl = el.childNodes[0] as any;
const ifFalseEl = el.childNodes[1] as any;
expect(ifTrueEl.textContent).toContain(ifTrueText);
expect(ifTrueEl.style.display).toBe('');
expect(ifFalseEl.textContent).toContain(ifFalseText);
expect(ifFalseEl.style.display).toBe('none');
function getFirstChildView(component: Component) {
return getFirstChild(component).getView();
}
component.setCondition(FALSE_CONDITION);
expect(ifTrueEl.style.display).toBe('none');
expect(ifTrueEl.textContent).toContain(ifTrueText);
expect(ifFalseEl.style.display).toBe('');
expect(ifFalseEl.textContent).toContain(ifFalseText);
});
it('should dynamically update display components when data source changes', () => {
const dataSource = {
id: 'ds1',
records: [{ id: 'left_id', left: 1 }],
};
dsm.add(dataSource);
const component = cmpRoot.append({
type: DataConditionType,
dataResolver: {
condition: {
left: {
type: DataVariableType,
path: 'ds1.left_id.left',
},
operator: NumberOperation.greaterThan,
right: 0,
},
},
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const el = component.view!.el!;
const falseValue = -1;
changeDataSourceValue(dsm, falseValue);
expect(el.innerHTML).toContain(ifTrueText);
expect(el.innerHTML).toContain(ifFalseText);
const ifTrueEl = el.childNodes[0] as any;
const ifFalseEl = el.childNodes[1] as any;
expect(ifTrueEl!.style.display).toBe('none');
expect(ifTrueEl.textContent).toContain(ifTrueText);
expect(ifFalseEl.style.display).toBe('');
expect(ifFalseEl.textContent).toContain(ifFalseText);
});
function getFirstChild(component: Component) {
return component.components().at(0);
it('should update content of ifTrue, ifFalse components when condition changes', () => {
const component = cmpRoot.append({
type: DataConditionType,
dataResolver: { condition: TRUE_CONDITION },
components: [ifTrueComponentDef, ifFalseComponentDef],
})[0] as ComponentDataCondition;
const el = component.view!.el;
component.setCondition(FALSE_CONDITION);
const ifTrueEl = el.childNodes[0] as any;
const ifFalseEl = el.childNodes[1] as any;
expect(ifTrueEl!.style.display).toBe('none');
expect(ifTrueEl.textContent).toContain(ifTrueText);
expect(ifFalseEl.style.display).toBe('');
expect(ifFalseEl.textContent).toContain(ifFalseText);
});
});
function changeDataSourceValue(dsm: DataSourceManager, newValue: string | number) {
dsm.get('ds1').getRecord('left_id')?.set('left', newValue);
}

12
packages/core/test/specs/data_sources/serialization.ts

@ -53,8 +53,7 @@ describe('DataSource Serialization', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: `${componentDataSource.id}.id1.content`,
dataResolver: { defaultValue: 'default', path: `${componentDataSource.id}.id1.content` },
},
],
})[0];
@ -118,8 +117,7 @@ describe('DataSource Serialization', () => {
test('ComponentDataVariable', () => {
const dataVariable = {
type: DataVariableType,
defaultValue: 'default',
path: `${componentDataSource.id}.id1.content`,
dataResolver: { defaultValue: 'default', path: `${componentDataSource.id}.id1.content` },
};
cmpRoot.append({
@ -309,9 +307,9 @@ describe('DataSource Serialization', () => {
{
components: [
{
path: 'component-serialization.id1.content',
type: 'data-variable',
value: 'default',
type: DataVariableType,
dataResolver: { path: 'component-serialization.id1.content' },
},
],
tagName: 'h1',
@ -403,7 +401,7 @@ describe('DataSource Serialization', () => {
style: {
color: {
path: 'colors-data.id1.color',
type: 'data-variable',
type: DataVariableType,
defaultValue: 'black',
},
},

8
packages/core/test/specs/data_sources/storage.ts

@ -39,8 +39,7 @@ describe('DataSource Storage', () => {
test('ComponentDataVariable', () => {
const dataVariable = {
type: DataVariableType,
defaultValue: 'default',
path: `${storedDataSource.id}.id1.content`,
dataResolver: { defaultValue: 'default', path: `${storedDataSource.id}.id1.content` },
};
cmpRoot.append({
@ -87,9 +86,8 @@ describe('DataSource Storage', () => {
{
components: [
{
defaultValue: 'default',
path: `${storedDataSource.id}.id1.content`,
type: 'data-variable',
type: DataVariableType,
dataResolver: { defaultValue: 'default', path: `${storedDataSource.id}.id1.content` },
},
],
tagName: 'h1',

6
packages/core/test/specs/data_sources/transformers.ts

@ -41,8 +41,7 @@ describe('DataSource Transformers', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'test-data-source.id1.content',
dataResolver: { defaultValue: 'default', path: 'test-data-source.id1.content' },
},
],
})[0];
@ -85,8 +84,7 @@ describe('DataSource Transformers', () => {
components: [
{
type: DataVariableType,
defaultValue: 'default',
path: 'test-data-source.id1.content',
dataResolver: { defaultValue: 'default', path: 'test-data-source.id1.content' },
},
],
})[0];

664
pnpm-lock.yaml

File diff suppressed because it is too large
Loading…
Cancel
Save