├── .nvmrc
├── strapi
├── src
│ ├── api
│ │ ├── .gitkeep
│ │ └── post
│ │ │ ├── routes
│ │ │ └── post.js
│ │ │ ├── services
│ │ │ └── post.js
│ │ │ ├── controllers
│ │ │ └── post.js
│ │ │ └── content-types
│ │ │ └── post
│ │ │ └── schema.json
│ ├── extensions
│ │ └── .gitkeep
│ ├── admin
│ │ ├── webpack.config.example.js
│ │ └── app.example.js
│ └── index.js
├── public
│ ├── uploads
│ │ └── .gitkeep
│ └── robots.txt
├── database
│ └── migrations
│ │ └── .gitkeep
├── .eslintignore
├── favicon.png
├── config
│ ├── api.js
│ ├── admin.js
│ ├── middlewares.js
│ ├── server.js
│ └── database.js
├── types
│ └── generated
│ │ └── components.d.ts
├── jsconfig.json
├── .env.example
├── .editorconfig
├── .eslintrc
├── package.json
├── .gitignore
└── README.md
├── .gitignore
├── .husky
└── pre-commit
├── integration-tests
├── base
│ ├── graphql-response.json
│ ├── preview.html
│ └── website.json
├── modules.html
├── visibility-truthy
│ ├── preview.html
│ └── website.json
└── graphql-modules.json
├── babel.config.js
├── tsconfig.cjs.json
├── __mocks__
└── graphql-mocks.js
├── jest.config.js
├── .github
└── workflows
│ ├── test.yml
│ └── npm-publish.yml
├── src
├── filters
│ ├── liquid.test.ts
│ └── index.ts
├── storage.ts
├── utils.test.ts
├── datasources
│ └── graphql-introspection-query.ts
├── commands.ts
├── index.ts
├── model
│ ├── dataSourceRegistry.ts
│ ├── state.test.ts
│ ├── queryBuilder.ts
│ ├── dataSourceRegistry.test.ts
│ ├── previewDataLoader.ts
│ ├── state.ts
│ ├── expressionEvaluator.ts
│ ├── completion.ts
│ ├── dataSourceManager.ts
│ ├── ExpressionTree.ts
│ └── token.ts
├── storage.test.ts
├── test-data.ts
├── view
│ ├── index.ts
│ ├── properties-editor.ts
│ ├── defaultStyles.ts
│ └── custom-states-editor.ts
└── types.ts
├── tsconfig.json
├── eslint.config.mjs
├── package.json
├── _index.html
└── example.graphql
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/strapi/src/api/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/strapi/public/uploads/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/strapi/src/extensions/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/strapi/database/migrations/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/strapi/.eslintignore:
--------------------------------------------------------------------------------
1 | .cache
2 | build
3 | **/node_modules/**
4 |
--------------------------------------------------------------------------------
/strapi/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/silexlabs/grapesjs-data-source/HEAD/strapi/favicon.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | .env
3 | .DS_Store
4 | private/
5 | /locale
6 | node_modules/
7 | *.log
8 | stats.json
9 | .vscode
10 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm install --package-lock-only --workspaces false
2 | git update-index --again
3 | npm test
4 | npm run lint
5 |
--------------------------------------------------------------------------------
/integration-tests/base/graphql-response.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "__typename": "Query",
4 | "continents": []
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/strapi/config/api.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | rest: {
3 | defaultLimit: 25,
4 | maxLimit: 100,
5 | withCount: true,
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/strapi/public/robots.txt:
--------------------------------------------------------------------------------
1 | # To prevent search engines from seeing the site altogether, uncomment the next two lines:
2 | # User-Agent: *
3 | # Disallow: /
4 |
--------------------------------------------------------------------------------
/strapi/types/generated/components.d.ts:
--------------------------------------------------------------------------------
1 | import type { Schema, Attribute } from '@strapi/strapi';
2 |
3 | declare module '@strapi/types' {
4 | export module Shared {}
5 | }
6 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ['@babel/preset-env', {targets: {node: 'current'}}],
4 | '@babel/preset-typescript',
5 | ],
6 | plugins: [],
7 | };
--------------------------------------------------------------------------------
/strapi/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "nodenext",
4 | "target": "ES2021",
5 | "checkJs": true,
6 | "allowJs": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "outDir": "./dist/cjs"
6 | },
7 | "include": ["src/**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/strapi/.env.example:
--------------------------------------------------------------------------------
1 | HOST=0.0.0.0
2 | PORT=1337
3 | APP_KEYS="toBeModified1,toBeModified2"
4 | API_TOKEN_SALT=tobemodified
5 | ADMIN_JWT_SECRET=tobemodified
6 | TRANSFER_TOKEN_SALT=tobemodified
7 | JWT_SECRET=tobemodified
8 |
--------------------------------------------------------------------------------
/strapi/src/api/post/routes/post.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * post router
5 | */
6 |
7 | const { createCoreRouter } = require('@strapi/strapi').factories;
8 |
9 | module.exports = createCoreRouter('api::post.post');
10 |
--------------------------------------------------------------------------------
/strapi/src/api/post/services/post.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * post service
5 | */
6 |
7 | const { createCoreService } = require('@strapi/strapi').factories;
8 |
9 | module.exports = createCoreService('api::post.post');
10 |
--------------------------------------------------------------------------------
/strapi/src/api/post/controllers/post.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * post controller
5 | */
6 |
7 | const { createCoreController } = require('@strapi/strapi').factories;
8 |
9 | module.exports = createCoreController('api::post.post');
10 |
--------------------------------------------------------------------------------
/strapi/config/admin.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ env }) => ({
2 | auth: {
3 | secret: env('ADMIN_JWT_SECRET'),
4 | },
5 | apiToken: {
6 | salt: env('API_TOKEN_SALT'),
7 | },
8 | transfer: {
9 | token: {
10 | salt: env('TRANSFER_TOKEN_SALT'),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/strapi/config/middlewares.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | 'strapi::errors',
3 | 'strapi::security',
4 | 'strapi::cors',
5 | 'strapi::poweredBy',
6 | 'strapi::logger',
7 | 'strapi::query',
8 | 'strapi::body',
9 | 'strapi::session',
10 | 'strapi::favicon',
11 | 'strapi::public',
12 | ];
13 |
--------------------------------------------------------------------------------
/strapi/config/server.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ env }) => ({
2 | host: env('HOST', '0.0.0.0'),
3 | port: env.int('PORT', 1337),
4 | app: {
5 | keys: env.array('APP_KEYS'),
6 | },
7 | webhooks: {
8 | populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/integration-tests/base/preview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
About this demo
4 |
5 |
6 |
--------------------------------------------------------------------------------
/strapi/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [{package.json,*.yml}]
12 | indent_style = space
13 | indent_size = 2
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/strapi/src/admin/webpack.config.example.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* eslint-disable no-unused-vars */
4 | module.exports = (config, webpack) => {
5 | // Note: we provide webpack above so you should not `require` it
6 | // Perform customizations to webpack config
7 | // Important: return the modified config
8 | return config;
9 | };
10 |
--------------------------------------------------------------------------------
/__mocks__/graphql-mocks.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 |
4 | exports.directusTestSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'directus-test-schema.json'), 'utf8'))
5 | exports.strapiSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'strapi-schema.json'), 'utf8'))
6 | exports.simpleSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'simple-schema.json'), 'utf8'))
7 | exports.squidexSchema = JSON.parse(fs.readFileSync(path.join(__dirname, 'squidex-schema.json'), 'utf8'))
8 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'jsdom',
4 | testMatch: [
5 | '**/src/**/*.test.ts'
6 | ],
7 | collectCoverageFrom: [
8 | 'src/**/*.ts',
9 | '!src/**/*.test.ts',
10 | '!src/**/test-data.ts'
11 | ],
12 | transformIgnorePatterns: [
13 | 'node_modules/(?!(@jest/.*|ts-jest|.*\\.mjs$))'
14 | ],
15 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
16 | globals: {
17 | 'ts-jest': {
18 | isolatedModules: true
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/strapi/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | /**
5 | * An asynchronous register function that runs before
6 | * your application is initialized.
7 | *
8 | * This gives you an opportunity to extend code.
9 | */
10 | register(/*{ strapi }*/) {},
11 |
12 | /**
13 | * An asynchronous bootstrap function that runs before
14 | * your application gets started.
15 | *
16 | * This gives you an opportunity to set up your data model,
17 | * run jobs, or perform some special logic.
18 | */
19 | bootstrap(/*{ strapi }*/) {},
20 | };
21 |
--------------------------------------------------------------------------------
/strapi/src/api/post/content-types/post/schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "kind": "collectionType",
3 | "collectionName": "posts",
4 | "info": {
5 | "singularName": "post",
6 | "pluralName": "posts",
7 | "displayName": "Post"
8 | },
9 | "options": {
10 | "draftAndPublish": true
11 | },
12 | "pluginOptions": {},
13 | "attributes": {
14 | "title": {
15 | "type": "string"
16 | },
17 | "content": {
18 | "type": "richtext"
19 | },
20 | "author": {
21 | "type": "relation",
22 | "relation": "oneToOne",
23 | "target": "admin::user"
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ${{ matrix.os }}
9 |
10 | strategy:
11 | matrix:
12 | node-version: [20.x]
13 | # os: [macos-latest, windows-latest, ubuntu-latest]
14 | os: [ubuntu-latest]
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | - run: npm install
23 | - run: npm run build
24 | - run: npm test
25 | env:
26 | CI: true
27 | - run: npm run lint
28 |
--------------------------------------------------------------------------------
/strapi/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "eslint:recommended",
4 | "env": {
5 | "commonjs": true,
6 | "es6": true,
7 | "node": true,
8 | "browser": false
9 | },
10 | "parserOptions": {
11 | "ecmaFeatures": {
12 | "experimentalObjectRestSpread": true,
13 | "jsx": false
14 | },
15 | "sourceType": "module"
16 | },
17 | "globals": {
18 | "strapi": true
19 | },
20 | "rules": {
21 | "indent": ["error", 2, { "SwitchCase": 1 }],
22 | "linebreak-style": ["error", "unix"],
23 | "no-console": 0,
24 | "quotes": ["error", "single"],
25 | "semi": ["error", "always"]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/strapi/src/admin/app.example.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | locales: [
3 | // 'ar',
4 | // 'fr',
5 | // 'cs',
6 | // 'de',
7 | // 'dk',
8 | // 'es',
9 | // 'he',
10 | // 'id',
11 | // 'it',
12 | // 'ja',
13 | // 'ko',
14 | // 'ms',
15 | // 'nl',
16 | // 'no',
17 | // 'pl',
18 | // 'pt-BR',
19 | // 'pt',
20 | // 'ru',
21 | // 'sk',
22 | // 'sv',
23 | // 'th',
24 | // 'tr',
25 | // 'uk',
26 | // 'vi',
27 | // 'zh-Hans',
28 | // 'zh',
29 | ],
30 | };
31 |
32 | const bootstrap = (app) => {
33 | console.log(app);
34 | };
35 |
36 | export default {
37 | config,
38 | bootstrap,
39 | };
40 |
--------------------------------------------------------------------------------
/strapi/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "strapi",
3 | "private": true,
4 | "version": "0.1.0",
5 | "description": "A Strapi application",
6 | "scripts": {
7 | "develop": "strapi develop",
8 | "start": "strapi start",
9 | "build": "strapi build",
10 | "strapi": "strapi"
11 | },
12 | "devDependencies": {},
13 | "dependencies": {
14 | "@strapi/plugin-graphql": "^4.14.4",
15 | "@strapi/plugin-i18n": "4.14.4",
16 | "@strapi/plugin-users-permissions": "4.14.4",
17 | "@strapi/strapi": "4.14.4",
18 | "better-sqlite3": "8.6.0"
19 | },
20 | "author": {
21 | "name": "A Strapi developer"
22 | },
23 | "strapi": {
24 | "uuid": "78ac33ac-80ec-4ccd-add5-ca321c8c5f43"
25 | },
26 | "engines": {
27 | "node": ">=16.0.0 <=20.x.x",
28 | "npm": ">=6.0.0"
29 | },
30 | "license": "MIT"
31 | }
32 |
--------------------------------------------------------------------------------
/src/filters/liquid.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { jest } from '@jest/globals'
6 | import { testFields } from '../test-data'
7 | import { isDate, isString } from './liquid'
8 |
9 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests
10 | jest.mock('lit', () => ({
11 | html: jest.fn(),
12 | render: jest.fn(),
13 | }))
14 |
15 | test('is string', () => {
16 | expect(isString(null)).toBe(false)
17 | expect(isString(testFields.stringField1)).toBe(true)
18 | expect(isString(testFields.dateField1)).toBe(false)
19 | })
20 |
21 | test('is date', () => {
22 | expect(isDate(null)).toBe(false)
23 | expect(isDate(testFields.stringField1)).toBe(false)
24 | expect(isDate(testFields.dateField1)).toBe(true)
25 | expect(isDate(testFields.dateField2, false)).toBe(true)
26 | expect(isDate(testFields.dateField2)).toBe(false)
27 | })
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "types": ["jest", "node"],
4 | "outDir": "./dist",
5 | "rootDir": "./src",
6 | "target": "es6",
7 | "lib": [
8 | "dom",
9 | "dom.iterable",
10 | "esnext"
11 | ],
12 | "allowJs": true,
13 | "sourceMap": true,
14 | "skipLibCheck": true,
15 | "esModuleInterop": true,
16 | "strict": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "module": "esnext",
20 | "moduleResolution": "node",
21 | "resolveJsonModule": true,
22 | "isolatedModules": true,
23 | "noEmit": false,
24 | "emitDecoratorMetadata": true,
25 | "experimentalDecorators": true,
26 | },
27 | "buildOptions": {
28 | "rootDir": "./src",
29 | "outDir": "./dist",
30 | "baseUrl": "./",
31 | "paths": {
32 | "@/*": ["src/*"]
33 | }
34 | },
35 | "include": ["src/**/*.ts"],
36 | "exclude": [
37 | "node_modules/@types/mocha",
38 | "./node_modules/**/*",
39 | "./dist/**/*",
40 | "./old/**/*",
41 | "./__mocks__/**/*",
42 | "node_modules",
43 | "dist",
44 | "old",
45 | "__mocks__",
46 | "**/*.test.ts"
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to npm
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | id-token: write
13 | contents: read
14 | steps:
15 | - uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 |
19 | - uses: actions/setup-node@v4
20 | with:
21 | node-version: '20'
22 | registry-url: 'https://registry.npmjs.org'
23 |
24 | - name: Update npm
25 | run: npm install -g npm@latest
26 |
27 | - run: npm ci
28 | - run: npm run lint --if-present
29 | - run: npm run build --if-present
30 | - run: npm test --if-present
31 |
32 | - name: Extract version and tag
33 | id: version
34 | run: |
35 | VERSION=$(node -p "require('./package.json').version")
36 | if [[ "$VERSION" == *-* ]]; then
37 | echo "tag=prerelease" >> $GITHUB_OUTPUT
38 | else
39 | echo "tag=latest" >> $GITHUB_OUTPUT
40 | fi
41 | echo "version=$VERSION" >> $GITHUB_OUTPUT
42 |
43 | - name: Publish to npm
44 | run: npm publish --tag ${{ steps.version.outputs.tag }} --access public
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
2 | import globals from "globals";
3 | import tsParser from "@typescript-eslint/parser";
4 | import path from "node:path";
5 | import { fileURLToPath } from "node:url";
6 | import js from "@eslint/js";
7 | import { FlatCompat } from "@eslint/eslintrc";
8 |
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 | const compat = new FlatCompat({
12 | baseDirectory: __dirname,
13 | recommendedConfig: js.configs.recommended,
14 | allConfig: js.configs.all
15 | })
16 |
17 | export default [
18 | ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"),
19 | {
20 | plugins: {
21 | "@typescript-eslint": typescriptEslint,
22 | },
23 |
24 | languageOptions: {
25 | globals: {
26 | ...globals.browser,
27 | ...globals.node,
28 | },
29 |
30 | parser: tsParser,
31 | ecmaVersion: 5,
32 | sourceType: "module",
33 | },
34 |
35 | rules: {
36 | indent: ["error", 2],
37 | "linebreak-style": ["error", "unix"],
38 | quotes: ["error", "single"],
39 | semi: ["error", "never"],
40 | "@typescript-eslint/no-unused-expressions": ["error", {
41 | allowShortCircuit: true,
42 | allowTernary: true,
43 | }],
44 | "comma-dangle": ["error", "always-multiline"],
45 | },
46 | },
47 | ]
48 |
--------------------------------------------------------------------------------
/integration-tests/modules.html:
--------------------------------------------------------------------------------
1 |
2 |
Home
3 |
6 |
9 |
10 |
special-heading
11 |
12 |
13 |
special-heading
14 |
15 |
16 |
section-squares
17 |
18 |
19 |
section-squares
20 |
21 |
22 |
special-heading
23 |
24 |
25 |
26 |
Help and support
27 |
30 |
31 |
special-heading
32 |
33 |
34 |
special-heading
35 |
36 |
37 |
section-squares
38 |
39 |
40 |
special-heading
41 |
42 |
43 |
special-heading
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/storage.ts:
--------------------------------------------------------------------------------
1 | import GraphQL, { GraphQLOptions } from "./datasources/GraphQL"
2 | import { IDataSource } from "./types"
3 | import { resetDataSources, refreshDataSources } from "./model/dataSourceManager"
4 | import { getAllDataSources, addDataSource } from "./model/dataSourceRegistry"
5 | import { Editor } from "grapesjs"
6 |
7 | export default (editor: Editor) => {
8 | // Save and load data sources
9 | editor.on('storage:start:store', (data: any) => {
10 | data.dataSources = getAllDataSources()
11 | .filter((ds: IDataSource) => typeof ds.readonly === 'undefined' || ds.readonly === false)
12 | .map((ds: IDataSource) => ({
13 | id: ds.id,
14 | label: ds.label,
15 | url: ds.url,
16 | type: ds.type,
17 | method: ds.method,
18 | headers: ds.headers,
19 | readonly: ds.readonly,
20 | hidden: ds.hidden
21 | }))
22 | })
23 |
24 | editor.on('storage:end:load', async (data: { dataSources: GraphQLOptions[] }) => {
25 | // Connect the data sources
26 | const newDataSources: IDataSource[] = (data.dataSources || [] as GraphQLOptions[])
27 | .map((ds: GraphQLOptions) => new GraphQL(ds))
28 |
29 | // Get all data sources
30 | const dataSources = getAllDataSources()
31 | // Keep only data sources from the config
32 | .filter((ds: IDataSource) => ds.readonly === true)
33 |
34 | // Reset the data sources to the original config
35 | resetDataSources(dataSources)
36 |
37 | // Add the new data sources
38 | newDataSources.forEach(ds => {
39 | addDataSource(ds)
40 | })
41 |
42 | refreshDataSources()
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/strapi/.gitignore:
--------------------------------------------------------------------------------
1 | ############################
2 | # OS X
3 | ############################
4 |
5 | .DS_Store
6 | .AppleDouble
7 | .LSOverride
8 | Icon
9 | .Spotlight-V100
10 | .Trashes
11 | ._*
12 |
13 |
14 | ############################
15 | # Linux
16 | ############################
17 |
18 | *~
19 |
20 |
21 | ############################
22 | # Windows
23 | ############################
24 |
25 | Thumbs.db
26 | ehthumbs.db
27 | Desktop.ini
28 | $RECYCLE.BIN/
29 | *.cab
30 | *.msi
31 | *.msm
32 | *.msp
33 |
34 |
35 | ############################
36 | # Packages
37 | ############################
38 |
39 | *.7z
40 | *.csv
41 | *.dat
42 | *.dmg
43 | *.gz
44 | *.iso
45 | *.jar
46 | *.rar
47 | *.tar
48 | *.zip
49 | *.com
50 | *.class
51 | *.dll
52 | *.exe
53 | *.o
54 | *.seed
55 | *.so
56 | *.swo
57 | *.swp
58 | *.swn
59 | *.swm
60 | *.out
61 | *.pid
62 |
63 |
64 | ############################
65 | # Logs and databases
66 | ############################
67 |
68 | .tmp
69 | *.log
70 | *.sql
71 | *.sqlite
72 | *.sqlite3
73 |
74 |
75 | ############################
76 | # Misc.
77 | ############################
78 |
79 | *#
80 | ssl
81 | .idea
82 | nbproject
83 | public/uploads/*
84 | !public/uploads/.gitkeep
85 |
86 | ############################
87 | # Node.js
88 | ############################
89 |
90 | lib-cov
91 | lcov.info
92 | pids
93 | logs
94 | results
95 | node_modules
96 | .node_history
97 |
98 | ############################
99 | # Tests
100 | ############################
101 |
102 | coverage
103 |
104 | ############################
105 | # Strapi
106 | ############################
107 |
108 | .env
109 | license.txt
110 | exports
111 | *.cache
112 | dist
113 | build
114 | .strapi-updater.json
115 |
--------------------------------------------------------------------------------
/integration-tests/base/website.json:
--------------------------------------------------------------------------------
1 | {
2 | "dataSources": [],
3 | "assets": [],
4 | "styles": [
5 | {
6 | "selectors": [
7 | "#i0f6"
8 | ],
9 | "style": {}
10 | },
11 | {
12 | "selectors": [
13 | "#ikha"
14 | ],
15 | "style": {}
16 | },
17 | {
18 | "selectors": [
19 | "#izij"
20 | ],
21 | "wrapper": 1,
22 | "style": {}
23 | }
24 | ],
25 | "pages": [
26 | {
27 | "frames": [
28 | {
29 | "component": {
30 | "type": "wrapper",
31 | "stylable": [
32 | "background",
33 | "background-color",
34 | "background-image",
35 | "background-repeat",
36 | "background-attachment",
37 | "background-position",
38 | "background-size"
39 | ],
40 | "attributes": {
41 | "id": "imnu"
42 | },
43 | "components": [
44 | {
45 | "attributes": {
46 | "id": "i0f6"
47 | },
48 | "components": [
49 | {
50 | "tagName": "h1",
51 | "type": "text",
52 | "attributes": {
53 | "id": "ikha"
54 | },
55 | "components": [
56 | {
57 | "type": "textnode",
58 | "content": "About this demo"
59 | }
60 | ]
61 | }
62 | ]
63 | }
64 | ],
65 | "head": {
66 | "type": "head"
67 | },
68 | "docEl": {
69 | "tagName": "html"
70 | }
71 | },
72 | "id": "CWkwORyYHAzFZcwo"
73 | }
74 | ],
75 | "id": "yrhlf8hT03KZ8BbD"
76 | }
77 | ],
78 | "symbols": []
79 | }
80 |
--------------------------------------------------------------------------------
/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { jest } from '@jest/globals'
2 | import { cleanStateName, concatWithLength } from './utils'
3 |
4 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests
5 | jest.mock('lit', () => ({
6 | html: jest.fn(),
7 | render: jest.fn(),
8 | }))
9 |
10 | test('test name fall new state', () => {
11 | expect(cleanStateName(null)).toBeUndefined()
12 | expect(cleanStateName('')).toBe('')
13 | expect(cleanStateName('New State')).toBe('new-state')
14 | expect(cleanStateName('1-+ eée')).toBe('----e-e')
15 | expect(cleanStateName('-e-')).toBe('-e-')
16 | expect(cleanStateName('e:e')).toBe('e:e')
17 | expect(cleanStateName('--test')).toBe('--test')
18 | // Bug fix: "0" should not be replaced with "-"
19 | expect(cleanStateName('0ab')).toBe('-ab')
20 | expect(cleanStateName('a0b')).toBe('a0b')
21 | expect(cleanStateName('ab0')).toBe('ab0')
22 | expect(cleanStateName('0ab-')).toBe('-ab-')
23 | expect(cleanStateName('a-0-b')).toBe('a-0-b')
24 | expect(cleanStateName('a-b-0')).toBe('a-b-0')
25 | expect(cleanStateName('a 0 b')).toBe('a-0-b')
26 | expect(cleanStateName('a b 0')).toBe('a-b-0')
27 | expect(cleanStateName('-a b 0-')).toBe('-a-b-0-')
28 | // Test special chars: -, _, ., :
29 | expect(cleanStateName('test-name')).toBe('test-name')
30 | expect(cleanStateName('test_name')).toBe('test_name')
31 | expect(cleanStateName('test.name')).toBe('test.name')
32 | expect(cleanStateName('test:name')).toBe('test:name')
33 | expect(cleanStateName('test-_.:')).toBe('test-_.:')
34 | expect(cleanStateName('a-b_c.d:e')).toBe('a-b_c.d:e')
35 | })
36 |
37 | test('Test string length', () => {
38 | expect(concatWithLength(5, 'a')).toHaveLength(5)
39 | expect(concatWithLength(5, 'a', 'b')).toHaveLength(5)
40 | expect(concatWithLength(5, 'a', 'b', 'c')).toHaveLength(5)
41 | expect(concatWithLength(5, 'aa', 'bbb')).toHaveLength(5)
42 | expect(concatWithLength(5, 'aaa', 'bb')).toHaveLength(5)
43 | expect(concatWithLength(5, 'a ', 'b')).toHaveLength(5)
44 | expect(concatWithLength(5, 'aaa', 'bbb')).toHaveLength(6)
45 | expect(concatWithLength(5, ' a ', ' b ')).toHaveLength(6)
46 | })
47 |
--------------------------------------------------------------------------------
/strapi/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Getting started with Strapi
2 |
3 | Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
4 |
5 | ### `develop`
6 |
7 | Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
8 |
9 | ```
10 | npm run develop
11 | # or
12 | yarn develop
13 | ```
14 |
15 | ### `start`
16 |
17 | Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
18 |
19 | ```
20 | npm run start
21 | # or
22 | yarn start
23 | ```
24 |
25 | ### `build`
26 |
27 | Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
28 |
29 | ```
30 | npm run build
31 | # or
32 | yarn build
33 | ```
34 |
35 | ## ⚙️ Deployment
36 |
37 | Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
38 |
39 | ## 📚 Learn more
40 |
41 | - [Resource center](https://strapi.io/resource-center) - Strapi resource center.
42 | - [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
43 | - [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
44 | - [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
45 | - [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
46 |
47 | Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
48 |
49 | ## ✨ Community
50 |
51 | - [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
52 | - [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
53 | - [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
54 |
55 | ---
56 |
57 | 🤫 Psst! [Strapi is hiring](https://strapi.io/careers).
58 |
--------------------------------------------------------------------------------
/src/datasources/graphql-introspection-query.ts:
--------------------------------------------------------------------------------
1 | export default `
2 | query IntrospectionQuery {
3 | __schema {
4 | queryType {
5 | name
6 | }
7 | types {
8 | ...FullType
9 | }
10 | }
11 | }
12 | fragment FullType on __Type {
13 | kind
14 | name
15 | description
16 | fields(includeDeprecated: true) {
17 | name
18 | description
19 | args {
20 | ...InputValue
21 | }
22 | type {
23 | ...TypeRef
24 | }
25 | isDeprecated
26 | deprecationReason
27 | }
28 | inputFields {
29 | ...InputValue
30 | }
31 | interfaces {
32 | ...TypeRef
33 | }
34 | enumValues(includeDeprecated: true) {
35 | name
36 | description
37 | isDeprecated
38 | deprecationReason
39 | }
40 | possibleTypes {
41 | ...TypeRef
42 | }
43 | }
44 | fragment InputValue on __InputValue {
45 | name
46 | description
47 | type {
48 | ...TypeRef
49 | }
50 | defaultValue
51 | }
52 | fragment TypeRef on __Type {
53 | kind
54 | name
55 | possibleTypes {
56 | kind
57 | name
58 | }
59 | ofType {
60 | kind
61 | name
62 | possibleTypes {
63 | kind
64 | name
65 | }
66 | ofType {
67 | kind
68 | name
69 | possibleTypes {
70 | kind
71 | name
72 | }
73 | ofType {
74 | kind
75 | name
76 | possibleTypes {
77 | kind
78 | name
79 | }
80 | ofType {
81 | kind
82 | name
83 | possibleTypes {
84 | kind
85 | name
86 | }
87 | ofType {
88 | kind
89 | name
90 | possibleTypes {
91 | kind
92 | name
93 | }
94 | ofType {
95 | kind
96 | name
97 | possibleTypes {
98 | kind
99 | name
100 | }
101 | ofType {
102 | kind
103 | name
104 | possibleTypes {
105 | kind
106 | name
107 | }
108 | }
109 | }
110 | }
111 | }
112 | }
113 | }
114 | }
115 | }
116 | `
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@silexlabs/grapesjs-data-source",
3 | "version": "0.1.1",
4 | "description": "Grapesjs Data Source",
5 | "main": "dist/index.js",
6 | "module": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "exports": {
9 | "import": "./dist/index.js",
10 | "require": "./dist/cjs/index.js"
11 | },
12 | "files": [
13 | "dist",
14 | "src",
15 | "README.md",
16 | "LICENSE"
17 | ],
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/silexlabs/grapesjs-data-source.git"
21 | },
22 | "scripts": {
23 | "start": "$npm_execpath run serve & grapesjs-cli serve",
24 | "build": "grapesjs-cli build --patch=false && tsc -p tsconfig.cjs.json",
25 | "serve": "$npm_execpath run serve:mocks & $npm_execpath run serve:grapesjs",
26 | "serve:mocks": "http-serve ./__mocks__/ -p 3000 --cors",
27 | "serve:grapesjs": "http-serve `node_modules grapesjs`/grapesjs/dist -p 3001 --cors",
28 | "lint": "eslint src/**/*",
29 | "test": "node --experimental-vm-modules `node_modules jest`/.bin/jest --runInBand --no-cache",
30 | "test:watch": "npm test -- --watch",
31 | "prepare": "husky",
32 | "lint:integration:tests": "tidy -m -i -wrap 0 --show-body-only yes integration-tests/**/*.html & find integration-tests -type f -name '*.json' -print0 | xargs -0 -n1 sh -c 'jq . \"$1\" | sponge \"$1\"' sh"
33 | },
34 | "keywords": [
35 | "silex",
36 | "grapesjs",
37 | "grapesjs-plugin",
38 | "plugin",
39 | "silex"
40 | ],
41 | "devDependencies": {
42 | "@babel/preset-typescript": "^7.27.0",
43 | "@eslint/eslintrc": "^3.3.1",
44 | "@eslint/js": "^9.25.0",
45 | "@jest/globals": "^29.7.0",
46 | "@types/jest": "^29.5.14",
47 | "@typescript-eslint/eslint-plugin": "^8.30.1",
48 | "@typescript-eslint/parser": "^8.30.1",
49 | "dom-compare": "^0.6.0",
50 | "eslint": "^9.25.0",
51 | "globals": "^16.0.0",
52 | "grapesjs-cli": "^4.1.3",
53 | "http-serve": "^1.0.1",
54 | "husky": "^9.1.7",
55 | "jest": "^29.7.0",
56 | "jest-environment-jsdom": "^29.7.0",
57 | "jsdom": "^26.1.0",
58 | "node_modules-path": "^2.2.0",
59 | "ts-jest": "^29.3.2",
60 | "typescript": "^5.8.3",
61 | "typescript-eslint": "^8.30.1"
62 | },
63 | "peerDependencies": {
64 | "grapesjs": ">=0.19.0 <0.23.0",
65 | "lit-html": "*"
66 | },
67 | "author": {
68 | "name": "Alex Hoyau",
69 | "url": "https://lexoyo.me/"
70 | },
71 | "license": "AGPL-3.0",
72 | "dependencies": {
73 | "@apollo/client": "^3.13.8",
74 | "@silexlabs/expression-input": "0.3.0",
75 | "dedent-js": "^1.0.1"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/integration-tests/visibility-truthy/preview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Africa
4 |
=======================
5 |
This plugin lets you set "states" on components with the data coming from the API
6 |
7 |
8 |
Antarctica
9 |
This plugin lets you set "states" on components with the data coming from the API
10 |
11 |
12 |
Asia
13 |
This plugin lets you set "states" on components with the data coming from the API
14 |
15 |
16 |
Europe
17 |
This plugin lets you set "states" on components with the data coming from the API
18 |
19 |
20 |
North America
21 |
This plugin lets you set "states" on components with the data coming from the API
22 |
23 |
24 |
Oceania
25 |
This plugin lets you set "states" on components with the data coming from the API
26 |
27 |
28 |
South America
29 |
This plugin lets you set "states" on components with the data coming from the API
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/commands.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DataSourceEditorOptions,
3 | COMMAND_REFRESH,
4 | COMMAND_PREVIEW_ACTIVATE,
5 | COMMAND_PREVIEW_DEACTIVATE,
6 | COMMAND_PREVIEW_REFRESH,
7 | COMMAND_PREVIEW_TOGGLE,
8 | PREVIEW_ACTIVATED,
9 | PREVIEW_DEACTIVATED,
10 | } from './types'
11 | import { refreshDataSources } from './model/dataSourceManager'
12 | import { Editor } from 'grapesjs'
13 | import { doRender, restoreOriginalRender } from './view/canvas'
14 |
15 | // Global state for preview activation
16 | let isPreviewActive = true
17 |
18 | export function getPreviewActive(): boolean {
19 | return isPreviewActive
20 | }
21 |
22 | // Function to force GrapesJS to re-render all components
23 | function forceRender(editor: Editor) {
24 | // Force a complete re-render by refreshing the canvas
25 | doRender(editor)
26 | }
27 |
28 | // GrapesJS plugin to add commands to the editor
29 | export default (editor: Editor, opts: DataSourceEditorOptions) => {
30 | // Set initial preview state
31 | isPreviewActive = opts.previewActive
32 |
33 | // Refresh all data sources
34 | editor.Commands.add(COMMAND_REFRESH, {
35 | run() {
36 | refreshDataSources()
37 | },
38 | })
39 |
40 | // Activate preview mode
41 | editor.Commands.add(COMMAND_PREVIEW_ACTIVATE, {
42 | run() {
43 | if (!isPreviewActive) {
44 | isPreviewActive = true
45 | // Force GrapesJS to re-render to show preview data
46 | forceRender(editor)
47 | // Emit event
48 | editor.trigger(PREVIEW_ACTIVATED)
49 | }
50 | },
51 | })
52 |
53 | // Deactivate preview mode
54 | editor.Commands.add(COMMAND_PREVIEW_DEACTIVATE, {
55 | run() {
56 | if (isPreviewActive) {
57 | isPreviewActive = false
58 | // Force GrapesJS to re-render to show original content
59 | const main = editor.Pages.getSelected()?.getMainComponent()
60 | if (main) restoreOriginalRender(main)
61 | // Emit event
62 | editor.trigger(PREVIEW_DEACTIVATED)
63 | }
64 | },
65 | })
66 |
67 | // Toggle preview mode
68 | editor.Commands.add(COMMAND_PREVIEW_TOGGLE, {
69 | run() {
70 | isPreviewActive = !isPreviewActive
71 | // Emit event
72 | if (isPreviewActive) {
73 | // Force GrapesJS to re-render to reflect the toggled state
74 | forceRender(editor)
75 | // Trigger event
76 | editor.trigger(PREVIEW_ACTIVATED)
77 | } else {
78 | // Force GrapesJS to re-render to show original content
79 | const main = editor.Pages.getSelected()?.getMainComponent()
80 | if (main) restoreOriginalRender(main)
81 | // Trigger event
82 | editor.trigger(PREVIEW_DEACTIVATED)
83 | }
84 | },
85 | })
86 |
87 | // Refresh preview data
88 | editor.Commands.add(COMMAND_PREVIEW_REFRESH, {
89 | run() {
90 | if (isPreviewActive) {
91 | forceRender(editor)
92 | }
93 | },
94 | })
95 | }
96 |
--------------------------------------------------------------------------------
/strapi/config/database.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = ({ env }) => {
4 | const client = env('DATABASE_CLIENT', 'sqlite');
5 |
6 | const connections = {
7 | mysql: {
8 | connection: {
9 | connectionString: env('DATABASE_URL'),
10 | host: env('DATABASE_HOST', 'localhost'),
11 | port: env.int('DATABASE_PORT', 3306),
12 | database: env('DATABASE_NAME', 'strapi'),
13 | user: env('DATABASE_USERNAME', 'strapi'),
14 | password: env('DATABASE_PASSWORD', 'strapi'),
15 | ssl: env.bool('DATABASE_SSL', false) && {
16 | key: env('DATABASE_SSL_KEY', undefined),
17 | cert: env('DATABASE_SSL_CERT', undefined),
18 | ca: env('DATABASE_SSL_CA', undefined),
19 | capath: env('DATABASE_SSL_CAPATH', undefined),
20 | cipher: env('DATABASE_SSL_CIPHER', undefined),
21 | rejectUnauthorized: env.bool(
22 | 'DATABASE_SSL_REJECT_UNAUTHORIZED',
23 | true
24 | ),
25 | },
26 | },
27 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
28 | },
29 | mysql2: {
30 | connection: {
31 | host: env('DATABASE_HOST', 'localhost'),
32 | port: env.int('DATABASE_PORT', 3306),
33 | database: env('DATABASE_NAME', 'strapi'),
34 | user: env('DATABASE_USERNAME', 'strapi'),
35 | password: env('DATABASE_PASSWORD', 'strapi'),
36 | ssl: env.bool('DATABASE_SSL', false) && {
37 | key: env('DATABASE_SSL_KEY', undefined),
38 | cert: env('DATABASE_SSL_CERT', undefined),
39 | ca: env('DATABASE_SSL_CA', undefined),
40 | capath: env('DATABASE_SSL_CAPATH', undefined),
41 | cipher: env('DATABASE_SSL_CIPHER', undefined),
42 | rejectUnauthorized: env.bool(
43 | 'DATABASE_SSL_REJECT_UNAUTHORIZED',
44 | true
45 | ),
46 | },
47 | },
48 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
49 | },
50 | postgres: {
51 | connection: {
52 | connectionString: env('DATABASE_URL'),
53 | host: env('DATABASE_HOST', 'localhost'),
54 | port: env.int('DATABASE_PORT', 5432),
55 | database: env('DATABASE_NAME', 'strapi'),
56 | user: env('DATABASE_USERNAME', 'strapi'),
57 | password: env('DATABASE_PASSWORD', 'strapi'),
58 | ssl: env.bool('DATABASE_SSL', false) && {
59 | key: env('DATABASE_SSL_KEY', undefined),
60 | cert: env('DATABASE_SSL_CERT', undefined),
61 | ca: env('DATABASE_SSL_CA', undefined),
62 | capath: env('DATABASE_SSL_CAPATH', undefined),
63 | cipher: env('DATABASE_SSL_CIPHER', undefined),
64 | rejectUnauthorized: env.bool(
65 | 'DATABASE_SSL_REJECT_UNAUTHORIZED',
66 | true
67 | ),
68 | },
69 | schema: env('DATABASE_SCHEMA', 'public'),
70 | },
71 | pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
72 | },
73 | sqlite: {
74 | connection: {
75 | filename: path.join(
76 | __dirname,
77 | '..',
78 | env('DATABASE_FILENAME', '.tmp/data.db')
79 | ),
80 | },
81 | useNullAsDefault: true,
82 | },
83 | };
84 |
85 | return {
86 | connection: {
87 | client,
88 | ...connections[client],
89 | acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
90 | },
91 | };
92 | };
93 |
--------------------------------------------------------------------------------
/src/filters/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Liquid Filter Engine
3 | * Centralized management of Liquid template filters
4 | */
5 |
6 | import { Editor } from 'grapesjs'
7 | import { DataSourceEditorOptions, Filter } from '../types'
8 | import getLiquidFilters from './liquid'
9 |
10 | /**
11 | * Liquid Engine interface for managing and applying filters
12 | */
13 | export interface LiquidEngine {
14 | readonly filters: ReadonlyMap
15 | applyFilter: (value: unknown, filterId: string, options: Record) => unknown
16 | hasFilter: (filterId: string) => boolean
17 | }
18 |
19 | /**
20 | * Create a Liquid engine with the given filters
21 | */
22 | export function createLiquidEngine(filters: readonly Filter[]): LiquidEngine {
23 | const filterMap = new Map(filters.map(f => [f.id, f]))
24 |
25 | return {
26 | filters: filterMap,
27 |
28 | applyFilter(value: unknown, filterId: string, options: Record): unknown {
29 | const filter = filterMap.get(filterId)
30 | if (!filter) {
31 | throw new Error(`Liquid filter not found: ${filterId}`)
32 | }
33 | return filter.apply(value, options)
34 | },
35 |
36 | hasFilter(filterId: string): boolean {
37 | return filterMap.has(filterId)
38 | },
39 | }
40 | }
41 |
42 | /**
43 | * Validate that required filter fields are present
44 | */
45 | export function validateFilter(filter: Filter): void {
46 | if (!filter.id) throw new Error('Filter id is required')
47 | if (!filter.label) throw new Error('Filter label is required')
48 | if (!filter.validate) throw new Error('Filter validate is required')
49 | if (!filter.output) throw new Error('Filter output is required')
50 | if (!filter.apply) throw new Error('Filter apply is required')
51 | }
52 |
53 | /**
54 | * Validate multiple filters
55 | */
56 | export function validateFilters(filters: readonly Filter[]): void {
57 | filters.forEach(validateFilter)
58 | }
59 |
60 | /**
61 | * Add filters to an existing engine
62 | */
63 | export function addFiltersToEngine(engine: LiquidEngine, filters: readonly Filter[]): LiquidEngine {
64 | validateFilters(filters)
65 | const allFilters = [...engine.filters.values(), ...filters]
66 | return createLiquidEngine(allFilters)
67 | }
68 |
69 | /**
70 | * Remove filters from an existing engine
71 | */
72 | export function removeFiltersFromEngine(engine: LiquidEngine, filterIds: readonly string[]): LiquidEngine {
73 | const remaining = [...engine.filters.values()].filter(f => !filterIds.includes(f.id))
74 | return createLiquidEngine(remaining)
75 | }
76 |
77 | /**
78 | * Initialize filters from options
79 | */
80 | export function initializeFilters(editor: Editor, options: DataSourceEditorOptions): Filter[] {
81 | if (typeof options.filters === 'string') {
82 | return [
83 | ...getLiquidFilters(editor),
84 | ]
85 | } else {
86 | return (options.filters as Filter[])
87 | .flatMap((filter: Partial | string): Filter[] => {
88 | if (typeof filter === 'string') {
89 | switch (filter) {
90 | case 'liquid': return getLiquidFilters(editor)
91 | default: throw new Error(`Unknown filters ${filter}`)
92 | }
93 | } else {
94 | return [{
95 | ...filter as Partial,
96 | type: 'filter',
97 | } as Filter]
98 | }
99 | })
100 | .map((filter: Filter) => ({ ...filter, type: 'filter' })) as Filter[]
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import commands from './commands'
19 | import { initializeDataSourceManager, refreshDataSources } from './model/dataSourceManager'
20 | import storage from './storage'
21 | import { DATA_SOURCE_ERROR, DataSourceEditorOptions, IDataSource, IDataSourceOptions } from './types'
22 | import { createDataSource, NOTIFICATION_GROUP } from './utils'
23 | import view from './view'
24 | import { Editor } from 'grapesjs'
25 |
26 | /**
27 | * Export the public API
28 | */
29 | // Main public API - this is what apps should use
30 | export * from './api'
31 |
32 | // Types and interfaces that apps need
33 | export * from './types'
34 |
35 | /**
36 | * GrapeJs plugin entry point
37 | */
38 | export default (editor: Editor, opts: Partial = {}) => {
39 | const options: DataSourceEditorOptions = {
40 | dataSources: [],
41 | filters: [],
42 | previewActive: true, // Default to preview active
43 | ...opts,
44 | view: {
45 | el: '.gjs-pn-panel.gjs-pn-views-container',
46 | ...opts?.view,
47 | },
48 | }
49 |
50 | const dataSources = options.dataSources
51 | // Make sure the data sources from the config are readonly
52 | .map(ds => ({ ...ds, readonly: true }))
53 | // Create the data sources from config
54 | .map((ds: IDataSourceOptions) => createDataSource(ds))
55 |
56 | // Connect the data sources (async)
57 | Promise.all(dataSources
58 | .map(ds => {
59 | return ds.connect()
60 | .catch(err => console.error(`Data source ${ds.id} connection failed:`, err))
61 | }))
62 | .catch(err => console.error('Error while connecting data sources', err))
63 |
64 | // Initialize the global data source manager
65 | initializeDataSourceManager(dataSources, editor, options)
66 |
67 | // Register the UI for component properties
68 | view(editor, options)
69 |
70 | // Save and load data sources
71 | storage(editor)
72 |
73 | // Register the commands
74 | commands(editor, options)
75 |
76 | // Use grapesjs-notifications plugin for errors
77 | editor.on(DATA_SOURCE_ERROR, (msg: string, ds: IDataSource) => editor.runCommand('notifications:add', { type: 'error', message: `Data source \`${ds.id}\` error: ${msg}`, group: NOTIFICATION_GROUP }))
78 |
79 | // Load data after editor is fully loaded
80 | editor.on('load', () => {
81 | refreshDataSources()
82 | })
83 |
84 | // Also refresh data when storage loads (to handle website data loading)
85 | editor.on('storage:end:load', () => {
86 | // Use setTimeout to ensure components are fully loaded
87 | setTimeout(() => {
88 | refreshDataSources()
89 | }, 100)
90 | })
91 | }
92 |
93 | /**
94 | * Version of the plugin
95 | * This is replaced by the build script
96 | */
97 | export const version = '__VERSION__'
98 |
--------------------------------------------------------------------------------
/src/model/dataSourceRegistry.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { DataSourceId, IDataSource, DATA_SOURCE_CHANGED } from '../types'
19 | import { Editor } from 'grapesjs'
20 |
21 | /**
22 | * Data source registry state
23 | */
24 | interface DataSourceRegistryState {
25 | dataSources: IDataSource[]
26 | editor: Editor
27 | }
28 |
29 | // Global registry instance
30 | let globalRegistry: DataSourceRegistryState | null = null
31 |
32 | /**
33 | * Initialize the data source registry
34 | */
35 | export function initializeDataSourceRegistry(editor: Editor): void {
36 | globalRegistry = {
37 | dataSources: [],
38 | editor,
39 | }
40 | }
41 |
42 | /**
43 | * Get the global registry (throws if not initialized)
44 | */
45 | function getRegistry(): DataSourceRegistryState {
46 | if (!globalRegistry) {
47 | throw new Error('DataSourceRegistry not initialized. Call initializeDataSourceRegistry first.')
48 | }
49 | return globalRegistry
50 | }
51 |
52 | /**
53 | * Get all data sources
54 | */
55 | export function getAllDataSources(): IDataSource[] {
56 | return [...getRegistry().dataSources]
57 | }
58 |
59 | /**
60 | * Add a data source
61 | */
62 | export function addDataSource(dataSource: IDataSource): void {
63 | const registry = getRegistry()
64 | registry.dataSources.push(dataSource)
65 | dataSource.connect()
66 | .then(() => {
67 | registry.editor.trigger(DATA_SOURCE_CHANGED)
68 | })
69 | .catch((error) => {
70 | console.error('Failed to connect data source:', error)
71 | registry.editor.trigger(DATA_SOURCE_CHANGED)
72 | })
73 | }
74 |
75 | /**
76 | * Remove a data source
77 | */
78 | export function removeDataSource(dataSource: IDataSource): void {
79 | const registry = getRegistry()
80 | const index = registry.dataSources.indexOf(dataSource)
81 | if (index > -1) {
82 | registry.dataSources.splice(index, 1)
83 | registry.editor.trigger(DATA_SOURCE_CHANGED)
84 | }
85 | }
86 |
87 | /**
88 | * Get a data source by ID
89 | */
90 | export function getDataSource(id: DataSourceId): IDataSource | undefined {
91 | return getRegistry().dataSources.find(ds => ds.id === id)
92 | }
93 |
94 | /**
95 | * Set all data sources (replaces existing)
96 | */
97 | export function setDataSources(dataSources: IDataSource[]): void {
98 | const registry = getRegistry()
99 | registry.dataSources = [...dataSources]
100 | registry.editor.trigger(DATA_SOURCE_CHANGED)
101 | }
102 |
103 | /**
104 | * Convert to JSON for storage
105 | */
106 | export function dataSourcesToJSON(): unknown[] {
107 | return getRegistry().dataSources.map(ds => ({
108 | id: ds.id,
109 | label: ds.label,
110 | url: ds.url,
111 | type: ds.type,
112 | method: ds.method,
113 | headers: ds.headers,
114 | readonly: ds.readonly,
115 | hidden: ds.hidden,
116 | }))
117 | }
118 |
--------------------------------------------------------------------------------
/src/storage.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { jest } from '@jest/globals'
5 | import { Editor } from 'grapesjs'
6 | import storage from './storage'
7 | import { resetDataSources, refreshDataSources } from './model/dataSourceManager'
8 | import { getAllDataSources, addDataSource } from './model/dataSourceRegistry'
9 |
10 | // FIXME: Workaround to avoid import of lit-html which breakes unit tests
11 | jest.mock('lit', () => ({
12 | html: jest.fn(),
13 | render: jest.fn(),
14 | }))
15 |
16 | // Mock the dataSourceManager to avoid complex initialization
17 | jest.mock('./model/dataSourceManager', () => ({
18 | resetDataSources: jest.fn(),
19 | refreshDataSources: jest.fn()
20 | }))
21 |
22 | // Mock the dataSourceRegistry
23 | jest.mock('./model/dataSourceRegistry', () => ({
24 | getAllDataSources: jest.fn(),
25 | addDataSource: jest.fn(),
26 | }))
27 |
28 | const config1 = { readonly: true, id: 'config1', label: 'Config 1', url: 'http://config1.com', type: 'graphql' as const } // from the config
29 | const config2 = { readonly: true, id: 'config2', label: 'Config 2', url: 'http://config2.com', type: 'graphql' as const } // from the config
30 | const website1 = { readonly: false, id: 'website1', label: 'Website 1', url: 'http://website1.com', type: 'graphql' as const } // from the website
31 | const website2 = { id: 'website2', label: 'Website 2', url: 'http://website2.com', type: 'graphql' as const } // from the website (readonly undefined)
32 | jest.mock('./datasources/GraphQL', () => jest.fn().mockImplementation((x) => ({ connect: jest.fn(), id: 'website1DataSource', ...x })))
33 |
34 | describe('storage', () => {
35 | let editor: Editor
36 | let onStore: Function
37 | let onLoad: Function
38 | beforeEach(() => {
39 | const callbacks = new Map()
40 | editor = {
41 | on: (event, callback) => callbacks.set(event, callback),
42 | } as any as Editor
43 |
44 | // Mock the getAllDataSources to return our test data
45 | ;(getAllDataSources as jest.Mock).mockReturnValue([config1, config2, website1, website2])
46 |
47 | storage(editor)
48 | expect(callbacks.get('storage:start:store')).toBeDefined()
49 | onStore = callbacks.get('storage:start:store')!
50 | expect(callbacks.get('storage:end:load')).toBeDefined()
51 | onLoad = callbacks.get('storage:end:load')!
52 | })
53 |
54 | it('should store data sources from the website only', () => {
55 | const previousDataSource = { id: 'whatever data'}
56 | const data = { dataSources: [previousDataSource] }
57 | onStore(data)
58 | expect(data.dataSources).toHaveLength(2)
59 | expect(data.dataSources[0]).not.toEqual(previousDataSource)
60 | expect(data.dataSources[0].id).toEqual('website1')
61 | expect(data.dataSources[1].id).toEqual('website2')
62 | })
63 | it('should load data sources from website and keep those from the config', async () => {
64 | const newWebsite1 = { readonly: false, id: 'newWebsite1', label: 'New Website 1', url: 'http://new1.com', type: 'graphql' as const }
65 | const newWebsite2 = { id: 'newWebsite2', label: 'New Website 2', url: 'http://new2.com', type: 'graphql' as const }
66 | const newWebsite3 = { readonly: true, id: 'newWebsite3', label: 'New Website 3', url: 'http://new3.com', type: 'graphql' as const }
67 |
68 | const data = { dataSources: [
69 | newWebsite1,
70 | newWebsite2,
71 | newWebsite3,
72 | ] }
73 | await onLoad(data)
74 |
75 | // Check that resetDataSources was called with config data sources only
76 | expect(resetDataSources).toHaveBeenCalledWith([config1, config2])
77 |
78 | // Check that addDataSource was called for each new data source
79 | expect(addDataSource).toHaveBeenCalledTimes(3)
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/src/model/state.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import grapesjs from 'grapesjs'
5 | import { getChildByPersistantId, getComponentByPersistentId, getParentByPersistentId, getPersistantId, getStateIds, setState } from './state'
6 | import { Editor } from 'grapesjs'
7 |
8 | test('getChildByPersistantId', () => {
9 | const editor = grapesjs.init({
10 | container: document.createElement('div'),
11 | components: ``,
17 | })
18 | const parent = editor.Components.getById('parent')
19 | expect(parent).not.toBeNull()
20 | expect(parent.get('attributes')?.id).toBe('parent')
21 | const child1 = editor.Components.getById('child1')
22 | expect(child1).not.toBeNull()
23 | const child2 = editor.Components.getById('child2')
24 | expect(child2).not.toBeNull()
25 | const child3 = editor.Components.getById('child3')
26 | expect(child3).not.toBeNull()
27 | parent.set('id-plugin-data-source', 'test-id-parent')
28 | expect(getPersistantId(parent)).toBe('test-id-parent')
29 | child3.set('id-plugin-data-source', 'test-id-child3')
30 | expect(getPersistantId(child3)).toBe('test-id-child3')
31 | expect(getParentByPersistentId('test-id-parent', child3)).toBe(parent)
32 | expect(getChildByPersistantId('test-id-child3', parent)).toBe(child3)
33 | expect(getComponentByPersistentId('test-id-child3', editor as Editor)).toBe(child3)
34 | expect(getComponentByPersistentId('test-id-parent', editor as Editor)).toBe(parent)
35 | })
36 | test('getStateIds', () => {
37 | const editor = grapesjs.init({
38 | container: document.createElement('div'),
39 | components: ``,
45 | })
46 | const parent = editor.Components.getById('parent')
47 | setState(parent, 'state1', {label: 'State 1', expression: []})
48 | setState(parent, 'state2', {label: 'State 2', expression: []}, true)
49 | setState(parent, 'state3', {label: 'State 3', expression: []}, false)
50 | setState(parent, 'state4', {label: 'State 4', expression: []}, true)
51 |
52 | expect(getStateIds(parent)).toEqual(['state1', 'state2', 'state4'])
53 | expect(getStateIds(parent, true)).toEqual(['state1', 'state2', 'state4'])
54 | expect(getStateIds(parent, false)).toEqual(['state3'])
55 | expect(getStateIds(parent, true, 'state2')).toEqual(['state1'])
56 | expect(getStateIds(parent, true, 'state4')).toEqual(['state1', 'state2'])
57 | expect(getStateIds(parent, true, 'does not exist')).toEqual(['state1', 'state2', 'state4'])
58 | })
59 | test('getStateIds with specific index', () => {
60 | const editor = grapesjs.init({
61 | container: document.createElement('div'),
62 | components: '
',
63 | })
64 | const parent = editor.Components.getById('parent')
65 | setState(parent, 'state1', {label: 'State 1', expression: []}, true)
66 | setState(parent, 'state2', {label: 'State 2', expression: []}, true)
67 | setState(parent, 'state3', {label: 'State 3', expression: []}, false)
68 | setState(parent, 'state4', {label: 'State 4', expression: []}, true)
69 | setState(parent, 'statePos10', {label: 'State Pos 10', expression: []}, true, 10)
70 | setState(parent, 'statePos0', {label: 'State Pos 0', expression: []}, true, 0)
71 | setState(parent, 'statePos2', {label: 'State Pos 2', expression: []}, true, 2)
72 | setState(parent, 'statePos11', {label: 'State Pos 11', expression: []}, true, 11)
73 |
74 | expect(getStateIds(parent)).toEqual(['statePos0', 'state1', 'statePos2', 'state2', 'state4', 'statePos10', 'statePos11'])
75 | expect(getStateIds(parent, false)).toEqual(['state3'])
76 | })
77 |
--------------------------------------------------------------------------------
/_index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Home
6 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
About this demo
25 |
For this test I configured the plugin with a demo country API:
26 | https://studio.apollographql.com/public/countries/variant/current/home
27 |
Select a component and check it's settings on the right
28 |
This plugin lets you set "states" on components with the data coming from the API
29 |
30 |
31 |
109 |
110 |
--------------------------------------------------------------------------------
/src/test-data.ts:
--------------------------------------------------------------------------------
1 | import { jest } from '@jest/globals'
2 | import { DataSourceId, Field, Filter, Token, Type } from './types'
3 |
4 | export async function importDataSource(datas?: unknown[]) {
5 | if (datas?.length) {
6 | global.fetch = jest.fn() as jest.MockedFunction
7 | datas?.forEach(data => {
8 | (global.fetch as jest.MockedFunction)
9 | .mockImplementationOnce(() => {
10 | return Promise.resolve({
11 | ok: true,
12 | json: () => Promise.resolve(data),
13 | } as Response)
14 | })
15 | })
16 | }
17 | return (await import('./datasources/GraphQL')).default
18 | }
19 |
20 | export const testDataSourceId: DataSourceId = 'testDataSourceId'
21 |
22 | export const testTokens: Record = {
23 | rootField1: {
24 | type: 'property',
25 | propType: 'field',
26 | fieldId: 'rootField1',
27 | label: 'test',
28 | typeIds: ['rootTypeId1'],
29 | kind: 'object',
30 | dataSourceId: 'testDataSourceId',
31 | },
32 | filter: {
33 | type: 'filter',
34 | id: 'filterId',
35 | label: 'filter name',
36 | validate: () => true,
37 | output: () => null,
38 | apply: () => null,
39 | options: {},
40 | },
41 | rootField2: {
42 | type: 'property',
43 | propType: 'field',
44 | fieldId: 'rootField2',
45 | label: 'test',
46 | typeIds: ['rootTypeId2'],
47 | kind: 'object',
48 | dataSourceId: 'testDataSourceId',
49 | },
50 | childField1: {
51 | type: 'property',
52 | propType: 'field',
53 | fieldId: 'childField1',
54 | label: 'test',
55 | typeIds: ['childTypeId1'],
56 | kind: 'scalar',
57 | dataSourceId: 'testDataSourceId',
58 | },
59 | childField2: {
60 | type: 'property',
61 | propType: 'field',
62 | fieldId: 'childField2',
63 | label: 'test',
64 | typeIds: ['childTypeId2'],
65 | kind: 'scalar',
66 | dataSourceId: 'testDataSourceId',
67 | },
68 | childField3: {
69 | type: 'property',
70 | propType: 'field',
71 | fieldId: 'childField3',
72 | label: 'test',
73 | typeIds: ['childTypeId3'],
74 | kind: 'scalar',
75 | dataSourceId: 'testDataSourceId',
76 | },
77 | }
78 | export const testFields: Record = {
79 | stringField1: {
80 | id: 'stringField1',
81 | label: 'test',
82 | typeIds: ['String'],
83 | kind: 'scalar',
84 | dataSourceId: 'testDataSourceId',
85 | },
86 | dateField1: {
87 | id: 'dateField1',
88 | label: 'test',
89 | typeIds: ['SomeType', 'date'],
90 | kind: 'scalar',
91 | dataSourceId: 'testDataSourceId',
92 | },
93 | dateField2: {
94 | id: 'dateField2',
95 | label: 'test',
96 | typeIds: ['SomeType', 'Instant'],
97 | kind: 'list',
98 | dataSourceId: 'testDataSourceId',
99 | },
100 | }
101 |
102 | export const simpleFilters: Filter[] = [{
103 | type: 'filter',
104 | id: 'testFilterAnyInput',
105 | label: 'test filter any input',
106 | validate: type => !type, // Just for empty expressions
107 | output: () => null,
108 | options: {},
109 | apply: jest.fn(),
110 | }, {
111 | type: 'filter',
112 | id: 'testFilterId',
113 | label: 'test filter name',
114 | validate: type => !!type?.typeIds.includes('testTypeId'),
115 | output: type => type!,
116 | options: {},
117 | apply: jest.fn(),
118 | }, {
119 | type: 'filter',
120 | id: 'testFilterId2',
121 | label: 'test filter name 2',
122 | validate: type => !!type?.typeIds.includes('testFieldTypeId'),
123 | output: () => null,
124 | options: {},
125 | apply: jest.fn(),
126 | }]
127 |
128 | export const simpleTypes: Type[] = [{
129 | id: 'testTypeId',
130 | label: 'test type name',
131 | fields: [
132 | {
133 | id: 'testFieldId',
134 | label: 'test field name',
135 | typeIds: ['testFieldTypeId'],
136 | kind: 'scalar',
137 | dataSourceId: testDataSourceId,
138 | }
139 | ],
140 | dataSourceId: testDataSourceId,
141 | }, {
142 | id: 'testFieldTypeId',
143 | label: 'test field type name',
144 | fields: [],
145 | dataSourceId: testDataSourceId,
146 | }]
147 |
148 | export const simpleQueryables: Field[] = [{
149 | id: 'testSimpleQueryableId',
150 | label: 'test queryable',
151 | typeIds: ['testTypeId'],
152 | kind: 'scalar',
153 | dataSourceId: testDataSourceId,
154 | }]
155 |
--------------------------------------------------------------------------------
/src/model/queryBuilder.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { DataSourceId, Property, StoredToken, ComponentExpression } from '../types'
19 | import { getPageExpressions, toTrees } from './ExpressionTree'
20 | import { getManager } from './dataSourceManager'
21 | import { getAllDataSources } from './dataSourceRegistry'
22 | import { Page, Editor } from 'grapesjs'
23 | import { getComponentDebug, NOTIFICATION_GROUP } from '../utils'
24 | import { resolveStateExpression } from './expressionEvaluator'
25 |
26 | /**
27 | * Get page query for both preview and production use
28 | * Used by preview system and 11ty site generation
29 | * @param page - The page to get queries for
30 | * @param editor - The GrapesJS editor instance
31 | * @param dataTree - The DataTree instance to use for query generation
32 | */
33 | export function getPageQuery(page: Page, editor: Editor): Record {
34 | const manager = getManager()
35 | const expressions: ComponentExpression[] = getPageExpressions(manager, page)
36 | const dataSources = getAllDataSources()
37 |
38 | return dataSources
39 | .map(ds => {
40 | if (!ds.isConnected()) {
41 | console.error('The data source is not yet connected, the value for this page can not be loaded')
42 | return {
43 | dataSourceId: ds.id.toString(),
44 | query: '',
45 | }
46 | }
47 |
48 | const dsExpressions = expressions
49 | // Resolve all states
50 | .map((componentExpression: ComponentExpression) => ({
51 | component: componentExpression.component,
52 | expression: componentExpression.expression.flatMap((token: StoredToken) => {
53 | switch(token.type) {
54 | case 'property':
55 | case 'filter':
56 | return token
57 | case 'state': {
58 | const resolved = resolveStateExpression(token, componentExpression.component, [])
59 | if (!resolved) {
60 | editor.runCommand('notifications:add', {
61 | type: 'error',
62 | group: NOTIFICATION_GROUP,
63 | message: `Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`,
64 | componentId: componentExpression.component.getId(),
65 | })
66 | throw new Error(`Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`)
67 | }
68 | return resolved
69 | }
70 | }
71 | }),
72 | }))
73 | // Keep only the expressions for the current data source
74 | .filter((componentExpression: ComponentExpression) => {
75 | const e = componentExpression.expression
76 | if(e.length === 0) return false
77 | // We resolved all states
78 | // An expression can not start with a filter
79 | // So this is a property
80 | const first = e[0] as Property
81 | // Keep only the expressions for the current data source
82 | return first?.dataSourceId === ds.id
83 | })
84 |
85 | const trees = toTrees(manager, dsExpressions, ds.id)
86 | if(trees.length === 0) {
87 | return {
88 | dataSourceId: ds.id.toString(),
89 | query: '',
90 | }
91 | }
92 |
93 | const query = ds.getQuery(trees)
94 | return {
95 | dataSourceId: ds.id.toString(),
96 | query,
97 | }
98 | })
99 | .filter(obj => !!obj.query)
100 | .reduce((acc, { dataSourceId, query }) => {
101 | acc[dataSourceId] = query
102 | return acc
103 | }, {} as Record)
104 | }
105 |
106 | /**
107 | * Build queries for multiple pages
108 | * Useful for batch operations like site generation
109 | * @param pages - Array of pages to build queries for
110 | * @param editor - The GrapesJS editor instance
111 | * @param dataTree - The DataTree instance to use for query generation
112 | */
113 | export function buildPageQueries(pages: Page[], editor: Editor): Record> {
114 | return pages.reduce((acc, page) => {
115 | acc[page.getId()] = getPageQuery(page, editor)
116 | return acc
117 | }, {} as Record>)
118 | }
119 |
--------------------------------------------------------------------------------
/src/model/dataSourceRegistry.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | import { jest } from '@jest/globals'
5 | import grapesjs, { Editor } from 'grapesjs'
6 | import {
7 | initializeDataSourceRegistry,
8 | getAllDataSources,
9 | addDataSource,
10 | removeDataSource,
11 | getDataSource,
12 | setDataSources,
13 | dataSourcesToJSON,
14 | } from './dataSourceRegistry'
15 | import { IDataSource } from '../types'
16 |
17 | // FIXME: Workaround to avoid import of lit-html which breaks unit tests
18 | jest.mock('lit', () => ({
19 | html: jest.fn(),
20 | render: jest.fn(),
21 | }))
22 |
23 | describe('DataSourceRegistry', () => {
24 | let editor: Editor
25 | let mockDataSource1: IDataSource
26 | let mockDataSource2: IDataSource
27 |
28 | beforeEach(() => {
29 | editor = grapesjs.init({
30 | container: document.createElement('div'),
31 | components: '
',
32 | })
33 |
34 | mockDataSource1 = {
35 | id: 'test1',
36 | label: 'Test Data Source 1',
37 | url: 'http://test1.com',
38 | type: 'graphql',
39 | method: 'POST',
40 | headers: { 'Content-Type': 'application/json' },
41 | readonly: false,
42 | hidden: false,
43 | connect: jest.fn(() => Promise.resolve()),
44 | isConnected: jest.fn(() => true),
45 | getTypes: jest.fn(() => []),
46 | getQueryables: jest.fn(() => []),
47 | getQuery: jest.fn(() => ''),
48 | fetchValues: jest.fn(() => Promise.resolve({})),
49 | on: jest.fn(),
50 | off: jest.fn(),
51 | }
52 |
53 | mockDataSource2 = {
54 | id: 'test2',
55 | label: 'Test Data Source 2',
56 | url: 'http://test2.com',
57 | type: 'graphql',
58 | method: 'GET',
59 | headers: {},
60 | readonly: true,
61 | hidden: false,
62 | connect: jest.fn(() => Promise.resolve()),
63 | isConnected: jest.fn(() => true),
64 | getTypes: jest.fn(() => []),
65 | getQueryables: jest.fn(() => []),
66 | getQuery: jest.fn(() => ''),
67 | fetchValues: jest.fn(() => Promise.resolve({})),
68 | on: jest.fn(),
69 | off: jest.fn(),
70 | }
71 |
72 | initializeDataSourceRegistry(editor)
73 | })
74 |
75 | it('should initialize with empty data sources', () => {
76 | expect(getAllDataSources()).toHaveLength(0)
77 | })
78 |
79 | it('should add a data source', () => {
80 | addDataSource(mockDataSource1)
81 | expect(getAllDataSources()).toHaveLength(1)
82 | expect(getAllDataSources()[0]).toBe(mockDataSource1)
83 | })
84 |
85 | it('should remove a data source', () => {
86 | addDataSource(mockDataSource1)
87 | addDataSource(mockDataSource2)
88 | expect(getAllDataSources()).toHaveLength(2)
89 |
90 | removeDataSource(mockDataSource1)
91 | expect(getAllDataSources()).toHaveLength(1)
92 | expect(getAllDataSources()[0]).toBe(mockDataSource2)
93 | })
94 |
95 | it('should get a data source by ID', () => {
96 | addDataSource(mockDataSource1)
97 | addDataSource(mockDataSource2)
98 |
99 | expect(getDataSource('test1')).toBe(mockDataSource1)
100 | expect(getDataSource('test2')).toBe(mockDataSource2)
101 | expect(getDataSource('nonexistent')).toBeUndefined()
102 | })
103 |
104 | it('should set all data sources', () => {
105 | addDataSource(mockDataSource1)
106 | expect(getAllDataSources()).toHaveLength(1)
107 |
108 | setDataSources([mockDataSource2])
109 | expect(getAllDataSources()).toHaveLength(1)
110 | expect(getAllDataSources()[0]).toBe(mockDataSource2)
111 | })
112 |
113 | it('should convert data sources to JSON', () => {
114 | addDataSource(mockDataSource1)
115 | addDataSource(mockDataSource2)
116 |
117 | const json = dataSourcesToJSON()
118 | expect(json).toHaveLength(2)
119 | expect(json[0]).toEqual({
120 | id: 'test1',
121 | label: 'Test Data Source 1',
122 | url: 'http://test1.com',
123 | type: 'graphql',
124 | method: 'POST',
125 | headers: { 'Content-Type': 'application/json' },
126 | readonly: false,
127 | hidden: false,
128 | })
129 | expect(json[1]).toEqual({
130 | id: 'test2',
131 | label: 'Test Data Source 2',
132 | url: 'http://test2.com',
133 | type: 'graphql',
134 | method: 'GET',
135 | headers: {},
136 | readonly: true,
137 | hidden: false,
138 | })
139 | })
140 |
141 | it('should trigger DATA_SOURCE_CHANGED event when adding data source', async () => {
142 | const triggerSpy = jest.spyOn(editor, 'trigger')
143 | addDataSource(mockDataSource1)
144 |
145 | // Wait for the promise to resolve
146 | await new Promise(resolve => setTimeout(resolve, 0))
147 |
148 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed')
149 | })
150 |
151 | it('should trigger DATA_SOURCE_CHANGED event when removing data source', () => {
152 | addDataSource(mockDataSource1)
153 | const triggerSpy = jest.spyOn(editor, 'trigger')
154 | removeDataSource(mockDataSource1)
155 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed')
156 | })
157 |
158 | it('should trigger DATA_SOURCE_CHANGED event when setting data sources', () => {
159 | const triggerSpy = jest.spyOn(editor, 'trigger')
160 | setDataSources([mockDataSource1])
161 | expect(triggerSpy).toHaveBeenCalledWith('data-source:changed')
162 | })
163 |
164 | it('should throw error when not initialized', async () => {
165 | // Create a new registry instance to test uninitialized state
166 | jest.resetModules()
167 | const { getAllDataSources: uninitializedGetAll } = await import('./dataSourceRegistry')
168 | expect(() => uninitializedGetAll()).toThrow('DataSourceRegistry not initialized')
169 | })
170 | })
--------------------------------------------------------------------------------
/integration-tests/graphql-modules.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "__typename": "ApplicationQueries",
4 | "queryPageContents": [
5 | {
6 | "__typename": "Page",
7 | "flatData": {
8 | "__typename": "PageFlatDataDto",
9 | "label": "Home",
10 | "modules": [
11 | {
12 | "__typename": "PageDataModulesChildDto",
13 | "item": {
14 | "__typename": "SimpleHeroComponent",
15 | "type": "simple-hero"
16 | }
17 | },
18 | {
19 | "__typename": "PageDataModulesChildDto",
20 | "item": {}
21 | },
22 | {
23 | "__typename": "PageDataModulesChildDto",
24 | "item": {
25 | "__typename": "SpecialHeadingComponent",
26 | "type": "special-heading"
27 | }
28 | },
29 | {
30 | "__typename": "PageDataModulesChildDto",
31 | "item": {}
32 | },
33 | {
34 | "__typename": "PageDataModulesChildDto",
35 | "item": {}
36 | },
37 | {
38 | "__typename": "PageDataModulesChildDto",
39 | "item": {}
40 | },
41 | {
42 | "__typename": "PageDataModulesChildDto",
43 | "item": {
44 | "__typename": "SpecialHeadingComponent",
45 | "type": "special-heading"
46 | }
47 | },
48 | {
49 | "__typename": "PageDataModulesChildDto",
50 | "item": {}
51 | },
52 | {
53 | "__typename": "PageDataModulesChildDto",
54 | "item": {}
55 | },
56 | {
57 | "__typename": "PageDataModulesChildDto",
58 | "item": {}
59 | },
60 | {
61 | "__typename": "PageDataModulesChildDto",
62 | "item": {}
63 | },
64 | {
65 | "__typename": "PageDataModulesChildDto",
66 | "item": {}
67 | },
68 | {
69 | "__typename": "PageDataModulesChildDto",
70 | "item": {}
71 | },
72 | {
73 | "__typename": "PageDataModulesChildDto",
74 | "item": {
75 | "__typename": "SpecialHeadingComponent",
76 | "type": "special-heading"
77 | }
78 | },
79 | {
80 | "__typename": "PageDataModulesChildDto",
81 | "item": {}
82 | },
83 | {
84 | "__typename": "PageDataModulesChildDto",
85 | "item": {}
86 | },
87 | {
88 | "__typename": "PageDataModulesChildDto",
89 | "item": {
90 | "__typename": "SectionSquaresComponent",
91 | "type": "section-squares"
92 | }
93 | },
94 | {
95 | "__typename": "PageDataModulesChildDto",
96 | "item": {
97 | "__typename": "SectionSquaresComponent",
98 | "type": "section-squares"
99 | }
100 | },
101 | {
102 | "__typename": "PageDataModulesChildDto",
103 | "item": {
104 | "__typename": "SpecialHeadingComponent",
105 | "type": "special-heading"
106 | }
107 | },
108 | {
109 | "__typename": "PageDataModulesChildDto",
110 | "item": {}
111 | }
112 | ]
113 | }
114 | },
115 | {
116 | "__typename": "Page",
117 | "flatData": {
118 | "__typename": "PageFlatDataDto",
119 | "label": "Help and support",
120 | "modules": [
121 | {
122 | "__typename": "PageDataModulesChildDto",
123 | "item": {
124 | "__typename": "SimpleHeroComponent",
125 | "type": "simple-hero"
126 | }
127 | },
128 | {
129 | "__typename": "PageDataModulesChildDto",
130 | "item": {
131 | "__typename": "SpecialHeadingComponent",
132 | "type": "special-heading"
133 | }
134 | },
135 | {
136 | "__typename": "PageDataModulesChildDto",
137 | "item": {
138 | "__typename": "SpecialHeadingComponent",
139 | "type": "special-heading"
140 | }
141 | },
142 | {
143 | "__typename": "PageDataModulesChildDto",
144 | "item": {
145 | "__typename": "SectionSquaresComponent",
146 | "type": "section-squares"
147 | }
148 | },
149 | {
150 | "__typename": "PageDataModulesChildDto",
151 | "item": {
152 | "__typename": "SpecialHeadingComponent",
153 | "type": "special-heading"
154 | }
155 | },
156 | {
157 | "__typename": "PageDataModulesChildDto",
158 | "item": {}
159 | },
160 | {
161 | "__typename": "PageDataModulesChildDto",
162 | "item": {
163 | "__typename": "SpecialHeadingComponent",
164 | "type": "special-heading"
165 | }
166 | }
167 | ]
168 | }
169 | }
170 | ]
171 | },
172 | "extensions": {
173 | "tracing": {
174 | "version": 1,
175 | "startTime": "2025-08-21T00:55:20.1733794Z",
176 | "endTime": "2025-08-21T00:55:20.1887985Z",
177 | "duration": 15419100,
178 | "parsing": {
179 | "startOffset": 500,
180 | "duration": 28100
181 | },
182 | "validation": {
183 | "startOffset": 29300,
184 | "duration": 467900
185 | },
186 | "execution": {
187 | "resolvers": []
188 | }
189 | }
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/example.graphql:
--------------------------------------------------------------------------------
1 | query {
2 | __typename
3 | queryPageContents(skip: 0) {
4 | __typename
5 | data {
6 | __typename
7 | slug {
8 | __typename
9 | fr
10 | en
11 |
12 | }
13 | modules {
14 | __typename
15 | en {
16 | __typename
17 | item__Dynamic
18 |
19 | }
20 |
21 | }
22 |
23 | }
24 | flatData {
25 | __typename
26 | shareImage {
27 | __typename
28 | url
29 |
30 | }
31 | description
32 | lang
33 | slugFr
34 | slugEn
35 | slug
36 | listImage {
37 | __typename
38 | url
39 |
40 | }
41 | title
42 | pubDate
43 | type
44 | modules {
45 | __typename
46 |
47 | item {
48 | ...on HeroWordSliderComponent {
49 | __typename
50 | type
51 | before
52 | words {
53 | __typename
54 | word
55 |
56 | }
57 | after
58 | cTAUrl
59 | cTALabel
60 |
61 | }
62 | }
63 | item {
64 | ...on SimpleHeroComponent {
65 | __typename
66 | type
67 | text
68 | secondaryUrl
69 | secondaryLabel
70 | cTAUrl
71 | cTALabel
72 |
73 | }
74 | }
75 | item {
76 | ...on SectionSlideshowUpComponent {
77 | __typename
78 | type
79 |
80 | }
81 | }
82 | item {
83 | ...on SectionImageUpComponent {
84 | __typename
85 | type
86 | image {
87 | __typename
88 | url
89 | fileName
90 |
91 | }
92 |
93 | }
94 | }
95 | item {
96 | ...on SpecialHeadingComponent {
97 | __typename
98 | styleBg
99 | type
100 | title
101 | subtitle
102 | text
103 | uRLSecondary
104 | labelSecondary
105 | uRL
106 | label
107 |
108 | }
109 | }
110 | item {
111 | ...on SectionPatternGridComponent {
112 | __typename
113 | type
114 | style
115 | title
116 | subtitle
117 | text
118 | uRLSecondary
119 | labelSecondary
120 | uRL
121 | label
122 | image {
123 | __typename
124 | url
125 | slug
126 |
127 | }
128 | shadow
129 |
130 | }
131 | }
132 | item {
133 | ...on SectionCodeComponent {
134 | __typename
135 | type
136 | code
137 |
138 | }
139 | }
140 | item {
141 | ...on SectionFriendsComponent {
142 | __typename
143 | type
144 |
145 | }
146 | }
147 | item {
148 | ...on SectionSquaresComponent {
149 | __typename
150 | type
151 | styleBg
152 | mainTitle
153 | mainSubtitle
154 | mainText
155 | cards {
156 | __typename
157 | image {
158 | __typename
159 | url
160 | fileName
161 |
162 | }
163 | title
164 | text
165 | uRL
166 | label
167 |
168 | }
169 | cTAUrl
170 | cTALabel
171 |
172 | }
173 | }
174 | item {
175 | ...on SectionSquaresFeaturesComponent {
176 | __typename
177 | type
178 | featureTitle
179 | featureSubtitle
180 | featureText
181 |
182 | }
183 | }
184 | item {
185 | ...on SectionSquaresPagesComponent {
186 | __typename
187 | type
188 | filterByType
189 |
190 | }
191 | }
192 | item {
193 | ...on SectionSquaresMastodonComponent {
194 | __typename
195 | type
196 | ctasLabel
197 |
198 | }
199 | }
200 | item {
201 | ...on SpecialHeadingImgComponent {
202 | __typename
203 | type
204 | image {
205 | __typename
206 | url
207 | slug
208 |
209 | }
210 | title
211 | subtitle
212 | text
213 | uRLSecondary
214 | labelSecondary
215 | uRL
216 | label
217 |
218 | }
219 | }
220 | item {
221 | ...on SectionRectBgComponent {
222 | __typename
223 | type
224 | title
225 | subtitle
226 | cards {
227 | __typename
228 | link
229 | image {
230 | __typename
231 | url
232 | fileName
233 |
234 | }
235 | title
236 | text
237 |
238 | }
239 |
240 | }
241 | }
242 | item {
243 | ...on SectionNetworkComponent {
244 | __typename
245 | type
246 | title
247 | subtitle
248 | cards {
249 | __typename
250 | link
251 | image {
252 | __typename
253 | url
254 | fileName
255 |
256 | }
257 | title
258 | text
259 |
260 | }
261 |
262 | }
263 | }
264 | item {
265 | ...on SectionBlogDataComponent {
266 | __typename
267 | type
268 | author {
269 | __typename
270 | flatData {
271 | __typename
272 | name
273 |
274 | }
275 |
276 | }
277 | date
278 |
279 | }
280 | }
281 | }
282 |
283 | }
284 |
285 | }
286 | findGlobalSingleton {
287 | __typename
288 | flatData {
289 | __typename
290 | favicon {
291 | __typename
292 | url
293 |
294 | }
295 | url
296 | nav {
297 | __typename
298 | label
299 | url
300 | title
301 |
302 | }
303 | footerHeadingsTitle
304 | footerHeadingsSubtitle
305 | footerHeadingsText
306 | uRLSecondary
307 | labelSecondary
308 | uRL
309 | label
310 | links {
311 | __typename
312 | title
313 | content
314 |
315 | }
316 | footerLegal
317 |
318 | }
319 |
320 | }
321 | queryGlobalContents(skip: 0) {
322 | __typename
323 | flatData {
324 | __typename
325 | banner {
326 | __typename
327 | html(indentation: 4)
328 |
329 | }
330 | bannerLink
331 | ctaCardsLabel
332 |
333 | }
334 |
335 | }
336 | queryFeatureContents(skip: 0) {
337 | __typename
338 | flatData {
339 | __typename
340 | image {
341 | __typename
342 | url
343 | fileName
344 |
345 | }
346 | title
347 | text
348 | uRL
349 | label
350 |
351 | }
352 |
353 | }
354 |
355 | }
356 |
--------------------------------------------------------------------------------
/src/view/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { DATA_SOURCE_CHANGED, DATA_SOURCE_DATA_LOAD_END, COMPONENT_STATE_CHANGED, DataSourceEditorOptions, DataSourceEditorViewOptions, Properties } from '../types'
19 | import { PROPERTY_STYLES } from './defaultStyles'
20 |
21 | import { PropertiesEditor } from './properties-editor'
22 | import { CustomStatesEditor } from './custom-states-editor'
23 |
24 | import settings from './settings'
25 | import { Editor } from 'grapesjs'
26 |
27 | import '@silexlabs/expression-input'
28 | import './properties-editor'
29 | import './custom-states-editor'
30 | import canvas from './canvas'
31 | import { getElementFromOption } from '../utils'
32 |
33 | export default (editor: Editor, opts: DataSourceEditorOptions) => {
34 | const options: DataSourceEditorViewOptions = {
35 | styles: PROPERTY_STYLES,
36 | defaultFixed: false,
37 | disableStates: false,
38 | disableAttributes: false,
39 | disableProperties: false,
40 | previewDebounceDelay: 100,
41 | previewRefreshEvents: `
42 | ${DATA_SOURCE_CHANGED}
43 | ${DATA_SOURCE_DATA_LOAD_END}
44 | ${COMPONENT_STATE_CHANGED}
45 | storage:after:load
46 | component:update:classes
47 | component:add
48 | component:remove
49 | `,
50 | ...opts.view,
51 | }
52 |
53 | if (opts.view.el) {
54 | // create a wrapper for our UI
55 | const wrapper = document.createElement('section')
56 | wrapper.classList.add('gjs-one-bg', 'ds-wrapper')
57 |
58 | // Add the web components
59 | const states = options.disableStates ? '' : `
60 |
75 |
78 |
79 | `
80 | const attributes = options.disableAttributes ? '' : `
81 |
96 |
99 |
100 | `
101 | const properties = options.disableProperties ? '' : `
102 |
106 |
109 |
110 | `
111 | wrapper.innerHTML = `
112 | ${states}
113 | ${attributes}
114 | ${properties}
115 | `
116 |
117 | // Build the settings view
118 | settings(editor, options)
119 |
120 | // The options el and button can be functions which use editor so they need to be called asynchronously
121 | editor.onReady(() => {
122 | // Get the container element for the UI
123 | const el = getElementFromOption(options.el, 'options.el')
124 |
125 | // Append the wrapper to the container
126 | el.appendChild(wrapper)
127 |
128 | // Get references to the web components
129 | const propertiesUi = wrapper.querySelector('properties-editor.ds-properties') as PropertiesEditor
130 | const statesUi = wrapper.querySelector('custom-states-editor.ds-states') as CustomStatesEditor
131 | const attributesUi = wrapper.querySelector('custom-states-editor.ds-attributes') as CustomStatesEditor
132 |
133 | // Init web components
134 | propertiesUi?.setEditor(editor)
135 | statesUi?.setEditor(editor)
136 | attributesUi?.setEditor(editor)
137 |
138 | // Show the UI when the button is clicked
139 | if (options.button) {
140 | const button = typeof options.button === 'function' ? options.button() : options.button
141 | if (!button) throw new Error(`Element ${options.button} not found`)
142 | button.on('change', () => {
143 | if (button.active) {
144 | // Move at the bottom
145 | el.appendChild(wrapper)
146 | // Show the UI
147 | wrapper.style.display = 'block'
148 | // Change web components state
149 | propertiesUi?.removeAttribute('disabled')
150 | statesUi?.removeAttribute('disabled')
151 | attributesUi?.removeAttribute('disabled')
152 | } else {
153 | // Hide the UI
154 | wrapper.style.display = 'none'
155 | // Change web components state
156 | propertiesUi?.setAttribute('disabled', '')
157 | statesUi?.setAttribute('disabled', '')
158 | attributesUi?.setAttribute('disabled', '')
159 | }
160 | })
161 | wrapper.style.display = button.active ? 'block' : 'none'
162 | }
163 | })
164 | } else {
165 | console.info('Dynamic data UI not enabled, please set the el option to enable it')
166 | }
167 | canvas(editor, options)
168 | }
169 |
--------------------------------------------------------------------------------
/src/model/previewDataLoader.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { DataSourceId, DATA_SOURCE_DATA_LOAD_START, DATA_SOURCE_DATA_LOAD_END, DATA_SOURCE_DATA_LOAD_CANCEL } from '../types'
19 | import { getPreviewData as getPreviewDataFromManager, setPreviewData as setPreviewDataInManager } from './dataSourceManager'
20 | import { getAllDataSources } from './dataSourceRegistry'
21 | import { getPageQuery } from './queryBuilder'
22 | import { Page, Editor } from 'grapesjs'
23 | import { NOTIFICATION_GROUP } from '../utils'
24 |
25 | /**
26 | * Preview data loader state
27 | */
28 | interface PreviewDataLoaderState {
29 | editor: Editor
30 | currentUpdatePid: number
31 | lastQueries: Record
32 | }
33 |
34 | // Global loader instance
35 | let globalLoader: PreviewDataLoaderState | null = null
36 |
37 | /**
38 | * Initialize the preview data loader
39 | */
40 | export function initializePreviewDataLoader(editor: Editor): void {
41 | globalLoader = {
42 | editor,
43 | currentUpdatePid: 0,
44 | lastQueries: {},
45 | }
46 | }
47 |
48 | /**
49 | * Get the global loader (throws if not initialized)
50 | */
51 | function getLoader(): PreviewDataLoaderState {
52 | if (!globalLoader) {
53 | throw new Error('PreviewDataLoader not initialized. Call initializePreviewDataLoader first.')
54 | }
55 | return globalLoader
56 | }
57 |
58 | /**
59 | * Compare two query objects to see if they are equal
60 | */
61 | function areQueriesEqual(queries1: Record, queries2: Record): boolean {
62 | const keys1 = Object.keys(queries1).sort()
63 | const keys2 = Object.keys(queries2).sort()
64 |
65 | // Check if they have the same number of keys
66 | if (keys1.length !== keys2.length) {
67 | return false
68 | }
69 |
70 | // Check if all keys are the same
71 | if (!keys1.every(key => keys2.includes(key))) {
72 | return false
73 | }
74 |
75 | // Check if all values are the same
76 | return keys1.every(key => queries1[key] === queries2[key])
77 | }
78 |
79 | /**
80 | * Load preview data for the current page
81 | * @param forceRefresh - If true, bypass query comparison and force refresh
82 | */
83 | export async function loadPreviewData(forceRefresh: boolean = false): Promise {
84 | const loader = getLoader()
85 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_START)
86 |
87 | const page = loader.editor.Pages.getSelected()
88 | if (!page) return
89 |
90 | // Get current queries
91 | const currentQueries = getPageQuery(page, loader.editor)
92 |
93 | // Compare with last queries to see if we need to refresh
94 | const queriesChanged = !areQueriesEqual(loader.lastQueries, currentQueries)
95 |
96 | if (!forceRefresh && !queriesChanged) {
97 | // Queries haven't changed, no need to refresh data sources
98 | // But still trigger load end to maintain expected event flow
99 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_END, getPreviewDataFromManager())
100 | return
101 | }
102 |
103 | // Update last queries
104 | loader.lastQueries = { ...currentQueries }
105 |
106 | loader.currentUpdatePid++
107 | const data = await fetchPagePreviewData(page)
108 |
109 | if (data !== 'interrupted') {
110 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_END, data)
111 | } else {
112 | console.warn(`Preview data update process for PID ${loader.currentUpdatePid} was interrupted.`)
113 | loader.editor.trigger(DATA_SOURCE_DATA_LOAD_CANCEL, data)
114 | }
115 | }
116 |
117 | /**
118 | * Fetch preview data for a specific page
119 | * @param page - The page object for which preview data needs to be fetched
120 | * @return The preview data returned by all data sources, or 'interrupted' if cancelled
121 | */
122 | export async function fetchPagePreviewData(page: Page): Promise | 'interrupted'> {
123 | const loader = getLoader()
124 | const myPid = loader.currentUpdatePid
125 | const queries = getPageQuery(page, loader.editor)
126 |
127 | // Reset preview data
128 | setPreviewDataInManager({})
129 |
130 | try {
131 | const results = await Promise.all(
132 | Object.entries(queries)
133 | .map(async ([dataSourceId, query]) => {
134 | if (myPid !== loader.currentUpdatePid) return
135 |
136 | const ds = getAllDataSources().find(ds => ds.id === dataSourceId)
137 | if (!ds) {
138 | console.error(`Data source ${dataSourceId} not found`)
139 | return null
140 | }
141 |
142 | if (!ds.isConnected()) {
143 | console.warn(`Data source ${dataSourceId} is not connected.`)
144 | return null
145 | }
146 |
147 | try {
148 | const value = await ds.fetchValues(query)
149 | const currentData = getPreviewDataFromManager()
150 | setPreviewDataInManager({ ...currentData, [dataSourceId]: value })
151 | return { dataSourceId, value }
152 | } catch (err) {
153 | console.error(`Error fetching preview data for data source ${dataSourceId}:`, err)
154 | loader.editor.runCommand('notifications:add', {
155 | type: 'error',
156 | group: NOTIFICATION_GROUP,
157 | message: `Error fetching preview data for data source ${dataSourceId}: ${err}`,
158 | })
159 | return null
160 | }
161 | })
162 | )
163 |
164 | if (myPid !== loader.currentUpdatePid) return 'interrupted'
165 |
166 | return results
167 | .filter(result => result !== null)
168 | .reduce((acc, result) => {
169 | const { dataSourceId, value } = result!
170 | acc[dataSourceId] = value
171 | return acc
172 | }, {} as Record)
173 | } catch (err) {
174 | console.error('Error while fetching preview data:', err)
175 | loader.editor.runCommand('notifications:add', {
176 | type: 'error',
177 | group: NOTIFICATION_GROUP,
178 | message: `Error while fetching preview data: ${err}`,
179 | })
180 | return {}
181 | }
182 | }
183 |
184 | /**
185 | * Get current preview data
186 | */
187 | export function getPreviewData(): Record {
188 | return getPreviewDataFromManager()
189 | }
190 |
191 | /**
192 | * Clear preview data
193 | */
194 | export function clearPreviewData(): void {
195 | setPreviewDataInManager({})
196 | }
197 |
--------------------------------------------------------------------------------
/src/model/state.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { Component, Editor } from 'grapesjs'
19 | import { Expression, StateId, State } from '../types'
20 |
21 | /**
22 | * @fileoverview This file contains the model for components states
23 | * A state is a value which can be used in expressions
24 | * If exported it will be available in the context of child components
25 | */
26 |
27 | // Keys to store the states in the component
28 | const EXPORTED_STATES_KEY = 'publicStates'
29 | const PRIVATE_STATES_KEY = 'privateStates'
30 |
31 | /**
32 | * Persistant ID is used to identify a component reliably
33 | * It will be stored with the website data
34 | */
35 | const PERSISTANT_ID_KEY = 'id-plugin-data-source'
36 |
37 | /**
38 | * Override the prefix of state names
39 | */
40 | export const COMPONENT_NAME_PREFIX = 'nameForDataSource'
41 |
42 | /**
43 | * Types
44 | */
45 | export interface StoredState {
46 | label?: string
47 | hidden?: boolean
48 | expression: Expression
49 | }
50 |
51 | export interface StoredStateWithId extends StoredState {
52 | id: StateId
53 | }
54 |
55 | export type PersistantId = string
56 |
57 | /**
58 | * Get the persistant ID of a component
59 | */
60 | export function getPersistantId(component: Component): PersistantId | null {
61 | return component.get(PERSISTANT_ID_KEY) ?? null
62 | }
63 |
64 | /**
65 | * Get the persistant ID of a component and create it if it does not exist
66 | */
67 | export function getOrCreatePersistantId(component: Component): PersistantId {
68 | const persistantId = component.get(PERSISTANT_ID_KEY)
69 | if(persistantId) return persistantId
70 | const newPersistantId = `${component.ccid}-${Math.round(Math.random() * 10000)}` as PersistantId
71 | component.set(PERSISTANT_ID_KEY, newPersistantId)
72 | return newPersistantId
73 | }
74 |
75 | /**
76 | * Find a component by its persistant ID in the current page
77 | */
78 | export function getComponentByPersistentId(id: PersistantId, editor: Editor): Component | null {
79 | const pages = editor.Pages.getAll()
80 | for(const page of pages) {
81 | const body = page.getMainComponent()
82 | const component = getChildByPersistantId(id, body)
83 | if(component) return component
84 | }
85 | return null
86 | }
87 |
88 | /**
89 | * Find a component by its persistant ID in
90 | */
91 | export function getChildByPersistantId(id: PersistantId, parent: Component): Component | null {
92 | if(getPersistantId(parent) === id) return parent
93 | for(const child of parent.components()) {
94 | const component = getChildByPersistantId(id, child)
95 | if(component) return component
96 | }
97 | return null
98 | }
99 |
100 | /**
101 | * Find a component by its persistant ID in the current page
102 | */
103 | export function getParentByPersistentId(id: PersistantId, component: Component | undefined): Component | null {
104 | if(!component) return null
105 | if(getPersistantId(component) === id) return component
106 | return getParentByPersistentId(id, component.parent())
107 | }
108 |
109 | /**
110 | * Get the display name of a state
111 | */
112 | export function getStateDisplayName(child: Component, state: State): string {
113 | const component = getParentByPersistentId(state.componentId, child)
114 | //const name = component?.getName() ?? '[Not found]'
115 | const prefix = component?.get(COMPONENT_NAME_PREFIX) ?? '' // `${name}'s`
116 | return `${prefix ? prefix + ' ' : ''}${state.label || state.storedStateId}`
117 | }
118 |
119 | /**
120 | * Callbacks called when a state is changed
121 | * @returns A function to remove the callback
122 | */
123 | const _callbacks: ((state: StoredState | null, component: Component) => void)[] = []
124 | export function onStateChange(callback: (state: StoredState | null, component: Component) => void): () => void {
125 | _callbacks.push(callback)
126 | return () => {
127 | const index = _callbacks.indexOf(callback)
128 | if(index >= 0) _callbacks.splice(index, 1)
129 | }
130 | }
131 | function fireChange(state: StoredState | null, component: Component) {
132 | _callbacks.forEach(callback => callback(state, component))
133 | }
134 |
135 | /**
136 | * List all exported states
137 | */
138 | export function getStateIds(component: Component, exported: boolean = true, before?: StateId): StateId[] {
139 | try {
140 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? []
141 | const allStates = states
142 | .sort(a => a.hidden ? -1 : 0) // Hidden states first
143 | .map(state => state.id)
144 | if(before) {
145 | const index = allStates.indexOf(before)
146 | if(index < 0) return allStates
147 | return allStates.slice(0, index)
148 | }
149 | return allStates
150 | } catch(e) {
151 | // this happens when the old deprecated state system is used
152 | console.error('Error while getting state ids', e)
153 | return []
154 | }
155 | }
156 |
157 | /**
158 | * List all exported states
159 | */
160 | export function getStates(component: Component, exported: boolean = true): StoredState[] {
161 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? []
162 | return states.map(state => ({
163 | label: state.label,
164 | hidden: state.hidden,
165 | expression: state.expression,
166 | }))
167 | }
168 |
169 | /**
170 | * Get the name of a state variable
171 | * Useful to generate code
172 | */
173 | export function getStateVariableName(componentId: string, stateId: StateId): string {
174 | return `state_${ componentId }_${ stateId }`
175 | }
176 |
177 | /**
178 | * Get a state
179 | */
180 | export function getState(component: Component, id: StateId, exported: boolean = true): StoredState | null {
181 | const states = component.get(exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY) as StoredStateWithId[] ?? []
182 | const state = states.find(state => state.id === id) ?? null
183 | if(!state) {
184 | return null
185 | }
186 | return {
187 | label: state.label,
188 | hidden: state.hidden,
189 | expression: state.expression,
190 | }
191 | }
192 |
193 | /**
194 | * Set a state
195 | * The state will be updated or created at the end of the list
196 | * Note: index is not used in this project anymore (maybe in apps using this plugins)
197 | */
198 | export function setState(component: Component, id: StateId, state: StoredState, exported = true, index = -1): void {
199 | const key = exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY
200 | const states = component.get(key) as StoredStateWithId[] ?? []
201 | const existing = states.find(s => s.id === id) ?? null
202 | if(existing) {
203 | component.set(key, states.map(s => s.id !== id ? s : {
204 | id,
205 | ...state,
206 | }))
207 | } else {
208 | component.set(key, [
209 | ...states,
210 | {
211 | id,
212 | ...state,
213 | },
214 | ])
215 | }
216 | // Set the index if needed
217 | if(index >= 0) {
218 | const states = [...component.get(key) as StoredStateWithId[]]
219 | const state = states.find(s => s.id === id)
220 | if(state && index < states.length) {
221 | states.splice(states.indexOf(state), 1)
222 | states.splice(index, 0, state)
223 | component.set(key, states)
224 | }
225 | }
226 | // Notify the change
227 | fireChange({
228 | label: state.label,
229 | hidden: state.hidden,
230 | expression: state.expression,
231 | }, component)
232 | }
233 |
234 | /**
235 | * Remove a state
236 | */
237 | export function removeState(component: Component, id: StateId, exported: boolean = true): void {
238 | const key = exported ? EXPORTED_STATES_KEY : PRIVATE_STATES_KEY
239 | const states = component.get(key) as StoredStateWithId[] ?? []
240 | const newStates = states.filter(s => s.id !== id)
241 | component.set(key, newStates)
242 | fireChange(null, component)
243 | }
244 |
--------------------------------------------------------------------------------
/src/model/expressionEvaluator.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { Component } from 'grapesjs'
19 | import { Expression, StoredToken, State, Property, Filter, DataSourceId, IDataSource, FIXED_TOKEN_ID } from '../types'
20 | import { DataSourceManagerState } from './dataSourceManager'
21 | import { getParentByPersistentId, getState } from './state'
22 | import { fromStored } from './token'
23 | import { toExpression } from '../utils'
24 |
25 | /**
26 | * Expression evaluation context
27 | */
28 | export interface EvaluationContext {
29 | readonly dataSources: readonly IDataSource[]
30 | readonly filters: readonly Filter[]
31 | readonly previewData: Record
32 | readonly component: Component
33 | readonly resolvePreviewIndex: boolean
34 | }
35 |
36 | /**
37 | * Handle preview index for array data
38 | */
39 | export function handlePreviewIndex(value: unknown, token: StoredToken): unknown {
40 | if (typeof token.previewIndex === 'undefined') {
41 | return value
42 | }
43 |
44 | if (Array.isArray(value)) {
45 | return value[token.previewIndex]
46 | }
47 | return value
48 | }
49 |
50 | /**
51 | * Evaluate a property token
52 | */
53 | export function evaluatePropertyToken(
54 | token: Property,
55 | remaining: Expression,
56 | context: EvaluationContext,
57 | prevValues: unknown
58 | ): unknown {
59 | // Handle "fixed" property (hard coded string set by the user)
60 | if (token.fieldId === FIXED_TOKEN_ID) {
61 | return evaluateExpressionTokens(remaining, context, token.options?.value)
62 | }
63 |
64 | // Get data object
65 | let prevObj
66 | if (typeof prevValues === 'undefined' || prevValues === null) {
67 | if (!token.dataSourceId) {
68 | throw new Error(`Data source ID is missing for token: ${JSON.stringify(token)}`)
69 | }
70 | prevObj = context.previewData[token.dataSourceId]
71 | } else {
72 | prevObj = prevValues
73 | }
74 |
75 | // Get the next value
76 | let value = prevObj ? (prevObj as Record)[token.fieldId] : null
77 |
78 | // Handle preview index if resolvePreviewIndex is true
79 | // Don't resolve if next token is a filter - filters need to operate on full arrays
80 | if (context.resolvePreviewIndex) {
81 | const nextToken = remaining[0]
82 | if (!nextToken || nextToken.type !== 'filter') {
83 | value = handlePreviewIndex(value, token)
84 | }
85 | }
86 |
87 | // For non-final tokens, handle preview index only if next token is not a filter
88 | // Filters need to operate on full arrays, not individual items
89 | if (remaining.length > 0 && !context.resolvePreviewIndex) {
90 | const nextToken = remaining[0]
91 | if (nextToken.type !== 'filter') {
92 | value = handlePreviewIndex(value, token)
93 | }
94 | }
95 |
96 | // Special handling for items state
97 | // @ts-expect-error - Runtime property check for items handling
98 | if (token.isItems && typeof token.previewIndex !== 'undefined') {
99 | if (remaining.length > 0) {
100 | value = [value]
101 | }
102 | }
103 |
104 | return evaluateExpressionTokens(remaining, context, value)
105 | }
106 |
107 | /**
108 | * Evaluate expression tokens recursively
109 | */
110 | export function evaluateExpressionTokens(
111 | expression: Expression,
112 | context: EvaluationContext,
113 | prevValues: unknown = null,
114 | ): unknown {
115 | if (expression.length === 0) {
116 | return prevValues
117 | }
118 |
119 | // Always create defensive copies of tokens to prevent mutations from affecting original data
120 | const cleanExpression = expression.map(token => ({ ...token }))
121 | const [token, ...rest] = cleanExpression
122 |
123 | switch (token.type) {
124 | case 'state': {
125 | return evaluateStateToken(token as State, rest, context, prevValues)
126 | }
127 | case 'property': {
128 | return evaluatePropertyToken(token as Property, rest, context, prevValues)
129 | }
130 | case 'filter': {
131 | return evaluateFilterToken(token as Filter, rest, context, prevValues)
132 | }
133 | default:
134 | throw new Error(`Unsupported token type: ${JSON.stringify(token)}`)
135 | }
136 | }
137 |
138 | /**
139 | * Evaluate a state token
140 | */
141 | export function evaluateStateToken(
142 | state: State,
143 | remaining: Expression,
144 | context: EvaluationContext,
145 | prevValues: unknown
146 | ): unknown {
147 | const resolvedExpression = resolveStateExpression(state, context.component, context)
148 | if (!resolvedExpression) {
149 | throw new Error(`Unable to resolve state: ${JSON.stringify(state)}`)
150 | }
151 |
152 | // Special handling for items state - always wrap result in array when resolvePreviewIndex is true
153 | const previewIndex = resolvedExpression[resolvedExpression.length - 1]?.previewIndex
154 | if (state.storedStateId === 'items' && typeof previewIndex !== 'undefined') {
155 | // @ts-expect-error - Adding runtime property for items state handling
156 | resolvedExpression[0].isItems = true
157 | }
158 |
159 | return evaluateExpressionTokens([...resolvedExpression, ...remaining], context, prevValues)
160 | }
161 |
162 | /**
163 | * Resolve a state token to its expression
164 | */
165 | export function resolveStateExpression(
166 | state: State,
167 | component: Component,
168 | context: DataSourceManagerState | EvaluationContext | readonly IDataSource[]
169 | ): Expression | null {
170 | const parent = getParentByPersistentId(state.componentId, component)
171 | if (!parent) {
172 | console.error('Component not found for state', state, component.get('id-plugin-data-source'))
173 | return null
174 | }
175 |
176 | // Get the expression of the state
177 | const storedState = getState(parent, state.storedStateId, state.exposed)
178 | if (!storedState?.expression) {
179 | console.warn('State is not defined on component', parent.getId(), state, storedState)
180 | return null
181 | }
182 |
183 | // Create a minimal DataTree-like object for fromStored compatibility
184 | return storedState.expression
185 | .flatMap((token: StoredToken) => {
186 | switch (token.type) {
187 | case 'state': {
188 | return resolveStateExpression(fromStored(token, component.getId()), parent, context) ?? []
189 | }
190 | default:
191 | return token
192 | }
193 | })
194 | }
195 |
196 | /**
197 | * Evaluate a filter token (Liquid filter)
198 | */
199 | export function evaluateFilterToken(
200 | token: Filter,
201 | remaining: Expression,
202 | context: EvaluationContext,
203 | prevValues: unknown
204 | ): unknown {
205 | const options = Object.entries(token.options).reduce((acc, [key, value]) => {
206 | // If value is a primitive (number, string, boolean), use it directly
207 | // Only evaluate as expression if it's an actual expression object/array
208 | const expression = toExpression(value)
209 | acc[key] = expression ? evaluateExpressionTokens(expression, context, null) : value
210 | return acc
211 | }, {} as Record)
212 |
213 | const filter = context.filters.find(f => f.id === token.id)
214 | if (!filter) {
215 | throw new Error(`Filter not found: ${token.id}`)
216 | }
217 |
218 | let value
219 | try {
220 | value = filter.apply(prevValues, options)
221 | } catch (e) {
222 | console.warn(`Filter "${filter.id}" error:`, e, {
223 | filter: filter.id,
224 | prevValues,
225 | options,
226 | valueType: typeof prevValues,
227 | isArray: Array.isArray(prevValues),
228 | isNull: prevValues === null,
229 | })
230 | // Mimic behavior of liquid - return null on error
231 | return null
232 | }
233 |
234 | // Don't resolve before filters - they need arrays
235 | const nextIsFilter = remaining.length > 0 && remaining[0]?.type === 'filter'
236 | if (!nextIsFilter && (context.resolvePreviewIndex || remaining.length > 0)) {
237 | value = handlePreviewIndex(value, token)
238 | }
239 |
240 | return evaluateExpressionTokens(remaining, context, value)
241 | }
242 |
--------------------------------------------------------------------------------
/src/model/completion.ts:
--------------------------------------------------------------------------------
1 | import { Component } from 'grapesjs'
2 | import { Context, DataSourceId, Expression, Field, Filter, IDataSource, Property, State, StateId, Token, Type, TypeId } from '../types'
3 | import { DataSourceManagerState, getManager, getTypes, STANDARD_TYPES } from './dataSourceManager'
4 | import { getOrCreatePersistantId, getState, getStateIds } from './state'
5 | import { getExpressionResultType, getTokenOptions } from './token'
6 | import { getFixedToken, NOTIFICATION_GROUP } from '../utils'
7 |
8 | /**
9 | * Get the context of a component
10 | * This includes all parents states, data sources queryable values, values provided in the options
11 | */
12 | export function getContext(component: Component, manager: DataSourceManagerState, currentStateId?: StateId, hideLoopData = false): Context {
13 | if (!component) {
14 | console.error('Component is required for context')
15 | throw new Error('Component is required for context')
16 | }
17 | // Get all queryable values from all data sources
18 | const queryable: Property[] = manager.cachedQueryables
19 | .map((field: Field) => {
20 | if (!field.dataSourceId) throw new Error(`Type ${field.id} has no data source`)
21 | return fieldToToken(field)
22 | })
23 | // Get all states in the component scope
24 | const states: State[] = []
25 | const loopProperties: Token[] = []
26 | let parent = component
27 | while (parent) {
28 | // Get explicitely set states
29 | states
30 | .push(...(getStateIds(parent, true, parent === component ? currentStateId : undefined)
31 | .map((stateId: StateId): State => ({
32 | type: 'state',
33 | storedStateId: stateId,
34 | previewIndex: 8888,
35 | label: getState(parent, stateId, true)?.label || stateId,
36 | componentId: getOrCreatePersistantId(parent),
37 | exposed: true,
38 | }))))
39 | // Get states from loops
40 | if (parent !== component || !hideLoopData) { // If it is a loop on the parent or if we don't hide the loop data
41 | const loopDataState = getState(parent, '__data', false)
42 | if (loopDataState) {
43 | try {
44 | const loopDataField = getExpressionResultType(loopDataState.expression, parent)
45 | if (loopDataField) {
46 | const displayName = (label: string) => `${parent.getName() ?? 'Unknown'}'s ${loopDataField.label} ${label}`
47 | if (loopDataField.kind === 'list') {
48 | loopProperties.push({
49 | type: 'state',
50 | storedStateId: '__data',
51 | componentId: getOrCreatePersistantId(parent),
52 | previewIndex: loopDataField.previewIndex,
53 | exposed: false,
54 | forceKind: 'object', // FIXME: this may be a scalar
55 | label: `Loop item (${loopDataField.label})`,
56 | }, {
57 | type: 'property',
58 | propType: 'field',
59 | fieldId: 'forloop.index0',
60 | label: displayName('forloop.index0'),
61 | kind: 'scalar',
62 | typeIds: ['number'],
63 | }, {
64 | type: 'property',
65 | propType: 'field',
66 | fieldId: 'forloop.index',
67 | label: displayName('forloop.index'),
68 | kind: 'scalar',
69 | typeIds: ['number'],
70 | })
71 | } else {
72 | console.warn('Loop item is not a list for component', parent, 'and state', loopDataState)
73 | }
74 | } else {
75 | console.warn('Loop item type not found for component', parent, 'and state', loopDataState)
76 | }
77 | } catch {
78 | console.error('Error while getting loop item for component', parent, 'and state', loopDataState)
79 | }
80 | }
81 | }
82 | // Go up to parent
83 | parent = parent.parent() as Component
84 | }
85 | // Get filters which accept no input
86 | const filters: Filter[] = manager.filters
87 | .filter(filter => {
88 | try {
89 | return filter.validate(null)
90 | } catch (e) {
91 | console.warn('Filter validate error:', e, {filter})
92 | return false
93 | }
94 | })
95 | // Add a fixed value
96 | const fixedValue = getFixedToken('')
97 | // Return the context
98 | return [
99 | ...queryable,
100 | ...states,
101 | ...loopProperties,
102 | ...filters,
103 | fixedValue,
104 | ]
105 | }
106 |
107 | /**
108 | * Create a property token from a field
109 | */
110 | export function fieldToToken(field: Field): Property {
111 | if (!field) throw new Error('Field is required for token')
112 | if (!field.dataSourceId) throw new Error(`Field ${field.id} has no data source`)
113 | return {
114 | type: 'property',
115 | propType: 'field',
116 | fieldId: field.id,
117 | label: field.label,
118 | typeIds: field.typeIds,
119 | dataSourceId: field.dataSourceId,
120 | kind: field.kind,
121 | ...getTokenOptions(field) ?? {},
122 | }
123 | }
124 |
125 | function getType(typeId: TypeId, dataSourceId: DataSourceId | null, componentId: string | null): Type {
126 | const manager = getManager()
127 | if(dataSourceId) {
128 | // Get the data source
129 | const dataSource = manager.dataSources
130 | .find((dataSource: IDataSource) => !dataSourceId || dataSource.id === dataSourceId)
131 | if(!dataSource) throw new Error(`Data source not found ${dataSourceId}`)
132 | // Get its types
133 | const types = dataSource?.getTypes()
134 | // Return the requested type
135 | const type = types.find((type: Type) => type.id === typeId)
136 | if (!type) {
137 | manager.editor.runCommand('notifications:add', {
138 | type: 'error',
139 | group: NOTIFICATION_GROUP,
140 | message: `Type not found ${dataSourceId ?? ''}.${typeId}`,
141 | componentId,
142 | })
143 | throw new Error(`Type not found ${dataSourceId ?? ''}.${typeId}`)
144 | }
145 | return type
146 | } else {
147 | // No data source id: search in standard types
148 | const standardType = STANDARD_TYPES.find(type => type.id === typeId.toLowerCase())
149 | if(standardType) return standardType
150 | // No data source id: search in all types
151 | const type = getTypes().find(type => type.id === typeId)
152 | if (!type) throw new Error(`Unknown type ${typeId}`)
153 | return type
154 | }
155 | }
156 |
157 | /**
158 | * Auto complete an expression
159 | * @returns a list of possible tokens to add to the expression
160 | */
161 | export function getCompletion(options: { component: Component, expression: Expression, manager: DataSourceManagerState, rootType?: TypeId, currentStateId?: StateId, hideLoopData?: boolean}): Context {
162 | const { component, expression, manager, rootType, currentStateId, hideLoopData } = options
163 | if (!component) throw new Error('Component is required for completion')
164 | if (!expression) throw new Error('Expression is required for completion')
165 | if (expression.length === 0) {
166 | if (rootType) {
167 | const type = getType(rootType, null, component.getId())
168 | if (!type) {
169 | console.warn('Root type not found', rootType)
170 | return []
171 | }
172 | return type.fields
173 | .map((field: Field) => fieldToToken(field))
174 | }
175 | return getContext(component, manager, currentStateId, hideLoopData)
176 | }
177 | const field = getExpressionResultType(expression, component)
178 | if (!field) {
179 | console.warn('Result type not found for expression', expression)
180 | return []
181 | }
182 | return ([] as Token[])
183 | // Add fields if the kind is object
184 | .concat(field.kind === 'object' ? field.typeIds
185 | // Find possible types
186 | .map((typeId: TypeId) => getType(typeId, field.dataSourceId ?? null, component.getId()))
187 | // Add all of their fields
188 | .flatMap((type: Type | null) => type?.fields ?? [])
189 | // To token
190 | .flatMap(
191 | (fieldOfField: Field): Token[] => {
192 | // const t: Type | null = this.findType(field.typeIds, field.dataSourceId)
193 | // if(!t) throw new Error(`Type ${field.typeIds} not found`)
194 | return fieldOfField.typeIds.map((typeId: TypeId) => ({
195 | ...fieldToToken(fieldOfField),
196 | typeIds: [typeId ],
197 | }))
198 | }
199 | ) : [])
200 | // Add filters
201 | .concat(
202 | manager.filters
203 | // Match input type
204 | .filter((filter: Filter) => {
205 | try {
206 | return filter.validate(field)
207 | } catch (e) {
208 | console.warn('Filter validate error:', e, {filter, field })
209 | return false
210 | }
211 | })
212 | )
213 | }
214 |
--------------------------------------------------------------------------------
/src/view/properties-editor.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import {LitElement, html} from 'lit'
19 | import { ref } from 'lit/directives/ref.js'
20 | import {property} from 'lit/decorators.js'
21 |
22 | import './state-editor'
23 | import { StateEditor } from './state-editor'
24 | import { Component, Editor } from 'grapesjs'
25 | import { PROPERTY_STYLES } from './defaultStyles'
26 | import { fromStored } from '../model/token'
27 | import { BinaryOperator, Properties, Token, UnariOperator } from '../types'
28 | import { getState, setState } from '../model/state'
29 | import { getFixedToken } from '../utils'
30 |
31 | /**
32 | * Editor for selected element's properties
33 | *
34 | * Usage:
35 | *
36 | * ```
37 | *
38 | *
39 | *
40 | * ```
41 | *
42 | */
43 |
44 | export class PropertiesEditor extends LitElement {
45 | @property({type: Boolean})
46 | disabled = false
47 |
48 | @property({type: Boolean, attribute: 'default-fixed'})
49 | defaultFixed = false
50 |
51 | inputs: Record = {
52 | innerHTML: undefined,
53 | condition: undefined,
54 | condition2: undefined,
55 | __data: undefined,
56 | }
57 |
58 | private editor: Editor | null = null
59 | private redrawing = false
60 |
61 | setEditor(editor: Editor) {
62 | if (this.editor) {
63 | console.warn('property-editor setEditor already set')
64 | return
65 | }
66 | this.editor = editor
67 |
68 | // Update the UI when a page is added/renamed/removed
69 | this.editor.on('page', () => this.requestUpdate())
70 |
71 | // Update the UI on component selection change
72 | this.editor.on('component:selected', () => this.requestUpdate())
73 |
74 | // Update the UI on component change
75 | this.editor.on('component:update', () => this.requestUpdate())
76 | }
77 |
78 | override render() {
79 | super.render()
80 | this.redrawing = true
81 | const selected = this.editor?.getSelected()
82 | const head = html`
83 |
86 |
87 | `
88 | const empty = html`
89 | ${head}
90 | Select an element to edit its properties
91 | `
92 | if(!this.editor || this.disabled) {
93 | this.resetInputs()
94 | this.redrawing = false
95 | return html``
96 | }
97 | if(!selected || selected.get('tagName') === 'body') {
98 | this.resetInputs()
99 | this.redrawing = false
100 | return empty
101 | }
102 | const result = html`
103 | ${head}
104 |
105 |
106 |
107 | Properties
108 |
109 | Help
110 |
114 |
115 |
116 |
117 |
118 | ${[
119 | {label: 'HTML content', name: Properties.innerHTML, publicState: false},
120 | ].map(({label, name, publicState}) => this.renderStateEditor(selected, label, name, publicState))}
121 |
122 |
123 |
124 |
125 | ${this.renderStateEditor(selected, 'Visibility Condition', Properties.condition, false)}
126 |
127 | ... is
128 | {
131 | const select = e.target as HTMLSelectElement
132 | const value = select.value
133 | if(!value) throw new Error('Selection required for operator select element')
134 | selected.set('conditionOperator', value)
135 | this.requestUpdate()
136 | }}
137 | >
138 |
139 | ${ Object.values(UnariOperator)
140 | .concat(Object.values(BinaryOperator))
141 | .map(operator => html`
142 | ${operator}
143 | `)
144 | }
145 |
146 | ${ this.renderStateEditor(selected, '', Properties.condition2, false, false, selected.has('conditionOperator') && Object.values(BinaryOperator).includes(selected.get('conditionOperator'))) }
147 |
148 |
149 |
150 |
151 | ${this.renderStateEditor(selected, 'Loop Data', Properties.__data, false, true)}
152 |
153 |
154 | `
155 | this.redrawing = false
156 | return result
157 | }
158 |
159 | resetInputs() {
160 | this.inputs = {
161 | innerHTML: undefined,
162 | condition: undefined,
163 | condition2: undefined,
164 | __data: undefined,
165 | }
166 | }
167 |
168 | renderStateEditor(selected: Component, label: string, name: Properties, publicState: boolean, hideLoopData = false, visible = true) {
169 | return html`
170 | {
179 | // Get the stateEditor ref
180 | if (el) {
181 | // Set the editor - we could do this only once
182 | const stateEditor = el as StateEditor
183 | // Store the stateEditor ref and the component it is representing
184 | if (!this.inputs[name]) {
185 | this.inputs[name] = {
186 | stateEditor,
187 | selected: undefined, // clear the selected component so that we update the data
188 | }
189 | }
190 | }
191 | // Finally update the data
192 | if (this.inputs[name]) {
193 | const stateEditorFinally = this.inputs[name]!.stateEditor
194 | this.redrawing = true
195 | try {
196 | stateEditorFinally.data = this.getTokens(selected, name, publicState)
197 | } catch (e) {
198 | console.error('Error setting data', e)
199 | stateEditorFinally.data = [getFixedToken(`Error setting data: ${e}`)]
200 | }
201 | this.redrawing = false
202 | // Store the selected component
203 | this.inputs[name]!.selected = selected
204 | }
205 | })}
206 | @change=${() => this.onChange(selected, name, publicState)}
207 | ?disabled=${this.disabled}
208 | >
209 | ${label}
210 |
211 | `
212 | }
213 |
214 | onChange(component: Component, name: Properties, publicState: boolean) {
215 | const {stateEditor} = this.inputs[name]!
216 | if(this.redrawing) return
217 | if (name === Properties.__data) {
218 | // Handle the case when data is empty (after clearing)
219 | if (stateEditor.data.length === 0) {
220 | setState(component, name, {
221 | expression: [],
222 | }, publicState)
223 | } else {
224 | setState(component, name, {
225 | expression: stateEditor.data.slice(0, -1).concat({
226 | ...stateEditor.data[stateEditor.data.length - 1],
227 | previewIndex: 0,
228 | } as unknown as Token),
229 | }, publicState)
230 | }
231 | } else {
232 | setState(component, name, {
233 | expression: stateEditor.data,
234 | }, publicState)
235 | }
236 | }
237 |
238 | getTokens(component: Component, name: Properties, publicState: boolean): Token[] {
239 | const state = getState(component, name, publicState)
240 | if(!state || !state.expression) return []
241 | return state.expression
242 | .filter(token => token && typeof token === 'object' && token.type) // Filter out invalid tokens
243 | .map(token => fromStored(token, component.getId()))
244 | }
245 | }
246 |
247 | if(!window.customElements.get('properties-editor')) {
248 | window.customElements.define('properties-editor', PropertiesEditor)
249 | }
250 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { Component } from 'grapesjs'
19 | import { TemplateResult } from 'lit'
20 | import { Button } from 'grapesjs'
21 |
22 |
23 | export interface DataSourceEditorViewOptions {
24 | el?: HTMLElement | string | undefined | (() => HTMLElement)
25 | settingsEl?: HTMLElement | string | (() => HTMLElement)
26 | button?: Button | (() => Button)
27 | styles?: string
28 | optionsStyles?: string
29 | defaultFixed?: boolean
30 | disableStates?: boolean
31 | disableAttributes?: boolean
32 | disableProperties?: boolean
33 | previewDebounceDelay?: number,
34 | previewRefreshEvents?: string, // used in tests
35 | }
36 |
37 | /**
38 | * Options for the DataSourceEditor plugin
39 | */
40 | export interface DataSourceEditorOptions {
41 | dataSources: IDataSourceOptions[],
42 | view: DataSourceEditorViewOptions,
43 | filters: Filter[] | string,
44 | previewActive: boolean,
45 | }
46 |
47 | // Queries
48 | export type PageId = string // GrapesJs page id type
49 | export interface Query {
50 | expression: Expression
51 | }
52 |
53 | /**
54 | * Tree structure for creating query from components states
55 | */
56 | export interface Tree {
57 | token: Property
58 | children: Tree[]
59 | }
60 |
61 | // Data sources must implement this interface
62 | export type DataSourceId = string | number
63 | export interface IDataSource {
64 | // For reference in expressions
65 | id: DataSourceId
66 |
67 | // Basic properties
68 | label: string
69 | url: string
70 | type: DataSourceType
71 | method?: string
72 | headers?: Record
73 |
74 | // Hide from users settings
75 | hidden?: boolean
76 | readonly?: boolean
77 |
78 | // Initialization
79 | connect(): Promise
80 | isConnected(): boolean
81 |
82 | // Introspection
83 | getTypes(): Type[]
84 | getQueryables(): Field[]
85 | getQuery(trees: Tree[]): string
86 |
87 | // Access data
88 | fetchValues(query: string): Promise
89 |
90 | // Event handling
91 | on?(event: any, callback?: any, context?: any): any
92 | off?(event?: any, callback?: any, context?: any): any
93 | trigger?(event: any, ...args: unknown[]): any
94 | }
95 | export const FIXED_TOKEN_ID = 'fixed'
96 |
97 | export const DATA_SOURCE_READY = 'data-source:ready'
98 | export const DATA_SOURCE_ERROR = 'data-source:error'
99 | export const DATA_SOURCE_CHANGED = 'data-source:changed'
100 | export const COMPONENT_STATE_CHANGED = 'component:state:changed'
101 | export const DATA_SOURCE_DATA_LOAD_START = 'data-source:data-load:start'
102 | export const DATA_SOURCE_DATA_LOAD_END = 'data-source:data-load:end'
103 | export const DATA_SOURCE_DATA_LOAD_CANCEL= 'data-source:data-load:cancel'
104 |
105 | export const PREVIEW_RENDER_START = 'data-source:start:preview'
106 | export const PREVIEW_RENDER_END = 'data-source:start:end'
107 | export const PREVIEW_RENDER_ERROR = 'data-source:start:error'
108 |
109 | export const PREVIEW_ACTIVATED = 'data-source:preview:activated'
110 | export const PREVIEW_DEACTIVATED = 'data-source:preview:deactivated'
111 |
112 | export const COMMAND_REFRESH = 'data-source:refresh'
113 | export const COMMAND_PREVIEW_ACTIVATE = 'data-source:preview:activate'
114 | export const COMMAND_PREVIEW_DEACTIVATE = 'data-source:preview:deactivate'
115 | export const COMMAND_PREVIEW_REFRESH = 'data-source:preview:refresh'
116 | export const COMMAND_PREVIEW_TOGGLE = 'data-source:preview:toggle'
117 | export const COMMAND_ADD_DATA_SOURCE = 'data-source:add'
118 |
119 | export type DataSourceType = 'graphql'
120 |
121 | // Options of a data source
122 | export interface IDataSourceOptions {
123 | id: DataSourceId
124 | label: string
125 | type: DataSourceType
126 | readonly?: boolean
127 | }
128 |
129 | // Types
130 | export type TypeId = string
131 | export type Type = {
132 | id: TypeId
133 | label: string
134 | fields: Field[]
135 | dataSourceId?: DataSourceId // Not required for builtin types
136 | }
137 |
138 | // From https://graphql.org/graphql-js/basic-types/
139 | export const builtinTypeIds = ['String', 'Int', 'Float', 'Boolean', 'ID', 'Unknown']
140 | export const builtinTypes: Type[] = builtinTypeIds.map(id => ({
141 | id,
142 | label: id,
143 | fields: [],
144 | }))
145 |
146 | // Fileds
147 | export type FieldId = string
148 | export type FieldKind = 'scalar' | 'object' | 'list' | 'unknown'
149 | export interface FieldArgument {
150 | name: string
151 | typeId: TypeId
152 | defaultValue?: unknown
153 | }
154 | export interface Field {
155 | id: FieldId
156 | label: string
157 | typeIds: TypeId[]
158 | kind: FieldKind
159 | dataSourceId?: DataSourceId
160 | arguments?: FieldArgument[]
161 | previewIndex?: number
162 | previewGroup?: number
163 | }
164 |
165 | // **
166 | // Data tree
167 | /**
168 | * A token can be a property or a filter
169 | */
170 | export type Token = Property | Filter | State
171 |
172 | /**
173 | * Stored tokens are how the tokens are stored in the component as JSON
174 | * Use DataTree#fromStored to convert them back to tokens
175 | */
176 | export type StoredToken = StoredProperty | StoredFilter | State
177 | export type Options = Record
178 |
179 | /**
180 | * A property is used to make expressions and access data from the data source
181 | */
182 | export interface BaseProperty {
183 | type: 'property'
184 | propType: /*'type' |*/ 'field'
185 | dataSourceId?: DataSourceId
186 | }
187 |
188 | export type PropertyOptions = Record
189 | export interface StoredProperty extends BaseProperty {
190 | typeIds: TypeId[]
191 | fieldId: FieldId
192 | label: string
193 | kind: FieldKind
194 | options?: PropertyOptions
195 | previewIndex?: number
196 | previewGroup?: number
197 | }
198 | export interface Property extends StoredProperty {
199 | optionsForm?: (selected: Component, input: Field | null, options: Options, stateName: string) => TemplateResult | null
200 | }
201 |
202 | /**
203 | * A filter is used to alter data in an expression
204 | * It is provided in the options
205 | */
206 | export type FilterId = string
207 | export interface StoredFilter {
208 | type: 'filter'
209 | id: FilterId
210 | filterName?: FilterId // Override the id for the filter name if present
211 | label: string
212 | options: Options
213 | quotedOptions?: string[]
214 | optionsKeys?: string[] // Optional, used to set a specific order
215 | previewIndex?: number
216 | previewGroup?: number
217 | }
218 | export interface Filter extends StoredFilter {
219 | optionsForm?: (selected: Component, input: Field | null, options: Options, stateName: string) => TemplateResult | null
220 | validate: (input: Field | null) => boolean
221 | output: (input: Field | null, options: Options) => Field | null
222 | apply: (input: unknown, options: Options) => unknown
223 | }
224 |
225 | /**
226 | * A component state
227 | */
228 | export type StateId = string
229 | export interface State {
230 | type: 'state'
231 | storedStateId: StateId // Id of the state stored in the component
232 | previewIndex?: number
233 | previewGroup?: number
234 | label: string
235 | componentId: string
236 | exposed: boolean
237 | forceKind?: FieldKind
238 | }
239 |
240 | ///**
241 | // * A fixed value
242 | // */
243 | //export interface Fixed extends Step {
244 | // type: 'fixed'
245 | // options: {
246 | // value: string
247 | // inpuType: FixedType
248 | // }
249 | //}
250 |
251 | /**
252 | * A context is a list of available tokens for a component
253 | */
254 | export type Context = Token[]
255 |
256 |
257 | /**
258 | * An expression is a list of tokens which can be evaluated to a value
259 | * It is used to access data from the data source
260 | */
261 | export type Expression = StoredToken[]
262 |
263 | /**
264 | * Operators for condition in visibility property
265 | */
266 | export enum UnariOperator {
267 | TRUTHY = 'truthy',
268 | FALSY = 'falsy',
269 | EMPTY_ARR = 'empty array',
270 | NOT_EMPTY_ARR = 'not empty array',
271 | }
272 |
273 | /**
274 | * Operators for condition in visibility property
275 | */
276 | export enum BinaryOperator {
277 | EQUAL = '==',
278 | NOT_EQUAL = '!=',
279 | GREATER_THAN = '>',
280 | LESS_THAN = '<',
281 | GREATER_THAN_OR_EQUAL = '>=',
282 | LESS_THAN_OR_EQUAL = '<=',
283 | }
284 |
285 | /**
286 | * Properties of elements
287 | * What is not a property is an attribute or a state
288 | */
289 | export enum Properties {
290 | innerHTML = 'innerHTML',
291 | condition = 'condition',
292 | condition2 = 'condition2',
293 | __data = '__data',
294 | }
295 |
296 | export interface ComponentExpression {
297 | expression: Expression
298 | component: Component
299 | }
300 |
--------------------------------------------------------------------------------
/src/model/dataSourceManager.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { COMPONENT_STATE_CHANGED, DATA_SOURCE_CHANGED, DATA_SOURCE_ERROR, DATA_SOURCE_READY, Filter, IDataSource, DataSourceEditorOptions, DataSourceId, Type, Field } from '../types'
19 |
20 | export const STANDARD_TYPES: Type[] = [
21 | {
22 | id: 'string',
23 | label: 'String',
24 | fields: [],
25 | },
26 | {
27 | id: 'number',
28 | label: 'Number',
29 | fields: [],
30 | },
31 | {
32 | id: 'boolean',
33 | label: 'Boolean',
34 | fields: [],
35 | },
36 | {
37 | id: 'date',
38 | label: 'Date',
39 | fields: [],
40 | },
41 | {
42 | id: 'unknown',
43 | label: 'Unknown',
44 | fields: [],
45 | },
46 | ]
47 | import { Component, Editor } from 'grapesjs'
48 | import { StoredState, onStateChange } from './state'
49 | import {
50 | initializeDataSourceRegistry,
51 | getAllDataSources,
52 | addDataSource as registryAddDataSource,
53 | removeDataSource as registryRemoveDataSource,
54 | setDataSources,
55 | dataSourcesToJSON,
56 | } from './dataSourceRegistry'
57 | import { initializePreviewDataLoader, loadPreviewData } from './previewDataLoader'
58 | import { initializeFilters } from '../filters'
59 | import { validateFilters } from '../filters'
60 |
61 | /**
62 | * Data source manager state
63 | */
64 | export interface DataSourceManagerState {
65 | dataSources: IDataSource[]
66 | filters: Filter[]
67 | previewData: Record
68 | readonly editor: Editor
69 | options: DataSourceEditorOptions
70 | cachedTypes: Type[]
71 | cachedQueryables: Field[]
72 | eventListeners: {
73 | dataChangedBinded: (e?: CustomEvent) => void
74 | dataSourceReadyBinded: (ds: IDataSource) => void
75 | dataSourceErrorBinded: (message: string, ds: IDataSource) => void
76 | }
77 | }
78 |
79 | // Global manager instance
80 | let globalManager: DataSourceManagerState | null = null
81 |
82 | /**
83 | * Get all types from all connected data sources
84 | */
85 | function getAllTypes(manager: DataSourceManagerState): Type[] {
86 | return manager.dataSources
87 | .filter(ds => ds.isConnected())
88 | .flatMap(ds => ds.getTypes())
89 | .concat(STANDARD_TYPES)
90 | }
91 |
92 | /**
93 | * Get all queryable fields from all connected data sources
94 | */
95 | function getAllQueryables(manager: DataSourceManagerState): Field[] {
96 | return manager.dataSources
97 | .filter(ds => ds.isConnected())
98 | .flatMap(ds => ds.getQueryables())
99 | }
100 |
101 | /**
102 | * Update cached types and queryables
103 | */
104 | function updateCachedData(): void {
105 | const manager = getManager()
106 |
107 | manager.cachedTypes = getAllTypes(manager)
108 | manager.cachedQueryables = getAllQueryables(manager)
109 | }
110 |
111 | /**
112 | * Initialize the global data source manager
113 | */
114 | export function initializeDataSourceManager(
115 | dataSources: IDataSource[],
116 | editor: Editor,
117 | options: DataSourceEditorOptions
118 | ): void {
119 | const filters = initializeFilters(editor, options)
120 |
121 | // Validate filters
122 | validateFilters(filters)
123 |
124 | // Initialize the registry
125 | initializeDataSourceRegistry(editor)
126 |
127 | // Set initial data sources
128 | setDataSources(dataSources)
129 |
130 | // Get current data sources
131 | const currentDataSources = getAllDataSources()
132 |
133 | // Initialize preview data loader
134 | initializePreviewDataLoader(editor)
135 |
136 | // Create bound event handlers
137 | const eventListeners = {
138 | dataChangedBinded: (e?: CustomEvent) => {
139 | editor.trigger(DATA_SOURCE_CHANGED, e?.detail)
140 | },
141 | dataSourceReadyBinded: (ds: IDataSource) => {
142 | updateCachedData() // Update cache when data source becomes ready
143 | editor.trigger(DATA_SOURCE_READY, ds)
144 | loadPreviewData(true) // force refresh when data source becomes ready
145 | },
146 | dataSourceErrorBinded: (message: string, ds: IDataSource) => {
147 | editor.trigger(DATA_SOURCE_ERROR, message, ds)
148 | },
149 | }
150 |
151 | globalManager = {
152 | editor,
153 | options,
154 | previewData: {},
155 | cachedTypes: [],
156 | cachedQueryables: [],
157 | dataSources: currentDataSources,
158 | filters,
159 | eventListeners,
160 | }
161 |
162 | // Set up event listeners
163 | setupEventListeners()
164 |
165 | // Listen for data source changes and re-setup event listeners
166 | editor.on(DATA_SOURCE_CHANGED, () => {
167 | setupEventListeners()
168 | updateCachedData()
169 | refreshDataSources()
170 | })
171 |
172 | // Update cached data initially
173 | updateCachedData()
174 |
175 | // Relay state changes to the editor
176 | onStateChange((state: StoredState | null, component: Component) => {
177 | loadPreviewData().then(() => editor.trigger(COMPONENT_STATE_CHANGED, { state, component }))
178 | })
179 | }
180 |
181 | /**
182 | * Get the global manager (throws if not initialized)
183 | */
184 | export function getManager(): DataSourceManagerState {
185 | if (!globalManager) {
186 | throw new Error('DataSourceManager not initialized. Call initializeDataSourceManager first.')
187 | }
188 | return globalManager
189 | }
190 |
191 | /**
192 | * Add a data source
193 | */
194 | export async function addDataSource(dataSource: IDataSource): Promise {
195 | registryAddDataSource(dataSource)
196 | setupEventListeners()
197 | }
198 |
199 | /**
200 | * Remove a data source
201 | */
202 | export function removeDataSource(dataSource: IDataSource): void {
203 | registryRemoveDataSource(dataSource)
204 | setupEventListeners()
205 | loadPreviewData(true) // force refresh when data source is removed
206 | }
207 |
208 | /**
209 | * Refresh all data sources data
210 | */
211 | export function refreshDataSources(): void {
212 | loadPreviewData(true) // force refresh when explicitly requested
213 | }
214 |
215 | /**
216 | * Reset all data sources
217 | */
218 | export function resetDataSources(dataSources: IDataSource[]): void {
219 | setDataSources(dataSources)
220 | setupEventListeners()
221 | }
222 |
223 |
224 | /**
225 | * Get filters
226 | */
227 | export function getFilters(): Filter[] {
228 | return getManager().filters
229 | }
230 |
231 | /**
232 | * Set filters
233 | */
234 | export function setFilters(filters: Filter[]): void {
235 | getManager().filters = filters
236 | updateCachedData()
237 | }
238 |
239 | /**
240 | * Get preview data
241 | */
242 | export function getPreviewData(): Record {
243 | return getManager().previewData
244 | }
245 |
246 | /**
247 | * Set preview data
248 | */
249 | export function setPreviewData(data: Record): void {
250 | getManager().previewData = data
251 | }
252 |
253 | /**
254 | * Get all types from all data sources
255 | */
256 | export function getTypes(): Type[] {
257 | return getAllTypes(getManager())
258 | }
259 |
260 | /**
261 | * Get all queryable fields from all data sources
262 | */
263 | export function getQueryables(): Field[] {
264 | return getAllQueryables(getManager())
265 | }
266 |
267 | /**
268 | * Get cached types
269 | */
270 | export function getCachedTypes(): Type[] {
271 | return getManager().cachedTypes
272 | }
273 |
274 | /**
275 | * Get cached queryables
276 | */
277 | export function getCachedQueryables(): Field[] {
278 | return getManager().cachedQueryables
279 | }
280 |
281 | /**
282 | * Convert to JSON for storage
283 | */
284 | export function toJSON(): unknown[] {
285 | return dataSourcesToJSON()
286 | }
287 |
288 | /**
289 | * Set up event listeners on all data sources
290 | */
291 | function setupEventListeners(): void {
292 | const manager = getManager()
293 | const dataSources = getAllDataSources()
294 |
295 | // Update the manager with current data sources
296 | manager.dataSources = [...dataSources]
297 |
298 | // Remove all listeners
299 | dataSources.forEach((dataSource: IDataSource) => {
300 | if (typeof dataSource.off === 'function') {
301 | dataSource.off(DATA_SOURCE_READY, manager.eventListeners.dataSourceReadyBinded)
302 | dataSource.off(DATA_SOURCE_CHANGED, manager.eventListeners.dataChangedBinded)
303 | dataSource.off(DATA_SOURCE_ERROR, manager.eventListeners.dataSourceErrorBinded)
304 | }
305 | })
306 |
307 | // Add listeners on all data sources
308 | dataSources.forEach((dataSource: IDataSource) => {
309 | if (typeof dataSource.on === 'function') {
310 | dataSource.on(DATA_SOURCE_READY, manager.eventListeners.dataSourceReadyBinded)
311 | dataSource.on(DATA_SOURCE_CHANGED, manager.eventListeners.dataChangedBinded)
312 | dataSource.on(DATA_SOURCE_ERROR, manager.eventListeners.dataSourceErrorBinded)
313 | }
314 | })
315 | }
316 |
--------------------------------------------------------------------------------
/integration-tests/visibility-truthy/website.json:
--------------------------------------------------------------------------------
1 | {
2 | "dataSources": [],
3 | "assets": [],
4 | "styles": [
5 | {
6 | "selectors": [
7 | "#idqd"
8 | ],
9 | "style": {}
10 | },
11 | {
12 | "selectors": [
13 | "#ivs7"
14 | ],
15 | "style": {}
16 | },
17 | {
18 | "selectors": [
19 | "#idzn"
20 | ],
21 | "style": {}
22 | },
23 | {
24 | "selectors": [
25 | "#iqiqn"
26 | ],
27 | "style": {}
28 | },
29 | {
30 | "selectors": [
31 | "#icaf"
32 | ],
33 | "wrapper": 1,
34 | "style": {}
35 | }
36 | ],
37 | "pages": [
38 | {
39 | "frames": [
40 | {
41 | "component": {
42 | "type": "wrapper",
43 | "stylable": [
44 | "background",
45 | "background-color",
46 | "background-image",
47 | "background-repeat",
48 | "background-attachment",
49 | "background-position",
50 | "background-size"
51 | ],
52 | "attributes": {
53 | "id": "icaf"
54 | },
55 | "components": [
56 | {
57 | "attributes": {
58 | "id": "idqd"
59 | },
60 | "components": [
61 | {
62 | "tagName": "h1",
63 | "type": "text",
64 | "attributes": {
65 | "id": "idzn"
66 | },
67 | "components": [
68 | {
69 | "type": "textnode",
70 | "content": "South Americax"
71 | }
72 | ],
73 | "privateStates": [
74 | {
75 | "id": "innerHTML",
76 | "expression": [
77 | {
78 | "type": "state",
79 | "storedStateId": "__data",
80 | "componentId": "idqd-4124",
81 | "previewIndex": 0,
82 | "exposed": false,
83 | "forceKind": "object",
84 | "label": "Loop data (Continent)"
85 | },
86 | {
87 | "type": "property",
88 | "propType": "field",
89 | "fieldId": "name",
90 | "label": "name",
91 | "typeIds": [
92 | "String"
93 | ],
94 | "dataSourceId": "countries",
95 | "kind": "scalar",
96 | "options": {}
97 | }
98 | ]
99 | }
100 | ]
101 | },
102 | {
103 | "tagName": "p",
104 | "type": "text",
105 | "attributes": {
106 | "id": "ivs7"
107 | },
108 | "components": [
109 | {
110 | "type": "textnode",
111 | "content": "For this test I configured the plugin with a demo country API:\n https://studio.apollographql.com/public/countries/variant/current/home"
112 | }
113 | ],
114 | "privateStates": [
115 | {
116 | "id": "innerHTML",
117 | "expression": [
118 | {
119 | "type": "property",
120 | "propType": "field",
121 | "fieldId": "fixed",
122 | "label": "Fixed value",
123 | "kind": "scalar",
124 | "typeIds": [
125 | "String"
126 | ],
127 | "options": {
128 | "value": "======================="
129 | }
130 | }
131 | ]
132 | },
133 | {
134 | "id": "condition",
135 | "expression": [
136 | {
137 | "type": "state",
138 | "storedStateId": "__data",
139 | "componentId": "idqd-4124",
140 | "previewIndex": 0,
141 | "exposed": false,
142 | "forceKind": "object",
143 | "label": "Loop data (Continent)"
144 | },
145 | {
146 | "type": "property",
147 | "propType": "field",
148 | "fieldId": "name",
149 | "label": "name",
150 | "typeIds": [
151 | "String"
152 | ],
153 | "dataSourceId": "countries",
154 | "kind": "scalar",
155 | "options": {}
156 | }
157 | ]
158 | },
159 | {
160 | "id": "condition2",
161 | "expression": [
162 | {
163 | "type": "property",
164 | "propType": "field",
165 | "fieldId": "fixed",
166 | "label": "Fixed value",
167 | "kind": "scalar",
168 | "typeIds": [
169 | "String"
170 | ],
171 | "options": {
172 | "value": "Africa"
173 | }
174 | }
175 | ]
176 | }
177 | ],
178 | "conditionOperator": "=="
179 | },
180 | {
181 | "tagName": "p",
182 | "type": "text",
183 | "attributes": {
184 | "id": "iqiqn"
185 | },
186 | "components": [
187 | {
188 | "type": "textnode",
189 | "content": "This plugin lets you set \"states\" on components with the data coming from the API"
190 | }
191 | ]
192 | }
193 | ],
194 | "privateStates": [
195 | {
196 | "id": "__data",
197 | "expression": [
198 | {
199 | "options": {
200 | "filter": "{}"
201 | },
202 | "type": "property",
203 | "propType": "field",
204 | "fieldId": "continents",
205 | "label": "continents",
206 | "typeIds": [
207 | "Continent"
208 | ],
209 | "dataSourceId": "countries",
210 | "kind": "list",
211 | "previewIndex": 6
212 | }
213 | ]
214 | },
215 | {
216 | "id": "condition",
217 | "expression": [
218 | {
219 | "type": "state",
220 | "storedStateId": "__data",
221 | "componentId": "idqd-4124",
222 | "previewIndex": 6,
223 | "exposed": false,
224 | "forceKind": "object",
225 | "label": "Loop data (Continent)"
226 | },
227 | {
228 | "type": "property",
229 | "propType": "field",
230 | "fieldId": "name",
231 | "label": "name",
232 | "typeIds": [
233 | "String"
234 | ],
235 | "dataSourceId": "countries",
236 | "kind": "scalar",
237 | "options": {},
238 | "previewIndex": 6
239 | }
240 | ]
241 | }
242 | ],
243 | "id-plugin-data-source": "idqd-4124",
244 | "conditionOperator": "truthy"
245 | }
246 | ],
247 | "head": {
248 | "type": "head"
249 | },
250 | "docEl": {
251 | "tagName": "html"
252 | }
253 | },
254 | "id": "4tWZTq4IM0e4mQtN"
255 | }
256 | ],
257 | "id": "l7ddUccrV1wxCVP7"
258 | }
259 | ],
260 | "symbols": []
261 | }
262 |
--------------------------------------------------------------------------------
/src/model/ExpressionTree.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import { Page } from 'grapesjs'
19 | import { ComponentExpression, DataSourceId, Expression, IDataSource, Property, Tree } from '../types'
20 | import { getStates } from './state'
21 | import { getOptionObject } from './token'
22 | import { getComponentDebug, NOTIFICATION_GROUP, toExpression } from '../utils'
23 | import { resolveStateExpression } from './expressionEvaluator'
24 | import { DataSourceManagerState } from './dataSourceManager'
25 |
26 |
27 |
28 | // Pure functions for data operations
29 |
30 |
31 |
32 | /**
33 | * Get all expressions used in a page
34 | */
35 | export function getPageExpressions(manager: DataSourceManagerState, page: Page): ComponentExpression[] {
36 | const result: ComponentExpression[] = []
37 | const mainComponent = page.getMainComponent()
38 | if (mainComponent) {
39 | mainComponent.onAll(component => {
40 | // Get expressions used by the component from states
41 | const states = getStates(component, true).concat(getStates(component, false))
42 | states.forEach(state => {
43 | if(state.expression) {
44 | result.push({
45 | expression: state.expression,
46 | component,
47 | })
48 | }
49 | })
50 | // Get expressions used by the component from attributes
51 | Object.values(component.getAttributes()).forEach((value: string) => {
52 | const expression = toExpression(value)
53 | if(expression) {
54 | result.push({
55 | expression,
56 | component,
57 | })
58 | }
59 | })
60 | })
61 | }
62 | return result
63 | }
64 |
65 | /**
66 | * Build a tree of expressions
67 | */
68 | export function getTrees(
69 | manager: DataSourceManagerState,
70 | {expression, component}: ComponentExpression,
71 | dataSourceId: DataSourceId
72 | ): Tree[] {
73 | if(expression.length === 0) return []
74 | const next = expression[0]
75 | switch(next.type) {
76 | case 'property': {
77 | if(next.dataSourceId !== dataSourceId) return []
78 | const trees = getTrees(manager, {expression: expression.slice(1), component}, dataSourceId)
79 | if(trees.length === 0) return [{
80 | token: next,
81 | children: [],
82 | }]
83 | return trees
84 | .flatMap(tree => {
85 | // Check if this is a "relative" property or "absolute" (a root type)
86 | if(isRelative(manager, next, tree.token, dataSourceId)) {
87 | return {
88 | token: next,
89 | children: [tree],
90 | }
91 | } else {
92 | return [{
93 | token: next,
94 | children: [],
95 | }, tree]
96 | }
97 | })
98 | }
99 | case 'filter': {
100 | const options = Object.values(next.options)
101 | .map((value: unknown) => toExpression(value))
102 | .filter((exp: Expression | null) => !!exp && exp.length > 0)
103 | .flatMap(exp => getTrees(manager, {expression: exp!, component}, dataSourceId))
104 |
105 | const trees = getTrees(manager, {expression: expression.slice(1), component}, dataSourceId)
106 | if(trees.length === 0) return options
107 | return trees.flatMap(tree => [tree, ...options])
108 | }
109 | case 'state': {
110 | const resolved = resolveStateExpression(next, component, manager)
111 | if(!resolved) {
112 | manager.editor.runCommand('notifications:add', {
113 | type: 'error',
114 | group: NOTIFICATION_GROUP,
115 | message: `Unable to resolve state ${JSON.stringify(next)} `,
116 | componentId: component.getId(),
117 | })
118 | throw new Error(`Unable to resolve state ${JSON.stringify(next)}. State defined on component ${getComponentDebug(component)}`)
119 | }
120 | return getTrees(manager, {expression: resolved, component}, dataSourceId)
121 | }
122 | default:
123 | manager.editor.runCommand('notifications:add', {
124 | type: 'error',
125 | group: NOTIFICATION_GROUP,
126 | message: `Invalid expression ${JSON.stringify(expression)} `,
127 | componentId: component.getId(),
128 | })
129 | throw new Error(`Invalid expression ${JSON.stringify(expression)}. Expression used on component ${getComponentDebug(component)}`)
130 | }
131 | }
132 |
133 | /**
134 | * Check if a property is relative to a type
135 | */
136 | export function isRelative(
137 | manager: DataSourceManagerState,
138 | parent: Property,
139 | child: Property,
140 | dataSourceId: DataSourceId
141 | ): boolean {
142 | const ds = manager.dataSources.find((dataSource: IDataSource) => dataSource.id === dataSourceId)
143 | if(!ds) throw new Error(`Data source not found ${dataSourceId}`)
144 | if(!ds.isConnected()) throw new Error(`Data source ${dataSourceId} is not ready (not connected)`)
145 | const parentTypes = ds.getTypes().filter(t => parent.typeIds.includes(t.id))
146 | const parentFieldsTypes = parentTypes.flatMap(t => t.fields.map(f => f.typeIds).flat())
147 | return parentFieldsTypes.length > 0 && child.typeIds.some(typeId => parentFieldsTypes.includes(typeId))
148 | }
149 |
150 | /**
151 | * From expressions to a tree
152 | */
153 | export function toTrees(
154 | manager: DataSourceManagerState,
155 | expressions: ComponentExpression[],
156 | dataSourceId: DataSourceId
157 | ): Tree[] {
158 | if(expressions.length === 0) return []
159 | return expressions
160 | // From Expression to Tree
161 | .flatMap(expression => getTrees(manager, expression, dataSourceId))
162 | // Group by root token
163 | .reduce((acc: Tree[][], tree: Tree) => {
164 | const existing = acc.find(t => t[0].token.fieldId === tree.token.fieldId && (!tree.token.dataSourceId || t[0].token.dataSourceId === tree.token.dataSourceId))
165 | if(existing) {
166 | existing.push(tree)
167 | } else {
168 | acc.push([tree])
169 | }
170 | return acc
171 | }, [] as Tree[][])
172 | // Merge all trees from the root
173 | .map((grouped: Tree[]) => {
174 | try {
175 | const merged = grouped.reduce((acc, tree) => mergeTrees(acc, tree))
176 | return merged
177 | } catch(e) {
178 | manager.editor.runCommand('notifications:add', {
179 | type: 'error',
180 | group: NOTIFICATION_GROUP,
181 | message: `Unable to merge trees ${JSON.stringify(grouped)} `,
182 | componentId: expressions[0].component.getId(),
183 | })
184 | throw e
185 | }
186 | })
187 | }
188 |
189 | /**
190 | * Recursively merge two trees
191 | */
192 | export function mergeTrees(tree1: Tree, tree2: Tree): Tree {
193 | // Check if the trees have the same fieldId
194 | if (tree1.token.dataSourceId !== tree2.token.dataSourceId
195 | // Don't check for kind because it can be different for the same fieldId
196 | // For example `blog` collection (kind: list) for a loop/repeat
197 | // and `blog` item (kind: object) from inside the loop
198 | // FIXME: why is this?
199 | // || tree1.token.kind !== tree2.token.kind
200 | ) {
201 | console.error('Unable to merge trees', tree1, tree2)
202 | throw new Error(`Unable to build GraphQL query: unable to merge trees ${JSON.stringify(tree1)} and ${JSON.stringify(tree2)}`)
203 | }
204 |
205 | // Check if there are children with the same fieldId but different options
206 | // FIXME: we should use graphql aliases: https://graphql.org/learn/queries/#aliases but then it changes the variable name in the result
207 | const errors = tree1.children
208 | .filter(child1 => tree2.children.find(child2 =>
209 | child1.token.fieldId === child2.token.fieldId
210 | && getOptionObject(child1.token.options, child2.token.options).error
211 | ))
212 | .map(child1 => {
213 | const child2 = tree2.children.find(child2 => child1.token.fieldId === child2.token.fieldId)
214 | return `${child1.token.fieldId} appears twice with different options: ${JSON.stringify(child1.token.options)} vs ${JSON.stringify(child2?.token.options)}`
215 | })
216 |
217 | if(errors.length > 0) {
218 | console.error('Unable to merge trees', errors)
219 | throw new Error(`Unable to build GraphQL query: unable to merge trees: \n* ${errors.join('\n* ')}`)
220 | }
221 |
222 | const different = tree1.children
223 | .filter(child1 => !tree2.children.find(child2 =>
224 | child1.token.fieldId === child2.token.fieldId
225 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',')
226 | && !getOptionObject(child1.token.options, child2.token.options).error
227 | ))
228 | .concat(tree2.children
229 | .filter(child2 => !tree1.children.find(child1 =>
230 | child1.token.fieldId === child2.token.fieldId
231 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',')
232 | && !getOptionObject(child1.token.options, child2.token.options).error
233 | ))
234 | )
235 | const same = tree1.children
236 | .filter(child1 => tree2.children.find(child2 =>
237 | child1.token.fieldId === child2.token.fieldId
238 | && child1.token.typeIds.join(',') === child2.token.typeIds.join(',')
239 | && !getOptionObject(child1.token.options, child2.token.options).error
240 | ))
241 |
242 | return {
243 | token: tree1.token,
244 | children: different
245 | .concat(same
246 | .map(child1 => {
247 | const child2 = tree2.children.find(child2 => child1.token.fieldId === child2.token.fieldId)
248 | return mergeTrees(child1, child2!)
249 | })),
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/src/view/defaultStyles.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | export const OPTIONS_STYLES = `
19 | form {
20 | display: flex;
21 | flex-direction: column;
22 | align-items: stretch;
23 | padding: 5px;
24 | color: var(--ds-tertiary);
25 | min-width: 300px;
26 | }
27 | form label {
28 | text-align: left;
29 | margin-top: 5px;
30 | }
31 | form .buttons {
32 | display: flex;
33 | justify-content: flex-end;
34 | margin: 5px 0;
35 | width: 100%;
36 | }
37 | form input {
38 | padding: 4px;
39 | background-color: transparent;
40 | border-radius: 2px;
41 | color: var(--ds-tertiary);
42 | border: 1px solid var(--ds-tertiary);
43 | }
44 | form .buttons input {
45 | margin-left: 5px;
46 | cursor: pointer;
47 | padding: 4px 10px;
48 | background-color: var(--ds-button-bg);
49 | }
50 | form .buttons input[type="reset"] {
51 | border-color: transparent;
52 | }
53 | form .buttons input:hover {
54 | color: var(--ds-primary);
55 | }
56 | form input.ds-expression-input__fixed {
57 | color: black;
58 | }
59 | `
60 | export const PROPERTY_STYLES = `
61 | :root {
62 | --ds-primary: #8873FE;
63 | --ds-secondary: #E5E5E5;
64 | --ds-tertiary: #1D1D1D;
65 | --ds-highlight: #8873FE;
66 | --ds-lowlight: #252525;
67 | --ds-button-color: #E5E5E5;
68 | --ds-button-bg: #252525;
69 | --ds-button-border: var(--ds-button-bg);
70 |
71 | --expression-input-dirty-background-color: var(--ds-button-bg);
72 | --expression-input-dirty-border-color: var(--ds-tertiary);
73 | --expression-input-dirty-color: var(--ds-highlight);
74 | --expression-input-active-color: var(--ds-tertiary);
75 | --expression-input-active-background-color: var(--ds-secondary);
76 | --popin-dialog-background: var(--ds-secondary);
77 | --popin-dialog-color: var(--ds-tertiary);
78 | --popin-dialog-header-background: transparent;
79 | --popin-dialog-body-background: transparent;
80 | --popin-dialog-footer-background: transparent;
81 | --expression-input-placeholder-margin: 0 10px;
82 | --expression-input-item-button-margin: 0;
83 | --expression-input-item-button-padding: 2px;
84 | --expression-input-item-button-border-radius: 50%;
85 | --expression-input-item-button-width: 20px;
86 | --expression-input-item-button-height: 20px;
87 | --expression-input-item-button-background-color: transparent;
88 | --expression-input-item-button-color: var(--ds-button-color);
89 | --expression-input-separator-color: var(--ds-button-color);
90 | --expression-input-separator-font-size: 0.7em;
91 | --expression-input-separator-margin: 0;
92 | --expression-input-separator-padding: 0 3px 0 1px;
93 | --expression-input-item-arrow-padding: 5px 5px 0 5px;
94 | --expression-input-values-li-icon-margin-right: 0;
95 | /*
96 | --popin-dialog-header-color: #333;
97 | --popin-dialog-body-color: #666;
98 | --popin-dialog-footer-color: #333;
99 | --popin-dialog-header-border-bottom: none;
100 | --popin-dialog-footer-border-top: none;
101 | --popin-dialog-header-padding: 0;
102 | --popin-dialog-body-padding: 5px;
103 | --popin-dialog-footer-padding: 0;
104 | */
105 | }
106 | .ds-state-editor__options {
107 | --ds-secondary: #1A1A1A;
108 | --ds-tertiary: #F5F5F5;
109 | --ds-lowlight: #E0E0E0;
110 | --ds-button-color: #1A1A1A;
111 | --ds-button-bg: #FFFFFF;
112 | --expression-input-dirty-background-color: var(--ds-button-bg);
113 | --expression-input-dirty-border-color: var(--ds-tertiary);
114 | --expression-input-dirty-color: var(--ds-highlight);
115 | --expression-input-active-color: var(--ds-tertiary);
116 | --expression-input-active-background-color: var(--ds-secondary);
117 | }
118 | .gjs-traits-label {
119 | font-family: "Ubuntu", sans-serif;
120 | font-size: 0.85rem;
121 | padding: 9px 10px 9px 20px;
122 | text-align: left;
123 | display: flex;
124 | justify-content: space-between;
125 | align-items: center;
126 | min-height: 40px;
127 | }
128 | expression-input {
129 | padding: 10px;
130 | display: block;
131 | }
132 | expression-input::part(separator__delete) {
133 | border-right: 1px solid var(--ds-button-border);
134 | height: 20px;
135 | }
136 | expression-input::part(add-button) {
137 | background-color: var(--ds-tertiary);
138 | border-radius: 2px;
139 | padding: 3px;
140 | margin: 0;
141 | border: 1px solid var(--ds-tertiary);
142 | width: 24px;
143 | height: 24px;
144 | box-sizing: border-box;
145 | cursor: pointer;
146 | }
147 | expression-input::part(delete-button) {
148 | margin: 0;
149 | padding: 0;
150 | display: flex;
151 | justify-content: center;
152 | color: var(--ds-button);
153 | }
154 | expression-input::part(header) {
155 | border: none;
156 | }
157 | expression-input::part(type) {
158 | padding-bottom: 0;
159 | padding-top: 4px;
160 | display: none;
161 | }
162 | expression-input::part(name) {
163 | font-weight: normal;
164 | padding-bottom: 0;
165 | padding-top: 0;
166 | padding-left: 5px;
167 | }
168 | expression-input::part(property-input) {
169 | padding: 4px;
170 | border: medium;
171 | flex: 1 1 auto;
172 | background-color: transparent;
173 | color: var(--ds-secondary);
174 | }
175 | expression-input::part(property-container) {
176 | background-color: var(--ds-tertiary);
177 | border-radius: 2px;
178 | box-sizing: border-box;
179 | padding: 5px;
180 | margin: 5px 0;
181 | }
182 | expression-input::part(scroll-container) {
183 | overflow: auto;
184 | box-sizing: border-box;
185 |
186 | /* inner shadow to make it visible when content is overflowing */
187 | box-shadow: inset 0 0 5px 0 rgba(0,0,0,.3);
188 |
189 | }
190 | expression-input::part(steps-container) {
191 | display: flex;
192 | align-items: center;
193 | background-color: var(--ds-button-bg);
194 | border-radius: 2px;
195 | padding: 5px;
196 | margin: 5px 0;
197 | width: max-content;
198 | min-width: 100%;
199 | box-sizing: border-box;
200 | }
201 | expression-input::part(dirty-icon) {
202 | cursor: pointer;
203 | margin: 0 10px;
204 | color: var(--ds-highlight);
205 | }
206 | expression-input::part(dirty-icon) {
207 | color: var(--ds-highlight);
208 | vertical-align: bottom;
209 | display: inline-flex;
210 | margin: 0;
211 | margin-left: 20px;
212 | }
213 | expression-input::part(expression-input-item) {
214 | border: 1px solid var(--ds-tertiary);
215 | background-color: var(--ds-tertiary);
216 | border-radius: 2px;
217 | margin-right: 5px;
218 | }
219 | .ds-section {
220 | &:last-child {
221 | margin-bottom: 100px;
222 | }
223 | details {
224 | margin: 2px;
225 | padding: 2px;
226 | background-color: transparent;
227 | border-radius: 2px;
228 | color: var(--ds-secondary);
229 | text-align: left;
230 | }
231 | details[open] {
232 | background-color: var(--ds-tertiary);
233 | }
234 | details summary {
235 | color: var(--ds-secondary);
236 | cursor: pointer;
237 | padding: 10px 0;
238 | }
239 | details a {
240 | color: var(--ds-link-color);
241 | }
242 | details .ds-states__help-link {
243 | display: block;
244 | }
245 | details .ds-states__help--tooltip {
246 | position: absolute;
247 | left: 50%;
248 | background: var(--ds-secondary);
249 | color: var(--ds-tertiary);
250 | padding: 10px;
251 | }
252 | .gjs-traits-label {
253 | background-color: var(--ds-tertiary);
254 | span {
255 | display: flex;
256 | align-items: center;
257 | }
258 | }
259 | main {
260 | display: flex;
261 | flex-direction: column;
262 | }
263 | .ds-slot-fixed {
264 | width: 100%;
265 | }
266 | select {
267 | width: 150px;
268 | flex: 0;
269 | margin: 5px;
270 | padding: 5px;
271 | background-color: var(--ds-button-bg);
272 | border-radius: 2px;
273 | color: var(--ds-secondary);
274 | border: 1px solid var(--ds-tertiary);
275 | cursor: pointer;
276 | font-size: medium;
277 | }
278 | input.ds-expression-input__fixed {
279 | color: var(--ds-secondary);
280 | padding: 10px;
281 | border: none;
282 | background-color: transparent;
283 | width: 100%;
284 | box-sizing: border-box;
285 | }
286 | .ds-expression-input__add {
287 | max-width: 40px;
288 | text-align: center;
289 | font-size: large;
290 | padding-right: 9px;
291 | -webkit-appearance: none;
292 | -moz-appearance: none;
293 | text-indent: 1px;
294 | text-overflow: '';
295 | }
296 | .ds-expression-input__add option {
297 | font-size: medium;
298 | }
299 | .ds-expression-input__options-button {
300 | background-color: transparent;
301 | border: none;
302 | color: var(--ds-secondary);
303 | cursor: pointer;
304 | padding: 0;
305 | margin: 10px;
306 | margin-left: 0;
307 | }
308 | label.ds-label {
309 | display: flex;
310 | align-items: center;
311 | padding: 10px;
312 | color: var(--ds-secondary);
313 | }
314 | label.ds-label--disabled {
315 | justify-content: space-between;
316 | }
317 | label.ds-label--disabled .ds-label__message {
318 | opacity: .5;
319 | }
320 | select.ds-visibility__condition-operator {
321 | margin: 10px;
322 | }
323 | }
324 | /* States CSS Styles */
325 | .ds-states {
326 | display: flex;
327 | flex-direction: column;
328 | }
329 | .ds-states__buttons {
330 | display: flex;
331 | flex-direction: row;
332 | justify-content: flex-end;
333 | margin: 0 5px;
334 | }
335 | .ds-states__button {
336 | cursor: pointer;
337 | border: 1px solid var(--ds-button-border);
338 | border-radius: 2px;
339 | padding: 5px;
340 | background: var(--ds-button-bg);
341 | color: var(--ds-button-color);
342 | margin: 5px;
343 | padding: 7px 14px;
344 | }
345 | .ds-states__button--disabled {
346 | opacity: 0.5;
347 | cursor: default;
348 | }
349 | .ds-states__remove-button {
350 | margin-left: 1em;
351 | }
352 | .ds-states__sep {
353 | width: 100%;
354 | border: none;
355 | height: 1px;
356 | background: var(--ds-button-bg);
357 | }
358 | /* real data */
359 | .ds-real-data {
360 | code {
361 | overflow: hidden;
362 | text-wrap: nowrap;
363 | display: block;
364 | padding: 0 10px;
365 | text-overflow: ellipsis;
366 | margin-top: -5px;
367 | margin-bottom: 10px;
368 | text-align: right;
369 | }
370 | }
371 | `
372 |
--------------------------------------------------------------------------------
/src/model/token.ts:
--------------------------------------------------------------------------------
1 | import { Component } from 'grapesjs'
2 | import { Expression, Field, FieldArgument, Filter, Options, Property, PropertyOptions, StoredToken, Token, Type } from '../types'
3 | import { getFilters, getManager } from './dataSourceManager'
4 | import { NOTIFICATION_GROUP } from '../utils'
5 | import { getParentByPersistentId, getState } from './state'
6 | import { TemplateResult, html } from 'lit'
7 |
8 | /**
9 | * Add missing methonds to the filter
10 | * When filters are stored they lose their methods
11 | * @throws Error if the filter is not found
12 | */
13 | export function getFilterFromToken(token: Filter, filters: Filter[]): Filter {
14 | if(token.type !== 'filter') throw new Error('Token is not a filter')
15 | const filter = filters.find(filter => filter.id === token.id)
16 | if (!filter) {
17 | console.error('Filter not found', token)
18 | throw new Error(`Filter ${token.id} not found`)
19 | }
20 | return {
21 | ...token,
22 | ...filter,
23 | // Keep the options as they are stored
24 | options: token.options,
25 | }
26 | }
27 |
28 | /**
29 | * Get the token from its stored form
30 | * @throws Error if the token type is not found
31 | */
32 | export function fromStored(token: StoredToken, componentId: string | null): T {
33 | // Handle invalid tokens - return null or throw a descriptive error
34 | if (!token || typeof token !== 'object') {
35 | console.error('Invalid token: not an object', token)
36 | throw new Error('Invalid token: expected an object')
37 | }
38 |
39 | if (!token.type) {
40 | console.error('Invalid token: missing type property', token)
41 | throw new Error('Invalid token: missing type property')
42 | }
43 |
44 | switch (token.type) {
45 | case 'filter': {
46 | if ((token as Filter).optionsForm) return token as T
47 | const original = getFilters().find(filter => filter.id === token.id) as T | undefined
48 | if (!original) {
49 | console.error('Filter not found', token)
50 | throw new Error(`Filter ${token.id} not found`)
51 | }
52 | return {
53 | ...original,
54 | ...token,
55 | type: 'filter',
56 | } as T
57 | }
58 | case 'property': {
59 | if ((token as Property).optionsForm) return token as T
60 | const field = propertyToField(token, componentId)
61 | if (!field) {
62 | console.error('Field not found for token', token)
63 | throw new Error(`Field ${token.fieldId} not found`)
64 | }
65 | return {
66 | ...getTokenOptions(field) ?? {},
67 | ...token,
68 | type: 'property',
69 | propType: 'field',
70 | } as T
71 | }
72 | case 'state':
73 | return token as T
74 | default:
75 | console.error('Unknown token type (reading type)', token)
76 | throw new Error('Unknown token type')
77 | }
78 | }
79 |
80 | /**
81 | * Get the type corresponding to a token
82 | */
83 | export function tokenToField(token: Token, prev: Field | null, component: Component): Field | null {
84 | switch (token.type) {
85 | case 'filter': {
86 | try {
87 | const filter = getFilterFromToken(token, getFilters())
88 | try {
89 | if (filter.validate(prev)) {
90 | return filter.output(prev, filter.options ?? {})
91 | }
92 | } catch (e) {
93 | console.warn('Filter validate error:', e, {token, prev})
94 | return null
95 | }
96 | return null
97 | } catch {
98 | // FIXME: notify user
99 | console.error('Error while getting filter result type', {token, prev, component})
100 | return null
101 | }
102 | }
103 | case 'property':
104 | try {
105 | return propertyToField(token, component.getId())
106 | } catch {
107 | // FIXME: notify user
108 | console.error('Error while getting property result type', {token, component})
109 | return null
110 | }
111 | case 'state': {
112 | const parent = getParentByPersistentId(token.componentId, component)
113 | if (!parent) {
114 | console.warn('Component not found for state', token)
115 | const manager = getManager()
116 | manager.editor.runCommand('notifications:add', {
117 | type: 'error',
118 | group: NOTIFICATION_GROUP,
119 | message: `Component not found for state: ${token.storedStateId}`,
120 | componentId: component.getId(),
121 | })
122 | return null
123 | }
124 | const expression = getState(parent, token.storedStateId, token.exposed)?.expression
125 | if (!expression) {
126 | console.warn('State is not defined on component', { component: parent, token })
127 | const manager = getManager()
128 | manager.editor.runCommand('notifications:add', {
129 | type: 'error',
130 | group: NOTIFICATION_GROUP,
131 | message: `State '${token.storedStateId}' is not defined on component '${parent.getName() || parent.get('id')}'`,
132 | componentId: parent.getId(),
133 | })
134 | return null
135 | }
136 | try {
137 | const field = getExpressionResultType(expression, parent)
138 | return field ? {
139 | ...field,
140 | kind: token.forceKind ?? field.kind,
141 | } : null
142 | } catch {
143 | // FIXME: notify user
144 | console.error('Error while getting expression result type in tokenToField', {expression, parent, component, token, prev})
145 | return null
146 | }
147 | }
148 | default:
149 | console.error('Unknown token type (reading type)', token)
150 | throw new Error('Unknown token type')
151 | }
152 | }
153 |
154 | /**
155 | * Get the type corresponding to a property
156 | * @throws Error if the type is not found
157 | */
158 | export function propertyToField(property: Property, componentId: string | null): Field {
159 | const manager = getManager()
160 | const allTypes = manager.cachedTypes.length > 0 ? manager.cachedTypes : []
161 |
162 | const typeNames: string[] = []
163 |
164 | // Check each typeId and handle missing types
165 | for (const typeId of property.typeIds) {
166 | const type: Type | undefined = allTypes.find(type => type.id === typeId && (!property.dataSourceId || type.dataSourceId === property.dataSourceId))
167 | if (!type) {
168 | // Show notification for missing type, similar to main branch DataTree.getType
169 | manager.editor.runCommand('notifications:add', {
170 | type: 'error',
171 | group: NOTIFICATION_GROUP,
172 | message: `Type not found ${property.dataSourceId ?? ''}.${typeId}`,
173 | componentId,
174 | })
175 | console.error(`Type not found ${property.dataSourceId ?? ''}.${typeId}`)
176 | // Continue processing other types instead of throwing
177 | } else {
178 | typeNames.push(type.label)
179 | }
180 | }
181 |
182 | const args = property.options ? Object.entries(property.options).map(([name, value]) => ({
183 | typeId: 'JSON', // FIXME: Why is this hardcoded?
184 | name,
185 | defaultValue: value, // FIXME: Why is this value, it should keep the initial default
186 | })) : undefined
187 |
188 | return {
189 | id: property.fieldId,
190 | label: typeNames.length > 0 ? typeNames.join(', ') : property.label,
191 | typeIds: property.typeIds,
192 | kind: property.kind,
193 | dataSourceId: property.dataSourceId,
194 | arguments: args,
195 | previewIndex: property.previewIndex,
196 | }
197 | }
198 |
199 | /**
200 | * Evaluate the types of each token in an expression
201 | */
202 | export function expressionToFields(expression: Expression, component: Component): Field[] {
203 | // Resolve type of the expression 1 step at a time
204 | let prev: Field | null = null
205 | const unknownField: Field = {
206 | id: 'unknown',
207 | label: 'unknown',
208 | typeIds: [],
209 | kind: 'scalar',
210 | }
211 | return expression.map((token) => {
212 | try {
213 | const field = tokenToField(fromStored(token, component.getId()), prev, component)
214 | if (!field) {
215 | console.warn('Type not found for token in expressionToFields', { token, expression })
216 | return unknownField
217 | }
218 | prev = field
219 | return field
220 | } catch {
221 | // FIXME: notify user
222 | console.error('Error while getting expression result type in expressionToFields', {expression, component, token, prev})
223 | return unknownField
224 | }
225 | })
226 | }
227 |
228 | /**
229 | * Evaluate an expression to a type
230 | * This is used to validate expressions and for autocompletion
231 | * @throws Error if the token type is not found
232 | */
233 | export function getExpressionResultType(expression: Expression, component: Component): Field | null {
234 | // Resolve type of the expression 1 step at a time
235 | return expression.reduce((prev: Field | null, token: StoredToken) => {
236 | return tokenToField(fromStored(token, component.getId()), prev, component)
237 | }, null)
238 | }
239 |
240 | /**
241 | * Get the options of a token
242 | */
243 | export function getTokenOptions(field: Field): { optionsForm: (selected: Component, input: Field | null, options: Options) => TemplateResult, options: Options } | null {
244 | if (field.arguments && field.arguments.length > 0) {
245 | return {
246 | optionsForm: optionsToOptionsForm(field.arguments.map((arg) => ({ name: arg.name, value: arg.defaultValue }))),
247 | options: field.arguments.reduce((options: Record, arg: FieldArgument) => {
248 | options[arg.name] = arg.defaultValue
249 | return options
250 | }, {}),
251 | }
252 | }
253 | return null
254 | }
255 |
256 | /**
257 | * Get the options of a token or a field
258 | */
259 | export function optionsToOptionsForm(arr: { name: string, value: unknown }[]): (selected: Component, input: Field | null, options: Options) => TemplateResult {
260 | return (selected: Component, input: Field | null, options: Options) => {
261 | return html`
262 | ${arr.map((obj) => {
263 | const value = options[obj.name] ?? obj.value ?? ''
264 | return html`${obj.name} `
265 | })
266 | }
267 | `
268 | }
269 | }
270 |
271 | /**
272 | * Utility function to shallow compare two objects
273 | * Used to compare options of tree items
274 | */
275 | export function getOptionObject(option1: PropertyOptions | undefined, option2: PropertyOptions | undefined): { error: boolean, result: PropertyOptions | undefined } {
276 | // Handle the case where one or both are undefined or empty
277 | if(!option1 && !option2) return { error: false, result: undefined }
278 | if(isEmpty(option1) && isEmpty(option2)) return { error: false, result: undefined }
279 | // Handle the case where one is undefined or empty and the other is not
280 | if(!option1 || !option2) return { error: true, result: undefined }
281 | if(isEmpty(option1) || isEmpty(option2)) return { error: true, result: undefined }
282 |
283 | const keys1 = Object.keys(option1)
284 | const keys2 = Object.keys(option2)
285 |
286 | if (keys1.length !== keys2.length) {
287 | return { error: true, result: undefined }
288 | }
289 |
290 | for (const key of keys1) {
291 | if (option1[key] !== option2[key]) {
292 | return { error: true, result: undefined }
293 | }
294 | }
295 |
296 | return { error: false, result: option1 }
297 | }
298 |
299 | function isJson(str: string) {
300 | if(typeof str !== 'string') return false
301 | if(str.length === 0) return false
302 | try {
303 | JSON.parse(str)
304 | } catch {
305 | return false
306 | }
307 | return true
308 | }
309 |
310 | function isEmpty(value: unknown): boolean {
311 | if(value === null || typeof value === 'undefined') return true
312 | const isString = typeof value === 'string'
313 | const isJsonString = isString && isJson(value)
314 | if (isString && !isJsonString) return value === ''
315 | const json = isJsonString ? JSON.parse(value) : value
316 | if (Array.isArray(json)) return json.length === 0
317 | if (typeof json === 'object') return Object.values(json).filter(v => !!v).length === 0
318 | return false
319 | }
320 |
321 | export function buildArgs(options: PropertyOptions | undefined): string {
322 | const args = options ? `(${Object
323 | .keys(options)
324 | .map(key => ({ key, value: options![key] }))
325 | .filter(({ value }) => !isEmpty(value))
326 | .map(({ key, value }) => {
327 | //return typeof value === 'string' && !isJson(value) ? `${key}: "${value}"` : `${key}: ${value}`
328 | return `${key}: ${value}`
329 | })
330 | .join(', ')
331 | })` : ''
332 | // Valid args for GraphQL canot be just ()
333 | const validArgs = args === '()' ? '' : args
334 | return validArgs
335 | }
336 |
--------------------------------------------------------------------------------
/src/view/custom-states-editor.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Silex website builder, free/libre no-code tool for makers.
3 | * Copyright (c) 2023 lexoyo and Silex Labs foundation
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU Affero General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | import {LitElement, html} from 'lit'
19 | import { ref } from 'lit/directives/ref.js'
20 | import {property} from 'lit/decorators.js'
21 | import { StoredState, getState, getStateIds, removeState, setState } from '../model/state'
22 | import { Token } from '../types'
23 |
24 | import './state-editor'
25 | import { StateEditor } from './state-editor'
26 | import { Component, Editor } from 'grapesjs'
27 | import { PROPERTY_STYLES } from './defaultStyles'
28 | import { fromStored } from '../model/token'
29 | import { cleanStateName } from '../utils'
30 |
31 | interface Item {
32 | name: string
33 | publicState?: boolean
34 | state: StoredState
35 | }
36 |
37 | /**
38 | * Editor for selected element's states
39 | *
40 | */
41 | export class CustomStatesEditor extends LitElement {
42 | @property({type: Boolean})
43 | disabled = false
44 |
45 | @property({type: Boolean, attribute: 'private-state'})
46 | privateState = false
47 |
48 | @property({type: String})
49 | title = 'Custom states'
50 |
51 | @property({type: Boolean, attribute: 'default-fixed'})
52 | defaultFixed = false
53 |
54 | @property({type: String, attribute: 'create-prompt'})
55 | createPrompt = 'Name this state'
56 |
57 | @property({type: String, attribute: 'rename-prompt'})
58 | renamePrompt = 'Rename this state'
59 |
60 | @property({type: String, attribute: 'default-name'})
61 | defaultName = 'New state'
62 |
63 | // This is a comma separated list of reserved names
64 | // Or an array of reserved names
65 | @property({type: String, attribute: 'reserved-names'})
66 | get reservedNames() { return this._reservedNames }
67 | set reservedNames(value: string | string[]) {
68 | if(typeof value === 'string') this._reservedNames = value.split(',').map(s => s.trim())
69 | else this._reservedNames = value
70 | }
71 |
72 | @property({type: Boolean, attribute: 'hide-loop-data'})
73 | hideLoopData = false
74 |
75 | @property({type: String, attribute: 'help-text'})
76 | helpText = ''
77 |
78 | @property({type: String, attribute: 'help-link'})
79 | helpLink = ''
80 |
81 | private _reservedNames: string[] = []
82 | private editor: Editor | null = null
83 | private redrawing = false
84 |
85 | setEditor(editor: Editor) {
86 | if (this.editor) {
87 | console.warn('property-editor setEditor already set')
88 | return
89 | }
90 | this.editor = editor
91 |
92 | // Update the UI when a page is added/renamed/removed
93 | this.editor.on('page', () => this.requestUpdate())
94 |
95 | // Update the UI on component selection change
96 | this.editor.on('component:selected', () => this.requestUpdate())
97 |
98 | // Update the UI on component change
99 | this.editor.on('component:update', () => this.requestUpdate())
100 | }
101 |
102 | getHead(selected: Component | null) {
103 | return html`
104 |
107 |
108 |
109 |
110 |
111 |
${this.title}
112 |
113 | ${ selected ? html`
114 | {
118 | const item = this.createCustomState(selected)
119 | if(!item) return
120 | this.setState(selected, item.name, item.state)
121 | }}
122 | >+
123 | ` : ''}
124 | ${this.helpText ? html`
125 |
126 | Help
127 |
137 |
138 | ` : ''}
139 |
140 |
141 |
142 |
143 | `
144 | }
145 |
146 | override render() {
147 | super.render()
148 | this.redrawing = true
149 | const selected = this.editor?.getSelected()
150 | const empty = html`
151 | ${this.getHead(null)}
152 | Select an element to edit its states
153 | `
154 | if(!this.editor || this.disabled) {
155 | this.redrawing = false
156 | return html``
157 | }
158 | if(!selected) {
159 | this.redrawing = false
160 | return empty
161 | }
162 | const items: Item[] = this.getStateIds(selected)
163 | .map(stateId => ({
164 | name: stateId,
165 | publicState: !this.privateState,
166 | state: this.getState(selected, stateId)!,
167 | }))
168 | .filter(item => item.state && !item.state.hidden)
169 | const result = html`
170 | ${this.getHead(selected)}
171 |
172 |
173 | ${ items.length === 0 ? html`
174 |
Use the "+" button to add elements to this list
175 | ` : ''}
176 | ${items
177 | .map((item, index) => html`
178 |
179 | ${this.getStateEditor(selected, item.state.label || '', item.name)}
180 |
181 | {
185 | this.removeState(selected, item.name)
186 | this.requestUpdate()
187 | }}
188 | >x
189 | {
193 | const newItem = this.renameCustomState(item)
194 | if(!newItem || newItem === item) return
195 | this.removeState(selected, item.name)
196 | this.setState(selected, newItem.name, newItem.state)
197 | this.requestUpdate()
198 | }}
199 | >\u270F
200 | {
204 | items.splice(index - 1, 0, items.splice(index, 1)[0])
205 | this.updateOrderCustomStates(selected, items)
206 | }}
207 | >\u2191
208 | {
212 | items.splice(index + 1, 0, items.splice(index, 1)[0])
213 | this.updateOrderCustomStates(selected, items)
214 | }}
215 | >\u2193
216 |
217 |
218 |
219 | `)}
220 |
221 |
222 | `
223 | this.redrawing = false
224 | return result
225 | }
226 |
227 | /**
228 | * Get the states for this type of editor
229 | */
230 | getStateIds(component: Component): string[] {
231 | return getStateIds(component, !this.privateState)
232 | // Filter out the states which are properties
233 | .filter(stateId => !this.reservedNames.includes(stateId))
234 | }
235 |
236 | /**
237 | * Get the states for this type of editor
238 | */
239 | getState(component: Component, name: string): StoredState | null {
240 | return getState(component, name, !this.privateState)
241 | }
242 |
243 | /**
244 | * Set the states for this type of editor
245 | */
246 | setState(component: Component, name: string, state: StoredState) {
247 | setState(component, name, state, !this.privateState)
248 | }
249 |
250 | /**
251 | * Remove the states for this type of editor
252 | */
253 | removeState(component: Component, name: string) {
254 | removeState(component, name, !this.privateState)
255 | }
256 |
257 | getStateEditor(selected: Component, label: string, name: string) {
258 | return html`
259 | {
267 | if (el) {
268 | const stateEditor = el as StateEditor
269 | stateEditor.data = this.getTokens(selected, name)
270 | }
271 | })}
272 | @change=${() => this.onChange(selected, name, label)}
273 | .disabled=${this.disabled}
274 | >
275 | ${label || name}
276 |
277 | `
278 | }
279 |
280 | onChange(component: Component, name: string, label: string) {
281 | if(this.redrawing) return
282 | const stateEditor = this.shadowRoot!.querySelector(`#${name}`) as StateEditor
283 | this.setState(component, name, {
284 | expression: stateEditor.data,
285 | label,
286 | })
287 | }
288 |
289 | getTokens(component: Component, name: string): Token[] {
290 | const state = this.getState(component, name)
291 | if(!state || !state.expression) return []
292 | return state.expression.map(token => {
293 | try {
294 | return fromStored(token, component.getId())
295 | } catch {
296 | // FIXME: notify user
297 | console.error('Error while getting expression result type in getTokens', {expression: state.expression, component, name})
298 | return {
299 | type: 'property',
300 | propType: 'field',
301 | fieldId: 'unknown',
302 | label: 'unknown',
303 | kind: 'scalar',
304 | typeIds: [],
305 | }
306 | }
307 | })
308 | }
309 |
310 | /**
311 | * Rename a custom state
312 | */
313 | renameCustomState(item: Item): Item {
314 | const label = prompt(this.renamePrompt, item.state.label)
315 | ?.toLowerCase()
316 | ?.replace(/[^a-z0-9]/g, '-')
317 | ?.replace(/^-+|-+$/g, '')
318 | if (!label || label === item.state.label) return item
319 | return {
320 | ...item,
321 | state: {
322 | ...item.state,
323 | label: label,
324 | },
325 | }
326 | }
327 |
328 | /**
329 | * Update the custom states, in the order of the list
330 | */
331 | updateOrderCustomStates(component: Component, items: Item[]) {
332 | const stateIds = this.getStateIds(component)
333 | // Remove all states
334 | stateIds.forEach(stateId => {
335 | if(items.map(item => item.name).includes(stateId)) {
336 | this.removeState(component, stateId)
337 | }
338 | })
339 | // Add states in the order of the list
340 | items.forEach(item => {
341 | this.setState(component, item.name, item.state)
342 | })
343 | }
344 |
345 | /**
346 | * Create a new custom state
347 | */
348 | createCustomState(component: Component): Item | null {
349 | const label = cleanStateName(prompt(this.createPrompt, this.defaultName))
350 | if (!label) return null
351 |
352 | if(this.reservedNames.includes(label)) {
353 | alert(`The name ${label} is reserved, please choose another name`)
354 | return null
355 | }
356 | const stateId = `${component.getId()}-${Math.random().toString(36).slice(2)}`
357 | const state: StoredState = {
358 | label,
359 | expression: [],
360 | }
361 | this.setState(component, stateId, state)
362 | return {
363 | name: stateId,
364 | state,
365 | publicState: !this.privateState,
366 | }
367 | }
368 | }
369 |
370 | if(!customElements.get('custom-states-editor')) {
371 | customElements.define('custom-states-editor', CustomStatesEditor)
372 | }
373 |
--------------------------------------------------------------------------------