feat: Add AudioWidget (#7179)

* Create initial version of AudioWidget by copying VideoWidget

* Add EventType for AUDIO

* Change default Audio URL to a podcast related to Appsmith

* Add AudioWidget icon

* Change Entity definition for AudioWidget

* Add cypress test

* Add jest test

* fix: typo
This commit is contained in:
Aswath K 2021-09-24 21:35:53 +05:30 committed by GitHub
parent f3afa81afe
commit 8a45e1507e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 445 additions and 0 deletions

View File

@ -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": []
}
}

View File

@ -69,6 +69,7 @@
"input2": "(//div[@class='bp3-input-group']//input)[1]", "input2": "(//div[@class='bp3-input-group']//input)[1]",
"input3": "(//div[@class='bp3-input-group']//input)[2]", "input3": "(//div[@class='bp3-input-group']//input)[2]",
"videoUrl": "https://www.youtube.com/watch?v=S5musXykVs0", "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": [ "TablePagination": [
{ {
"id": 2381224, "id": 2381224,

View File

@ -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,
);
});
});

View File

@ -56,6 +56,7 @@
"SearchTextChangeAction": ".t--property-control-onsearchtextchanged button", "SearchTextChangeAction": ".t--property-control-onsearchtextchanged button",
"tableSearchTextChangeSelected": ".t--property-control-onsearchtextchanged", "tableSearchTextChangeSelected": ".t--property-control-onsearchtextchanged",
"videoWidget": ".t--draggable-videowidget", "videoWidget": ".t--draggable-videowidget",
"audioWidget": ".t--draggable-audiowidget",
"autoPlay": ".t--property-control-autoplay > .bp3-control > .bp3-control-indicator", "autoPlay": ".t--property-control-autoplay > .bp3-control > .bp3-control-indicator",
"defaultOption": ".t--property-control-defaultoption .CodeMirror-code", "defaultOption": ".t--property-control-defaultoption .CodeMirror-code",
"dropdownSingleSelect": ".bp3-popover-target > div > .bp3-button", "dropdownSingleSelect": ".bp3-popover-target > div > .bp3-button",

View File

@ -78,6 +78,7 @@
"toastAction": ".t--toast-action", "toastAction": ".t--toast-action",
"toastBody": ".Toastify__toast-body", "toastBody": ".Toastify__toast-body",
"videoInner": ".t--draggable-videowidget span.t--widget-name", "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", "onPlay": ".t--property-control-onplay .t--open-dropdown-Select-Action",
"chooseAction": ".single-select", "chooseAction": ".single-select",
"chooseMsgType": ".t--open-dropdown-Select-type", "chooseMsgType": ".t--open-dropdown-Select-type",

View File

@ -77,6 +77,10 @@ export enum EventType {
ON_VIDEO_END = "ON_VIDEO_END", ON_VIDEO_END = "ON_VIDEO_END",
ON_VIDEO_PLAY = "ON_VIDEO_PLAY", ON_VIDEO_PLAY = "ON_VIDEO_PLAY",
ON_VIDEO_PAUSE = "ON_VIDEO_PAUSE", 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_RATE_CHANGED = "ON_RATE_CHANGED",
ON_IFRAME_URL_CHANGED = "ON_IFRAME_URL_CHANGED", ON_IFRAME_URL_CHANGED = "ON_IFRAME_URL_CHANGED",
ON_IFRAME_MESSAGE_RECEIVED = "ON_IFRAME_MESSAGE_RECEIVED", ON_IFRAME_MESSAGE_RECEIVED = "ON_IFRAME_MESSAGE_RECEIVED",

View File

@ -1,4 +1,8 @@
export const HelpMap: Record<string, { path: string; searchKey: string }> = { export const HelpMap: Record<string, { path: string; searchKey: string }> = {
AUDIO_WIDGET: {
path: "/widget-reference/audio",
searchKey: "Audio",
},
CONTAINER_WIDGET: { CONTAINER_WIDGET: {
path: "/widget-reference/container", path: "/widget-reference/container",
searchKey: "Container", searchKey: "Container",

View File

@ -29,6 +29,7 @@ export const FORM_VALIDATION_EMPTY_EMAIL = () => `Please enter an email`;
export const FORM_VALIDATION_INVALID_EMAIL = () => export const FORM_VALIDATION_INVALID_EMAIL = () =>
`Please provide a valid email address`; `Please provide a valid email address`;
export const ENTER_VIDEO_URL = () => `Please provide a valid url`; 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_EMPTY_PASSWORD = () => `Please enter the password`;
export const FORM_VALIDATION_PASSWORD_RULE = () => export const FORM_VALIDATION_PASSWORD_RULE = () =>

View File

@ -90,6 +90,9 @@ import StatboxWidget, {
import FilePickerWidgetV2, { import FilePickerWidgetV2, {
CONFIG as FILEPICKER_WIDGET_V2_CONFIG, CONFIG as FILEPICKER_WIDGET_V2_CONFIG,
} from "widgets/FilePickerWidgetV2"; } from "widgets/FilePickerWidgetV2";
import AudioWidget, {
CONFIG as AUDIO_WIDGET_CONFIG,
} from "widgets/AudioWidget";
import AudioRecorderWidget, { import AudioRecorderWidget, {
CONFIG as AUDIO_RECORDER_WIDGET_CONFIG, CONFIG as AUDIO_RECORDER_WIDGET_CONFIG,
@ -143,6 +146,7 @@ export const registerWidgets = () => {
registerWidget(AudioRecorderWidget, AUDIO_RECORDER_WIDGET_CONFIG); registerWidget(AudioRecorderWidget, AUDIO_RECORDER_WIDGET_CONFIG);
registerWidget(MultiSelectTreeWidget, MULTI_SELECT_TREE_WIDGET_CONFIG); registerWidget(MultiSelectTreeWidget, MULTI_SELECT_TREE_WIDGET_CONFIG);
registerWidget(SingleSelectTreeWidget, SINGLE_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"); log.debug("Widget registration took: ", performance.now() - start, "ms");
}; };

View File

@ -33,6 +33,13 @@ export const entityDefinitions: Record<string, unknown> = {
clear: "fn() -> void", 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: { CONTAINER_WIDGET: {
"!doc": "!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.", "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.",

View File

@ -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<ReactPlayer>;
}
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 ? (
<ReactPlayer
controls={controls || true}
height="100%"
onEnded={onEnded}
onError={onError}
onPause={onPause}
onPlay={onPlay}
onProgress={onProgress}
onReady={onReady}
onSeek={onSeek}
onStart={onStart}
pip={false}
playing={autoplay}
ref={player}
url={url}
width="100%"
/>
) : (
<ErrorContainer>
<Error>{createMessage(ENTER_AUDIO_URL)}</Error>
</ErrorContainer>
);
}

View File

@ -0,0 +1,8 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 11V3H2.5V11H0.5Z" fill="#EAEAEA"/>
<path d="M3.5 13V1H5.5V13H3.5Z" fill="#EAEAEA"/>
<path d="M6.5 10V5H8.5V10H6.5Z" fill="#EAEAEA"/>
<path d="M9.5 14V0H11.5V14H9.5Z" fill="#EAEAEA"/>
<path d="M12.5 12V2H14.5V12H12.5Z" fill="#EAEAEA"/>
<path d="M15.5 10V4H17.5V10H15.5Z" fill="#EAEAEA"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -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;

View File

@ -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 || "");
});
});
});

View File

@ -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<AudioWidgetProps, WidgetState> {
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<ReactPlayer>();
static getMetaPropertiesMap(): Record<string, any> {
return {
playState: PlayState.NOT_STARTED,
};
}
static getDefaultPropertiesMap(): Record<string, string> {
return {};
}
getPageView() {
const { autoPlay, onEnd, onPause, onPlay, url } = this.props;
return (
<Suspense fallback={<Skeleton />}>
<AudioComponent
autoplay={autoPlay}
controls
onEnded={() => {
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}
/>
</Suspense>
);
}
static getWidgetType(): WidgetType {
return "AUDIO_WIDGET";
}
}
export interface AudioWidgetProps extends WidgetProps {
url: string;
autoPlay: boolean;
onPause?: string;
onPlay?: string;
onEnd?: string;
}
export default AudioWidget;