Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Shiny Event classes for custom events #3815

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
01fd8fa
Remove `el` from ShinyEventCommon, value is `any`
gadenbuie Apr 26, 2023
90c0523
Allow any-typed arguments in exported functions
gadenbuie Apr 26, 2023
8d12ae7
Remove `value`, add `el` to ShinyEventInputChanged
gadenbuie Apr 26, 2023
0e2d040
Implement `EventCommon` and `EventInputChanged` classes
gadenbuie Apr 26, 2023
e2d19b9
Use `EventInputChanged()` to emit file upload input event
gadenbuie Apr 26, 2023
60f074d
Make the `EventCommon` class work more like an `Event`
gadenbuie Apr 26, 2023
54fa857
Use `EventInputChanged` in `InputEventDecorator`
gadenbuie Apr 26, 2023
63427a7
Go back to using `EvtFn()` to extend jquery event handler
gadenbuie Apr 26, 2023
651b768
Implement one lower-level event class `EventBase` and use `event` rat…
gadenbuie Apr 26, 2023
922d16d
Implement `EventUpdateInput` for `shiny:updateinput`
gadenbuie Apr 26, 2023
2740be1
Implement `EventValue` for `shiny:value`
gadenbuie Apr 26, 2023
b5c6561
Implement `EventError` for `shiny:error` events
gadenbuie Apr 26, 2023
a66ef6d
Implement `EventMessage` for `shiny:message`
gadenbuie Apr 26, 2023
08ced6b
Use `@babel/plugin-transform-typescript`
gadenbuie Apr 27, 2023
4f50796
import $ from jquery
gadenbuie Apr 27, 2023
ce4961f
`EventBase.triggerOn()` now accepts jquery html elements
gadenbuie Apr 27, 2023
0adeb4b
DRY properties and type definitions
gadenbuie Apr 27, 2023
4648423
Use more descriptive names for events, not `evt`
gadenbuie Apr 27, 2023
eddb0d9
Add more context in comments
gadenbuie Apr 27, 2023
064da3a
yarn build
gadenbuie Apr 27, 2023
10c2398
Add jsdoc strings to shinyEvents.ts
gadenbuie May 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ rules:
- off
"@typescript-eslint/explicit-module-boundary-types":
- error
- allowArgumentsExplicitlyTypedAsAny: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments below on this. I'd imagine we can unset this again. Might require some assertions but that's probably safer anyways.


default-case:
- error
Expand Down
10 changes: 9 additions & 1 deletion babel.config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
{
"plugins": [
[
"@babel/plugin-transform-typescript",
{
"allowDeclareFields": true
}
]
],
"presets": [
"@babel/preset-typescript",
[
Expand All @@ -9,7 +17,7 @@
}
]
],
"ignore":[
"ignore": [
"node_modules/core-js"
]
}
1,996 changes: 1,145 additions & 851 deletions inst/www/shared/shiny.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions inst/www/shared/shiny.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion inst/www/shared/shiny.min.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions inst/www/shared/shiny.min.js.map

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"yarn": ">= 1.22"
},
"dependencies": {
"@babel/plugin-transform-typescript": "^7.21.3",
"@types/bootstrap": "3.4.0",
"@types/bootstrap-datepicker": "0.0.14",
"@types/datatables.net": "^1.10.19",
Expand Down
26 changes: 0 additions & 26 deletions srcts/src/events/inputChanged.ts

This file was deleted.

2 changes: 2 additions & 0 deletions srcts/src/events/jQueryEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ declare global {
): this;
}
}

export type { EvtFn };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming this stands for "Event Function"? If so Is there a reason to keep it so abbreviated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think mostly because it was reused often in this file and because evt is used as the convention for "jQuery event" elsewhere in the codebase.

227 changes: 221 additions & 6 deletions srcts/src/events/shinyEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,250 @@ import type { InputBinding } from "../bindings/input/inputBinding";
import type { OutputBindingAdapter } from "../bindings/outputAdapter";
import type { EventPriority } from "../inputPolicies/inputPolicy";
import type { ErrorsMessageValue } from "../shiny/shinyapp";
import type { EvtFn } from "./jQueryEvents";
import $ from "jquery";

// This class implements a common interface for all Shiny events, and provides a
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// This class implements a common interface for all Shiny events, and provides a
/**
* This class implements a common interface for all Shiny events and provides a
* layer of abstraction between the Shiny event and the underlying jQuery event
* object. We use a new class, rather than extending JQuery.Event, because
* jQuery.Event is an old function-style class. Each Event class has a
* corresponding ShinyEvent interface that describes the event object that is
* emitted. At the end of this file, we extend JQuery's `on()` method to
* associate the ShinyEvent interfaces with their corresponding event string.
*/
class EventBase {
event: JQuery.Event;
/**
* Constructor for the EventBase class.
*
* @param {string} type The event type.
*/
constructor(type: string) {
this.event = $.Event(type);
}
/**
* Triggers the event on the specified element or the document.
*
* @param {HTMLElement | JQuery<HTMLElement> | typeof document | null} el The element to trigger the event on, or `null` for the document.
*/
triggerOn(
el: HTMLElement | JQuery<HTMLElement> | typeof document | null
): void {
$(el || window.document).trigger(this.event);
}
/**
* Checks if the default action of the event has been prevented.
*
* @returns {boolean} `true` if the default action has been prevented, `false` otherwise.
*/
isDefaultPrevented(): boolean {
return this.event.isDefaultPrevented();
}
}

Using JSDoc syntax so docs are given on mouseover using intellisense

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc is nice; it's a shame it doesn't pick up type definitions though. I'll look into adding JSDoc comments in the code I'm editing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 10c2398

// layer of abstraction between the Shiny event and the underlying jQuery event
// object. We use a new class, rather than extending JQuery.Event, because
// jQuery.Event is an old function-style class. Each Event class has a
// corresponding ShinyEvent interface that describes the event object that is
// emitted. At the end of this file, we extend JQuery's `on()` method to
// associate the ShinyEvent interfaces with their corresponding event string.
class EventBase {
event: JQuery.Event;

constructor(type: string) {
this.event = $.Event(type);
}

triggerOn(
el: HTMLElement | JQuery<HTMLElement> | typeof document | null
): void {
$(el || window.document).trigger(this.event);
}

isDefaultPrevented(): boolean {
return this.event.isDefaultPrevented();
}
}

interface ShinyEventCommon extends JQuery.Event {
name: string;
value: unknown;
el: HTMLElement | null;
value: any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why any here? Typically unknown is safer.

Copy link
Collaborator

@schloerke schloerke May 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tl/dr; My current understanding is to use any as we can not change the type of unknown to another value in a sub class.

It comes from https://en.wikipedia.org/wiki/SOLID's The Liskov substitution principle:

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

I broke this rule all over the place in the original conversion within rstudio/shiny. (This rule alone warrants a rewrite of most of the unknown types to any or Generic types.)

For example: Let say ShinyEventCommon has value: unknown. Let's have NickEventInputChanged extend ShinyEventCommon. Even if you know NickEventInputChanged's value has type NickValue, the value must satisfy it's parent's class value type of unknown. This forces NickEventInputChanged to then have a value: unknown. If ShinyEventCommon had value: any, then NickEventInputChanged can have value: customType as any is more generic than customType.


But after writing this all out, I am thinking of any vs never. The never type can not be recovered. The unknown type requires some type checking but can be eventually be used (it just requires that you type check it).

I think it'd be good to have a quick discussion at the end of standup today.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this all go away if we used a generic type on value where T extends any and value: T. Then we don't need to worry what the type as it is always defined by the Generic type.

}

class EventCommon extends EventBase {
declare event: ShinyEventCommon;

constructor(
type: ShinyEventCommon["type"],
name: ShinyEventCommon["name"],
value: ShinyEventCommon["value"]
) {
super(type);
this.event.name = name;
this.event.value = value;
}

get name(): ShinyEventCommon["name"] {
return this.event.name;
}

get value(): ShinyEventCommon["value"] {
return this.event.value;
}
}

interface ShinyEventInputChanged extends ShinyEventCommon {
value: unknown;
el: HTMLElement | null;
binding: InputBinding | null;
inputType: string;
priority: EventPriority;
priority?: EventPriority;
}

class EventInputChanged extends EventCommon {
declare event: ShinyEventInputChanged;

constructor({
name,
value,
el,
binding,
inputType,
priority,
}: {
name: ShinyEventInputChanged["name"];
value: ShinyEventInputChanged["value"];
el: ShinyEventInputChanged["el"];
binding: ShinyEventInputChanged["binding"];
inputType: ShinyEventInputChanged["inputType"];
priority?: ShinyEventInputChanged["priority"];
}) {
super("shiny:inputchanged", name, value);
this.event.el = el;
this.event.binding = binding;
this.event.inputType = inputType;
if (priority) {
this.event.priority = priority;
}
}

get el(): ShinyEventInputChanged["el"] {
return this.event.el;
}

get binding(): ShinyEventInputChanged["binding"] {
return this.event.binding;
}

get inputType(): ShinyEventInputChanged["inputType"] {
return this.event.inputType;
}

get priority(): ShinyEventInputChanged["priority"] {
return this.event.priority;
}
}

interface ShinyEventUpdateInput extends ShinyEventCommon {
message: unknown;
message?: any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here about any.

binding: InputBinding;
}

class EventUpdateInput extends EventBase {
declare event: ShinyEventUpdateInput;

constructor({
message,
binding,
}: {
message?: ShinyEventUpdateInput["message"];
binding: ShinyEventUpdateInput["binding"];
}) {
super("shiny:updateinput");
if (message) {
this.event.message = message;
}
this.event.binding = binding;
}

get message(): ShinyEventUpdateInput["message"] {
return this.event.message;
}

get binding(): ShinyEventUpdateInput["binding"] {
return this.event.binding;
}
}

interface ShinyEventValue extends ShinyEventCommon {
value: unknown;
binding: OutputBindingAdapter;
}

class EventValue extends EventCommon {
declare event: ShinyEventValue;

constructor({
name,
value,
binding,
}: {
name: ShinyEventValue["name"];
value: ShinyEventValue["value"];
binding: ShinyEventValue["binding"];
}) {
super("shiny:value", name, value);
this.event.binding = binding;
}

get binding(): ShinyEventValue["binding"] {
return this.event.binding;
}
}

interface ShinyEventError extends ShinyEventCommon {
binding: OutputBindingAdapter;
error: ErrorsMessageValue;
}

class EventError extends EventCommon {
declare event: ShinyEventError;

constructor({
name,
binding,
error,
}: {
name: ShinyEventError["name"];
binding: ShinyEventError["binding"];
error: ShinyEventError["error"];
}) {
super("shiny:error", name, null);
this.event.binding = binding;
this.event.error = error;
}

get binding(): ShinyEventError["binding"] {
return this.event.binding;
}

get error(): ShinyEventError["error"] {
return this.event.error;
}
}

interface ShinyEventMessage extends JQuery.Event {
message: { [key: string]: unknown };
}

class EventMessage extends EventBase {
declare event: ShinyEventMessage;

constructor(message: ShinyEventMessage["message"]) {
super("shiny:message");
this.event.message = message;
}

get message(): ShinyEventMessage["message"] {
return this.event.message;
}
}

// Augment the JQuery interface ----------------------------------------------
// This allows extensions to use .on() in Typescript with Shiny's custom events.
// E.g. in {bslib}, we can use the following with complete type information:
//
// ```
// $(document).on("shiny:value", function(event: ShinyEventValue) { })
// ```
declare global {
interface JQuery {
on(
events: "shiny:inputchanged",
handler: EvtFn<ShinyEventInputChanged>
): this;

on(
events: "shiny:updateinput",
handler: EvtFn<ShinyEventUpdateInput>
): this;

on(events: "shiny:value", handler: EvtFn<ShinyEventValue>): this;
on(events: "shiny:error", handler: EvtFn<ShinyEventError>): this;
on(events: "shiny:message", handler: EvtFn<ShinyEventMessage>): this;
}
}

export {
EventCommon,
EventInputChanged,
EventUpdateInput,
EventValue,
EventError,
EventMessage,
};

export type {
ShinyEventInputChanged,
ShinyEventUpdateInput,
Expand Down
20 changes: 10 additions & 10 deletions srcts/src/file/fileProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import $ from "jquery";
import { triggerFileInputChanged } from "../events/inputChanged";
import { $escape } from "../utils";
import type { ShinyApp } from "../shiny/shinyapp";
import { getFileInputBinding } from "../shiny/initedMethods";
import { EventInputChanged } from "../events/shinyEvents";

type JobId = string;
type UploadUrl = string;
Expand Down Expand Up @@ -227,14 +227,14 @@ class FileUploader extends FileProcessor {
// Trigger shiny:inputchanged. Unlike a normal shiny:inputchanged event,
// it's not possible to modify the information before the values get
// sent to the server.
const evt = triggerFileInputChanged(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is much nicer

this.id,
fileInfo,
getFileInputBinding(),
this.el,
"shiny.fileupload",
document
);
const inputChangedEvent = new EventInputChanged({
name: this.id,
value: fileInfo,
el: this.el,
binding: getFileInputBinding(),
inputType: "shiny.fileupload",
});
inputChangedEvent.triggerOn(document);

this.makeRequest(
"uploadEnd",
Expand All @@ -245,7 +245,7 @@ class FileUploader extends FileProcessor {
this.$bar().text("Upload complete");
// Reset the file input's value to "". This allows the same file to be
// uploaded again. https://stackoverflow.com/a/22521275
$(evt.el as HTMLElement).val("");
$(inputChangedEvent.el as HTMLElement).val("");
},
(error) => {
this.onError(error);
Expand Down
Loading