diff --git a/app/client/cypress/fixtures/audioWidgetDsl.json b/app/client/cypress/fixtures/audioWidgetDsl.json new file mode 100644 index 0000000000..f6cef469eb --- /dev/null +++ b/app/client/cypress/fixtures/audioWidgetDsl.json @@ -0,0 +1,49 @@ +{ + "dsl": { + "widgetName": "MainContainer", + "backgroundColor": "none", + "rightColumn": 915.6499999999999, + "snapColumns": 64, + "detachFromLayout": true, + "widgetId": "0", + "topRow": 0, + "bottomRow": 750, + "containerStyle": "none", + "snapRows": 125, + "parentRowSpace": 1, + "type": "CANVAS_WIDGET", + "canExtend": true, + "version": 38, + "minHeight": 760, + "parentColumnSpace": 1, + "dynamicBindingPathList": [], + "leftColumn": 0, + "children": [ + { + "isVisible": true, + "widgetName": "Audio1", + "url": "https://cdn.simplecast.com/audio/10488ddf-3ca4-4300-9391-c2967d806334/episodes/8c8341f0-0a3a-4f2c-bfe0-0abb6b3c1c87/audio/03e2e3d8-e703-4953-adc0-e72687f31178/default_tc.mp3", + "autoPlay": false, + "version": 1, + "type": "AUDIO_WIDGET", + "hideCard": false, + "displayName": "Audio", + "key": "qdo3rra7pr", + "iconSVG": "/static/media/icon.26d597b5.svg", + "widgetId": "ttnnyvbw2c", + "renderMode": "CANVAS", + "isLoading": false, + "parentColumnSpace": 14.609375, + "parentRowSpace": 10, + "leftColumn": 5, + "rightColumn": 33, + "topRow": 11, + "bottomRow": 15, + "parentId": "0", + "dynamicBindingPathList": [], + "dynamicTriggerPathList": [] + } + ], + "dynamicTriggerPathList": [] + } +} \ No newline at end of file diff --git a/app/client/cypress/fixtures/testdata.json b/app/client/cypress/fixtures/testdata.json index d601c36224..107a8d24ad 100644 --- a/app/client/cypress/fixtures/testdata.json +++ b/app/client/cypress/fixtures/testdata.json @@ -69,6 +69,7 @@ "input2": "(//div[@class='bp3-input-group']//input)[1]", "input3": "(//div[@class='bp3-input-group']//input)[2]", "videoUrl": "https://www.youtube.com/watch?v=S5musXykVs0", + "audioUrl": "https://cdn.simplecast.com/audio/10488ddf-3ca4-4300-9391-c2967d806334/episodes/8c8341f0-0a3a-4f2c-bfe0-0abb6b3c1c87/audio/03e2e3d8-e703-4953-adc0-e72687f31178/default_tc.mp3", "TablePagination": [ { "id": 2381224, diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/audio_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/audio_spec.js new file mode 100644 index 0000000000..1eccbf7e48 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/audio_spec.js @@ -0,0 +1,55 @@ +const widgetsPage = require("../../../../locators/Widgets.json"); +const commonlocators = require("../../../../locators/commonlocators.json"); +const dsl = require("../../../../fixtures/audioWidgetDsl.json"); +const testdata = require("../../../../fixtures/testdata.json"); + +describe("Audio Widget Functionality", function() { + before(() => { + cy.addDsl(dsl); + }); + + it("Audio Widget play functionality validation", function() { + cy.openPropertyPane("audiowidget"); + cy.widgetText("Audio1", widgetsPage.audioWidget, commonlocators.audioInner); + cy.get(commonlocators.onPlay).click(); + cy.selectShowMsg(); + cy.addSuccessMessage("Play success"); + cy.get(widgetsPage.autoPlay).click(); + cy.wait("@updateLayout").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + }); + + it("Audio widget pause functionality validation", function() { + cy.get(commonlocators.onPause).click(); + cy.selectShowMsg(); + cy.addSuccessMessage("Pause success"); + cy.get(widgetsPage.autoPlay).click(); + cy.wait("@updateLayout").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + }); + + it("Update audio url and check play and pause functionality validation", function() { + cy.testCodeMirror(testdata.audioUrl); + cy.get(".CodeMirror textarea") + .first() + .blur(); + cy.get(widgetsPage.autoPlay).click({ force: true }); + cy.wait("@updateLayout").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + cy.get(widgetsPage.autoPlay).click({ force: true }); + cy.wait("@updateLayout").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + }); +}); diff --git a/app/client/cypress/locators/Widgets.json b/app/client/cypress/locators/Widgets.json index 1f0f444846..c519995f6f 100644 --- a/app/client/cypress/locators/Widgets.json +++ b/app/client/cypress/locators/Widgets.json @@ -56,6 +56,7 @@ "SearchTextChangeAction": ".t--property-control-onsearchtextchanged button", "tableSearchTextChangeSelected": ".t--property-control-onsearchtextchanged", "videoWidget": ".t--draggable-videowidget", + "audioWidget": ".t--draggable-audiowidget", "autoPlay": ".t--property-control-autoplay > .bp3-control > .bp3-control-indicator", "defaultOption": ".t--property-control-defaultoption .CodeMirror-code", "dropdownSingleSelect": ".bp3-popover-target > div > .bp3-button", diff --git a/app/client/cypress/locators/commonlocators.json b/app/client/cypress/locators/commonlocators.json index f01b3c05ed..f718e0f0c8 100644 --- a/app/client/cypress/locators/commonlocators.json +++ b/app/client/cypress/locators/commonlocators.json @@ -78,6 +78,7 @@ "toastAction": ".t--toast-action", "toastBody": ".Toastify__toast-body", "videoInner": ".t--draggable-videowidget span.t--widget-name", + "audioInner": ".t--draggable-audiowidget span.t--widget-name", "onPlay": ".t--property-control-onplay .t--open-dropdown-Select-Action", "chooseAction": ".single-select", "chooseMsgType": ".t--open-dropdown-Select-type", diff --git a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx index 881735bc9e..1191abffad 100644 --- a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx +++ b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx @@ -77,6 +77,10 @@ export enum EventType { ON_VIDEO_END = "ON_VIDEO_END", ON_VIDEO_PLAY = "ON_VIDEO_PLAY", ON_VIDEO_PAUSE = "ON_VIDEO_PAUSE", + ON_AUDIO_START = "ON_AUDIO_START", + ON_AUDIO_END = "ON_AUDIO_END", + ON_AUDIO_PLAY = "ON_AUDIO_PLAY", + ON_AUDIO_PAUSE = "ON_AUDIO_PAUSE", ON_RATE_CHANGED = "ON_RATE_CHANGED", ON_IFRAME_URL_CHANGED = "ON_IFRAME_URL_CHANGED", ON_IFRAME_MESSAGE_RECEIVED = "ON_IFRAME_MESSAGE_RECEIVED", diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index 46083cb65c..25be69f731 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -1,4 +1,8 @@ export const HelpMap: Record = { + AUDIO_WIDGET: { + path: "/widget-reference/audio", + searchKey: "Audio", + }, CONTAINER_WIDGET: { path: "/widget-reference/container", searchKey: "Container", diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index f9a5a68d29..92b32de8eb 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -29,6 +29,7 @@ export const FORM_VALIDATION_EMPTY_EMAIL = () => `Please enter an email`; export const FORM_VALIDATION_INVALID_EMAIL = () => `Please provide a valid email address`; export const ENTER_VIDEO_URL = () => `Please provide a valid url`; +export const ENTER_AUDIO_URL = () => `Please provide a valid url`; export const FORM_VALIDATION_EMPTY_PASSWORD = () => `Please enter the password`; export const FORM_VALIDATION_PASSWORD_RULE = () => diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx index f830e15467..603542b75e 100644 --- a/app/client/src/utils/WidgetRegistry.tsx +++ b/app/client/src/utils/WidgetRegistry.tsx @@ -90,6 +90,9 @@ import StatboxWidget, { import FilePickerWidgetV2, { CONFIG as FILEPICKER_WIDGET_V2_CONFIG, } from "widgets/FilePickerWidgetV2"; +import AudioWidget, { + CONFIG as AUDIO_WIDGET_CONFIG, +} from "widgets/AudioWidget"; import AudioRecorderWidget, { CONFIG as AUDIO_RECORDER_WIDGET_CONFIG, @@ -143,6 +146,7 @@ export const registerWidgets = () => { registerWidget(AudioRecorderWidget, AUDIO_RECORDER_WIDGET_CONFIG); registerWidget(MultiSelectTreeWidget, MULTI_SELECT_TREE_WIDGET_CONFIG); registerWidget(SingleSelectTreeWidget, SINGLE_SELECT_TREE_WIDGET_CONFIG); + registerWidget(AudioWidget, AUDIO_WIDGET_CONFIG); log.debug("Widget registration took: ", performance.now() - start, "ms"); }; diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index f2906b1e52..37b742ee80 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -33,6 +33,13 @@ export const entityDefinitions: Record = { clear: "fn() -> void", }; }, + AUDIO_WIDGET: { + "!doc": + "Audio widget can be used for playing a variety of audio formats like MP3, AAC etc.", + "!url": "https://docs.appsmith.com/widget-reference/audio", + playState: "number", + autoPlay: "bool", + }, CONTAINER_WIDGET: { "!doc": "Containers are used to group widgets together to form logical higher order widgets. Containers let you organize your page better and move all the widgets inside them together.", diff --git a/app/client/src/widgets/AudioWidget/component/index.tsx b/app/client/src/widgets/AudioWidget/component/index.tsx new file mode 100644 index 0000000000..8d1c4a4899 --- /dev/null +++ b/app/client/src/widgets/AudioWidget/component/index.tsx @@ -0,0 +1,68 @@ +import ReactPlayer from "react-player"; +import React, { Ref } from "react"; +import styled from "styled-components"; +import { createMessage, ENTER_AUDIO_URL } from "constants/messages"; +export interface AudioComponentProps { + url?: string; + autoplay?: boolean; + controls?: boolean; + onStart?: () => void; + onPlay?: () => void; + onPause?: () => void; + onEnded?: () => void; + onReady?: () => void; + onProgress?: () => void; + onSeek?: () => void; + onError?: () => void; + player?: Ref; +} + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +`; + +const Error = styled.span``; + +export default function AudioComponent(props: AudioComponentProps) { + const { + autoplay, + controls, + onEnded, + onError, + onPause, + onPlay, + onProgress, + onReady, + onSeek, + onStart, + player, + url, + } = props; + return url ? ( + + ) : ( + + {createMessage(ENTER_AUDIO_URL)} + + ); +} diff --git a/app/client/src/widgets/AudioWidget/icon.svg b/app/client/src/widgets/AudioWidget/icon.svg new file mode 100644 index 0000000000..037a9be6a8 --- /dev/null +++ b/app/client/src/widgets/AudioWidget/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/client/src/widgets/AudioWidget/index.tsx b/app/client/src/widgets/AudioWidget/index.tsx new file mode 100644 index 0000000000..7339f63b73 --- /dev/null +++ b/app/client/src/widgets/AudioWidget/index.tsx @@ -0,0 +1,27 @@ +import Widget from "./widget"; +import IconSVG from "./icon.svg"; +import { GRID_DENSITY_MIGRATION_V1 } from "widgets/constants"; + +export const CONFIG = { + type: Widget.getWidgetType(), + name: "Audio", + iconSVG: IconSVG, + needsMeta: true, + defaults: { + rows: 1 * GRID_DENSITY_MIGRATION_V1, + columns: 7 * GRID_DENSITY_MIGRATION_V1, + widgetName: "Audio", + url: + "https://cdn.simplecast.com/audio/10488ddf-3ca4-4300-9391-c2967d806334/episodes/8c8341f0-0a3a-4f2c-bfe0-0abb6b3c1c87/audio/03e2e3d8-e703-4953-adc0-e72687f31178/default_tc.mp3", + autoPlay: false, + version: 1, + }, + properties: { + derived: Widget.getDerivedPropertiesMap(), + default: Widget.getDefaultPropertiesMap(), + meta: Widget.getMetaPropertiesMap(), + config: Widget.getPropertyPaneConfig(), + }, +}; + +export default Widget; diff --git a/app/client/src/widgets/AudioWidget/widget/index.test.tsx b/app/client/src/widgets/AudioWidget/widget/index.test.tsx new file mode 100644 index 0000000000..880a2f5958 --- /dev/null +++ b/app/client/src/widgets/AudioWidget/widget/index.test.tsx @@ -0,0 +1,40 @@ +import { PropertyPaneControlConfig } from "constants/PropertyControlConstants"; +import AudioWidget from "."; + +const urlTests = [ + { url: "https://appsmith.com/", isValid: true }, + { url: "http://appsmith.com/", isValid: true }, + { url: "appsmith.com/", isValid: true }, + { url: "appsmith.com", isValid: true }, + { url: "release.appsmith.com", isValid: true }, + { url: "appsmith.com/audio.mp3", isValid: true }, + { url: "appsmith./audio.mp3", isValid: false }, + { url: "https://appsmith.com/randompath/somefile.mp3", isValid: true }, + { url: "https://appsmith.com/randompath/some file.mp3", isValid: true }, + { url: "random string", isValid: false }, + { + url: "blob:https://dev.appsmith.com/9db94f56-5e32-4b18-2758-64c21a7f4610", + isValid: true, + }, +]; + +describe("urlRegexValidation", () => { + const generalSectionProperties: PropertyPaneControlConfig[] = AudioWidget.getPropertyPaneConfig().filter( + (x) => x.sectionName === "General", + )[0].children; + const urlPropertyControl = generalSectionProperties.filter( + (x) => x.propertyName === "url", + )[0]; + const regEx = urlPropertyControl.validation?.params?.regex; + + it("validate existence of regEx", () => { + expect(regEx).toBeDefined(); + }); + + it("test regEx", () => { + urlTests.forEach((test) => { + if (test.isValid) expect(test.url).toMatch(regEx || ""); + else expect(test.url).not.toMatch(regEx || ""); + }); + }); +}); diff --git a/app/client/src/widgets/AudioWidget/widget/index.tsx b/app/client/src/widgets/AudioWidget/widget/index.tsx new file mode 100644 index 0000000000..c6d892cf80 --- /dev/null +++ b/app/client/src/widgets/AudioWidget/widget/index.tsx @@ -0,0 +1,175 @@ +import React, { Suspense, lazy } from "react"; +import BaseWidget, { WidgetProps, WidgetState } from "../../BaseWidget"; +import { WidgetType } from "constants/WidgetConstants"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; +import { ValidationTypes } from "constants/WidgetValidation"; +import Skeleton from "components/utils/Skeleton"; +import { retryPromise } from "utils/AppsmithUtils"; +import ReactPlayer from "react-player"; +import { AutocompleteDataType } from "utils/autocomplete/TernServer"; + +const AudioComponent = lazy(() => retryPromise(() => import("../component"))); + +export enum PlayState { + NOT_STARTED = "NOT_STARTED", + PAUSED = "PAUSED", + ENDED = "ENDED", + PLAYING = "PLAYING", +} + +class AudioWidget extends BaseWidget { + static getPropertyPaneConfig() { + return [ + { + sectionName: "General", + children: [ + { + propertyName: "url", + label: "URL", + controlType: "INPUT_TEXT", + placeholderText: "Enter url", + inputType: "TEXT", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.TEXT, + params: { + regex: /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/, + expected: { + type: "Audio URL", + example: + "https://cdn.simplecast.com/audio/10488ddf-3ca4-4300-9391-c2967d806334/episodes/8c8341f0-0a3a-4f2c-bfe0-0abb6b3c1c87/audio/03e2e3d8-e703-4953-adc0-e72687f31178/default_tc.mp3", + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + }, + { + propertyName: "autoPlay", + label: "Auto Play", + helpText: "Audio will be automatically played", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + { + helpText: "Controls the visibility of the widget", + propertyName: "isVisible", + label: "Visible", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, + ], + }, + { + sectionName: "Actions", + children: [ + { + helpText: "Triggers an action when the audio is played", + propertyName: "onPlay", + label: "onPlay", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + { + helpText: "Triggers an action when the audio is paused", + propertyName: "onPause", + label: "onPause", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + { + helpText: "Triggers an action when the audio ends", + propertyName: "onEnd", + label: "onEnd", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + }, + ], + }, + ]; + } + + private _player = React.createRef(); + + static getMetaPropertiesMap(): Record { + return { + playState: PlayState.NOT_STARTED, + }; + } + + static getDefaultPropertiesMap(): Record { + return {}; + } + + getPageView() { + const { autoPlay, onEnd, onPause, onPlay, url } = this.props; + return ( + }> + { + this.props.updateWidgetMetaProperty("playState", PlayState.ENDED, { + triggerPropertyName: "onEnd", + dynamicString: onEnd, + event: { + type: EventType.ON_AUDIO_END, + }, + }); + }} + onPause={() => { + //TODO: We do not want the pause event for onSeek or onEnd. + this.props.updateWidgetMetaProperty("playState", PlayState.PAUSED, { + triggerPropertyName: "onPause", + dynamicString: onPause, + event: { + type: EventType.ON_AUDIO_PAUSE, + }, + }); + }} + onPlay={() => { + this.props.updateWidgetMetaProperty( + "playState", + PlayState.PLAYING, + { + triggerPropertyName: "onPlay", + dynamicString: onPlay, + event: { + type: EventType.ON_AUDIO_PLAY, + }, + }, + ); + }} + player={this._player} + url={url} + /> + + ); + } + + static getWidgetType(): WidgetType { + return "AUDIO_WIDGET"; + } +} + +export interface AudioWidgetProps extends WidgetProps { + url: string; + autoPlay: boolean; + onPause?: string; + onPlay?: string; + onEnd?: string; +} + +export default AudioWidget;