Skip to content

Commit

Permalink
chore: custom widget (#28926)
Browse files Browse the repository at this point in the history
## Description
This PR's adds the custom widget.

#### PR fixes following issue(s)
Fixes #28607
Fixes #28610
Fixes #28615
Fixes #28608
Fixes #28612
> if no issue exists, please create an issue and ask the maintainers
about this first
>
>
#### Media
> A video or a GIF is preferred. when using Loom, don’t embed because it
looks like it’s a GIF. instead, just link to the video
>
>
#### Type of change
- New feature (non-breaking change which adds functionality)
>
>
>
## Testing
>
#### How Has This Been Tested?
> Please describe the tests that you ran to verify your changes. Also
list any relevant details for your test configuration.
> Delete anything that is not relevant
- [ ] Manual
- [ ] JUnit
- [x] Jest
- [x] Cypress
>
>
#### Test Plan
> Add Testsmith test cases links that relate to this PR
>
>
#### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)
>
>
>
## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced search functionality with a wildcard option for unmatched
widget searches.
  - Added new routes for custom widget editing.
  - Implemented additional editor modes for HTML and CSS.
- Created new code templates and help components for custom widget
builders.
- Enhanced property controls with new button controls for custom widget
editing.
  - Updated theming interfaces and constants for better theming support.
- Added the CustomWidget component for embedding custom widgets in
iframes.
  - Expanded widgets library to include the CustomWidget.

- **Enhancements**
- Improved `CodeEditor` with additional modes, props, and resize
behavior.
- Enhanced `PropertyPaneControlConfig` interface with dynamic
dependencies and additional properties.
- Refined custom widget scripts with communication channel and event
handling.

- **Bug Fixes**
  - Fixed visibility logic for the `ExternalWidget` card.
  
- **Documentation**
- Added new messages and documentation links for custom widget features.

- **Tests**
- Implemented new Cypress tests for custom widget default components and
property pane interactions.
  - Updated workspace commands in Cypress tests.

- **Refactor**
- Streamlined `PropertyControl` state management and editing functions.
  - Refactored code editor hint helper logic.
  
- **Style**
  - Added `borderLess` prop to style components without borders.

- **Chores**
  - Updated constants and messages related to custom widget features.
  - Adjusted webpack configuration to ignore specific module warnings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
balajisoundar authored Dec 28, 2023
1 parent f8ef73b commit 837d0cc
Show file tree
Hide file tree
Showing 101 changed files with 5,765 additions and 151 deletions.
5 changes: 3 additions & 2 deletions app/client/craco.common.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ module.exports = {
ignoreWarnings: [
function ignoreSourcemapsloaderWarnings(warning) {
return (
warning.module &&
(warning.module &&
warning.module.resource.includes("node_modules") &&
warning.details &&
warning.details.includes("source-map-loader")
warning.details.includes("source-map-loader")) ||
warning.module.resource.includes("/node_modules/@babel/standalone/babel.js")
);
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ describe(
],
Buttons: ["Button", "Button Group", "Icon button", "Menu button"],
Select: ["Multi TreeSelect", "MultiSelect", "Select", "TreeSelect"],
Display: ["Chart", "Iframe", "List", "Map Chart", "Stats Box", "Table"],
Display: [
"Chart",
"Custom",
"Iframe",
"List",
"Map Chart",
"Stats Box",
"Table",
],
Layout: ["Container", "Divider", "Form", "JSON Form", "Modal", "Tabs"],
Media: ["Audio", "Document Viewer", "Image", "Video"],
Toggles: [
Expand Down Expand Up @@ -185,7 +193,8 @@ describe(
agHelper.AssertElementLength(entityExplorer._widgetCards, 2);

agHelper.ClearNType(entityExplorer._widgetSearchInput, "cypress");
agHelper.AssertElementLength(entityExplorer._widgetCards, 0);
agHelper.AssertElementLength(entityExplorer._widgetCards, 1);
agHelper.AssertElementExist(".t--widget-card-draggable-customwidget");

agHelper.ClearTextField(entityExplorer._widgetSearchInput);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
agHelper,
deployMode,
entityExplorer,
} from "../../../../../support/Objects/ObjectsCore";

describe(
"Custom widget Tests",
{ tags: ["@tag.Widget", "@tag.Custom"] },
function () {
before(() => {
entityExplorer.DragDropWidgetNVerify("customwidget", 550, 100);
cy.wait(5000);
});

const getIframeBody = () => {
// get the iframe > document > body
// and retry until the body element is not empty
return (
cy
.get(".t--widget-customwidget iframe")
.its("0.contentDocument.body")
.should("not.be.empty")
// wraps "body" DOM element to allow
// chaining more Cypress commands, like ".find(...)"
// https://on.cypress.io/wrap
.then(cy.wrap)
);
};

it("should check that custom widget default component loaded and working properly", () => {
getIframeBody().find(".tip-container").should("exist");
getIframeBody()
.find(".tip-container p")
.should(
"have.text",
"Pass data to this widget in the default model field",
);

getIframeBody().find("button.primary").trigger("click");

getIframeBody()
.find(".tip-container p")
.should(
"have.text",
"Access data in the javascript file using the appsmith.model variable",
);

getIframeBody().find("button.reset").trigger("click");

agHelper.ValidateToastMessage("Successfully reset!!");
});

it("should check that custom widget default component loaded and working properly in published mode", () => {
deployMode.DeployApp();

getIframeBody().find(".tip-container").should("exist");
getIframeBody()
.find(".tip-container p")
.should(
"have.text",
"Pass data to this widget in the default model field",
);

getIframeBody().find("button.primary").trigger("click");

getIframeBody()
.find(".tip-container p")
.should(
"have.text",
"Access data in the javascript file using the appsmith.model variable",
);

getIframeBody().find("button.reset").trigger("click");

agHelper.ValidateToastMessage("Successfully reset!!");
});
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
agHelper,
deployMode,
entityExplorer,
propPane,
} from "../../../../../support/Objects/ObjectsCore";

describe(
"Custom widget Tests",
{ tags: ["@tag.Widget", "@tag.Custom"] },
function () {
before(() => {
agHelper.AddDsl("customWidget");
cy.wait(5000);
});

const getIframeBody = () => {
// get the iframe > document > body
// and retry until the body element is not empty
return (
cy
.get(".t--widget-customwidget iframe")
.its("0.contentDocument.body")
.should("not.be.empty")
// wraps "body" DOM element to allow
// chaining more Cypress commands, like ".find(...)"
// https://on.cypress.io/wrap
.then(cy.wrap)
);
};

it("shoud check that default model changes are converyed to custom component", () => {
getIframeBody().find(".tip-container").should("exist");

agHelper.GetElement(".t--text-widget-container").should("have.text", "");

getIframeBody().find("button.primary").trigger("click");

agHelper.GetElement(".t--text-widget-container").should("have.text", "1");

getIframeBody().find("button.reset").trigger("click");

agHelper.ValidateToastMessage("Successfully reset from 1");

agHelper.GetElement(".t--text-widget-container").should("have.text", "0");
});
},
);
156 changes: 156 additions & 0 deletions app/client/cypress/fixtures/customWidget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
{
"dsl": {
"widgetName": "MainContainer",
"backgroundColor": "none",
"rightColumn": 4896,
"snapColumns": 64,
"detachFromLayout": true,
"widgetId": "0",
"topRow": 0,
"bottomRow": 400,
"containerStyle": "none",
"snapRows": 124,
"parentRowSpace": 1,
"type": "CANVAS_WIDGET",
"canExtend": true,
"version": 87,
"minHeight": 1292,
"dynamicTriggerPathList": [],
"parentColumnSpace": 1,
"dynamicBindingPathList": [],
"leftColumn": 0,
"children": [
{
"mobileBottomRow": 33,
"widgetName": "Custom1",
"srcDoc": {
"html": "<!-- no need to write html, head, body tags, it is handled by the widget -->\n<div id=\"root\"></div>\n",
"css": ".app {\n\theight: calc(var(--appsmith-ui-height) * 1px);\n\twidth: calc(var(--appsmith-ui-width) * 1px);\n\tjustify-content: center;\n\tborder-radius: var(--appsmith-theme-borderRadius);\n\tbox-shadow: var(--appsmith-theme-boxShadow);\n}\n\n.tip-container {\n margin-bottom: 20px;\n}\n\n.tip-container h2 {\n margin-bottom: 20px;\n\tfont-size: 16px;\n\tfont-weight: 700;\n}\n\n.tip-header {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: baseline;\n}\n\n.tip-header div {\n\tcolor: #999;\n}\n\n.button-container {\n\ttext-align: right;\t\n}\n\n.button-container button {\n margin: 0 10px;\n}\n\n.button-container button.primary {\n\tbackground: var(--appsmith-theme-primaryColor) !important;\n}\n\n.button-container button.reset {\n\tcolor: var(--appsmith-theme-primaryColor) !important;\n\tborder-color: var(--appsmith-theme-primaryColor) !important;\n}",
"js": "import React from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';\nimport reactDom from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';\nimport { Button, Card } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';\nimport Markdown from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';\nfunction App() {\n const [currentIndex, setCurrentIndex] = React.useState(0);\n const [key, setKey] = React.useState(0);\n const handleNext = () => {\n const index = (currentIndex + 1) % appsmith.model.tips.length;\n setCurrentIndex(index);\n appsmith.updateModel({\n currentIndex: index\n });\n };\n const handleReset = () => {\n setCurrentIndex(0);\n appsmith.updateModel({\n currentIndex: 0\n });\n appsmith.triggerEvent(\"onResetClick\", {\n oldIndex: currentIndex\n });\n };\n React.useEffect(() => {\n appsmith.onModelChange((model, prevModel) => {\n if (JSON.stringify(prevModel?.tips) !== JSON.stringify(model.tips)) {\n setKey(Math.random());\n }\n });\n }, []);\n return /*#__PURE__*/React.createElement(Card, {\n className: \"app\",\n key: key\n }, /*#__PURE__*/React.createElement(\"div\", {\n className: \"tip-container\"\n }, /*#__PURE__*/React.createElement(\"div\", {\n className: \"tip-header\"\n }, /*#__PURE__*/React.createElement(\"h2\", null, \"Custom Widget\"), /*#__PURE__*/React.createElement(\"div\", null, currentIndex + 1, \" / \", appsmith.model.tips.length, \" \")), /*#__PURE__*/React.createElement(Markdown, null, appsmith.model.tips[currentIndex])), /*#__PURE__*/React.createElement(\"div\", {\n className: \"button-container\"\n }, /*#__PURE__*/React.createElement(Button, {\n className: \"primary\",\n onClick: handleNext,\n type: \"primary\"\n }, \"Next Tip\"), /*#__PURE__*/React.createElement(Button, {\n className: \"reset\",\n onClick: handleReset\n }, \"Reset\")));\n}\nappsmith.onReady(() => {\n reactDom.render( /*#__PURE__*/React.createElement(App, null), document.getElementById(\"root\"));\n});"
},
"isCanvas": false,
"displayName": "Custom",
"iconSVG": "/static/media/icon.867bcc8399fa3f897d425d72690b86e4.svg",
"searchTags": [
"external"
],
"topRow": 3,
"bottomRow": 33,
"parentRowSpace": 10,
"type": "CUSTOM_WIDGET",
"hideCard": false,
"mobileRightColumn": 41,
"parentColumnSpace": 19.296875,
"dynamicTriggerPathList": [
{
"key": "onResetClick"
}
],
"dynamicBindingPathList": [
{
"key": "theme"
}
],
"leftColumn": 21,
"defaultModel": "{\n \"tips\": [\n \"Pass data to this widget in the default model field\",\n \"Access data in the javascript file using the appsmith.model variable\",\n \"Create events in the widget and trigger them in the javascript file using appsmith.triggerEvent('eventName')\",\n \"Access data in CSS as var(--appsmith-model-{property-name})\"\n ]\n}",
"theme": "{{appsmith.theme}}",
"onResetClick": "{{showAlert('Successfully reset from ' + oldIndex, '');}}",
"events": [
"onResetClick"
],
"key": "nuzm5titx7",
"isDeprecated": false,
"rightColumn": 41,
"isSearchWildcard": true,
"widgetId": "giszumqrjp",
"isVisible": true,
"version": 1,
"uncompiledSrcDoc": {
"html": "<!-- no need to write html, head, body tags, it is handled by the widget -->\n<div id=\"root\"></div>\n",
"css": ".app {\n\theight: calc(var(--appsmith-ui-height) * 1px);\n\twidth: calc(var(--appsmith-ui-width) * 1px);\n\tjustify-content: center;\n\tborder-radius: var(--appsmith-theme-borderRadius);\n\tbox-shadow: var(--appsmith-theme-boxShadow);\n}\n\n.tip-container {\n margin-bottom: 20px;\n}\n\n.tip-container h2 {\n margin-bottom: 20px;\n\tfont-size: 16px;\n\tfont-weight: 700;\n}\n\n.tip-header {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: baseline;\n}\n\n.tip-header div {\n\tcolor: #999;\n}\n\n.button-container {\n\ttext-align: right;\t\n}\n\n.button-container button {\n margin: 0 10px;\n}\n\n.button-container button.primary {\n\tbackground: var(--appsmith-theme-primaryColor) !important;\n}\n\n.button-container button.reset {\n\tcolor: var(--appsmith-theme-primaryColor) !important;\n\tborder-color: var(--appsmith-theme-primaryColor) !important;\n}",
"js": "import React from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'\nimport reactDom from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'\nimport { Button, Card } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'\nimport Markdown from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';\n\nfunction App() {\n\tconst [currentIndex, setCurrentIndex] = React.useState(0);\n\t\n\tconst [key, setKey] = React.useState(0);\n\n\tconst handleNext = () => {\n\t\tconst index = (currentIndex + 1) % appsmith.model.tips.length\n\t\tsetCurrentIndex(index);\n\t\tappsmith.updateModel({\n\t\t\tcurrentIndex: index\n\t\t});\n\t};\n\n\tconst handleReset = () => {\n\t\tsetCurrentIndex(0);\n\t\tappsmith.updateModel({\n\t\t\tcurrentIndex: 0\n\t\t});\n\t\tappsmith.triggerEvent(\"onResetClick\", {\n\t\t\toldIndex: currentIndex\n\t\t});\n\t};\n\t\n\tReact.useEffect(() => {\t\t\n\t\tappsmith.onModelChange((model, prevModel) => {\n\t\t\tif (JSON.stringify(prevModel?.tips) !== JSON.stringify(model.tips)) {\n\t\t\t\tsetKey(Math.random());\n\t\t\t}\n\t\t});\n\t}, []);\n\t\n\n\treturn (\n\t\t<Card className=\"app\" key={key}>\n\t\t\t<div className=\"tip-container\">\n\t\t\t\t<div className=\"tip-header\">\n\t\t\t\t\t<h2>Custom Widget</h2>\n\t\t\t\t\t<div>{currentIndex + 1} / {appsmith.model.tips.length}\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<Markdown>{appsmith.model.tips[currentIndex]}</Markdown>\n\t\t\t</div>\n\t\t\t<div className=\"button-container\">\n\t\t\t\t<Button className=\"primary\" onClick={handleNext} type=\"primary\">Next Tip</Button>\n\t\t\t\t<Button className=\"reset\" onClick={handleReset}>Reset</Button>\n\t\t\t</div>\n\t</Card>\n);\n}\n\nappsmith.onReady(() => {\n\treactDom.render(<App />, document.getElementById(\"root\"));\n});"
},
"parentId": "0",
"tags": [
"Display"
],
"renderMode": "CANVAS",
"isLoading": false,
"mobileTopRow": 3,
"mobileLeftColumn": 21,
"dynamicPropertyPathList": [
{
"key": "onResetClick"
}
]
},
{
"isVisible": true,
"type": "TEXT_WIDGET",
"text": "{{Custom1.model.currentIndex}}",
"fontSize": "1rem",
"fontStyle": "BOLD",
"textAlign": "LEFT",
"textColor": "#231F20",
"widgetName": "Text1",
"shouldTruncate": false,
"overflow": "NONE",
"version": 1,
"animateLoading": true,
"responsiveBehavior": "fill",
"minWidth": 450,
"minDynamicHeight": 4,
"maxDynamicHeight": 9000,
"dynamicHeight": "AUTO_HEIGHT",
"searchTags": [
"typography",
"paragraph",
"label"
],
"tags": [
"Suggested",
"Content"
],
"hideCard": false,
"isDeprecated": false,
"displayName": "Text",
"key": "my0l36gkcf",
"iconSVG": "/static/media/icon.a47d6d5dbbb718c4dc4b2eb4f218c1b7.svg",
"widgetId": "144bnot1xa",
"renderMode": "CANVAS",
"truncateButtonColor": "{{appsmith.theme.colors.primaryColor}}",
"fontFamily": "{{appsmith.theme.fontFamily.appFont}}",
"borderRadius": "{{appsmith.theme.borderRadius.appBorderRadius}}",
"isLoading": false,
"parentColumnSpace": 19.296875,
"parentRowSpace": 10,
"leftColumn": 23,
"rightColumn": 40,
"topRow": 36,
"bottomRow": 40,
"mobileLeftColumn": 23,
"mobileRightColumn": 39,
"mobileTopRow": 38,
"mobileBottomRow": 42,
"parentId": "0",
"dynamicBindingPathList": [
{
"key": "truncateButtonColor"
},
{
"key": "fontFamily"
},
{
"key": "borderRadius"
},
{
"key": "text"
}
],
"dynamicTriggerPathList": [],
"originalTopRow": 38,
"originalBottomRow": 42
}
]
}
}
4 changes: 4 additions & 0 deletions app/client/cypress/support/WorkspaceCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require("cy-verify-downloads").addCustomCommand();
require("cypress-file-upload");
import homePage from "../locators/HomePage";
import { ObjectsRegistry } from "../support/Objects/Registry";
import { featureFlagIntercept } from "./Objects/FeatureFlags";

const agHelper = ObjectsRegistry.AggregateHelper;
const assertHelper = ObjectsRegistry.AssertHelper;
Expand Down Expand Up @@ -272,6 +273,9 @@ Cypress.Commands.add("CreateNewAppInNewWorkspace", () => {
localStorage.setItem("workspaceName", workspaceName);
homePageTS.CreateAppInWorkspace(localStorage.getItem("workspaceName"));
});

featureFlagIntercept({ release_custom_widgets_enabled: true });

cy.get("@createNewApplication").then((xhr) => {
const response = xhr.response;
expect(response.body.responseMeta.status).to.eq(201);
Expand Down
29 changes: 0 additions & 29 deletions app/client/generators/widget/templates/index.js.hbs
Original file line number Diff line number Diff line change
@@ -1,32 +1,3 @@
import Widget from "./widget";
import IconSVG from "./icon.svg";

export const CONFIG = {
type: Widget.getWidgetType(),
name: "{{name}}", // The display name which will be made in uppercase and show in the widgets panel ( can have spaces )
iconSVG: IconSVG,
needsMeta: false, // Defines if this widget adds any meta properties
isCanvas: false, // Defines if this widget has a canvas within in which we can drop other widgets
features: {
dynamicHeight: {
sectionIndex: 0, // Index of the property pane "General" section
active: false,
},
},
defaults: {
widgetName: "{{name}}",
rows: 1,
columns: 3,
version: 1,
},
properties: {
derived: Widget.getDerivedPropertiesMap(),
default: Widget.getDefaultPropertiesMap(),
meta: Widget.getMetaPropertiesMap(),
contentConfig: Widget.getPropertyPaneContentConfig(),
styleConfig: Widget.getPropertyPaneStyleConfig(),
autocompleteDefinitions: Widget.getAutocompleteDefinitions()
},
};

export default Widget;
Loading

0 comments on commit 837d0cc

Please sign in to comment.