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

UIAPPS-104 Create a UI Extensions react package #94

Merged

Conversation

joneshf-dd
Copy link
Collaborator

@joneshf-dd joneshf-dd commented Jan 13, 2022

Motivation

Changes

There's a lot here, but it's mostly infrastructure changes.

  • We make it so any package that starts with @datadog/ui-extensions- gets locked together with the same version. This took some extra work to make changesets still work the way we want. Hopefully if Add support for fixed packages changesets/changesets#690 merges, it'll mean we can remove a lot of this behavior.
    There's also a patch that we had to do to the @changesets/assemble-release-plan package that we should ask about.
  • We make a react package that provides a hook for using the context. The context is one of those things that can solve a lot of problems, but using it is not that straight-forward. Hopefully by providing a hook that does all the hard work, we can push more people toward using it.
    Also, we should be able to build upon it to make other aspects of UI Extensions easier as well.
  • Finally, we actually use the new package in the examples. This dogfooding should help guide the direction of this new package, while also showing its usefulness.

Testing

  • Mostly, take a look at the examples and see if they make sense.

Releases

Choose one:

  • No release is necessary.
    If you're only updating examples/documentation, this is likely what you want.
  • All packages that need a release have a changeset.
📦 Published PR as canary version: Canary Versions

✨ Test out this PR locally via:

npm install @datadog/[email protected]
npm install @datadog/[email protected]
npm install @datadog/[email protected]
npm install @datadog/[email protected]
# or 
yarn add @datadog/[email protected]
yarn add @datadog/[email protected]
yarn add @datadog/[email protected]
yarn add @datadog/[email protected]

We want all `@datadog/ui-extensions-` packages to have the same version.

This is explained in more detail in the `RELEASE.md` file. The long and
short is that it should be easier on App Developers if they don't have
to manage different sets of versions, and rather can just install one
version for everything.

We add a script that checks that all the `@datadog/ui-extensions-*`
packages have a changeset if any of them do. This script works in tandem
with the change to `.changeset/config.json` where we link all
`@datadog/ui-extensions-*` packages together. It's a bit unfortunate
that we have to roll a script for this, but it's not the end of the
world. If changesets gets this behavior, we can move over to using that
instead of this two-pronged approach.

Now, we can start adding packages.
We want to provide helpers for using the SDK with `react`.

We throw together a single hook for now: `useContext`. It provides the
context along with its state. We also give it a few tests to check that
it behaves the way we expect it to.

As far as the dependencies, we have two peer dependencies: `react`, and
`@datadog/ui-extensions-sdk`. Since both of these packages are being
used by our own package, and we also depend on their APIs, we want to
make sure the consumer of our package have these others installed.

This is a bit different from how dependencies normally work in the `npm`
world (but fairly normal to how dependencies work in other ecosystems).
This pattern tends to show up for similar packages to what we have here:
other `react`-based packages, plugins, or (more generally) anything
where the public API depends on a different package's API.

We also add a changeset for this new package so it'll be released.
We want to start dogfooding this package.

There's two big reasons to dogfood this:
1. It provides examples of how to actually use it.
2. It gives us feedback on whether the juice is worth the squeeze.

The first point is one that shouldn't be understated. For stuff like
this that can be kind of complex, having an example of how it's supposed
to work can be really helpful. There's a time and place for reference
documentation, and there's a time and place for example documentation.

The second point is more of an internal metric. If we look at what is
provided and don't see anything worth maintaining, we can more easily
justify dropping it. If we do see big benefits, we can more easily
justify maintaining or expanding it.

In any case, most of these examples are relatively straight-forward. The
thing that's kind of interesting is that in some cases we don't need to
use `react` hooks to get similar behavior anymore. We dropped a bunch of
`useEffect`s and even got rid of some `useState`s.

Hopefully as we expand on this `react` package, we can continue getting
this sorts of benefits.
We don't want to force a major bump unnecessarily.

For our use case, we don't want the packages to bump a major version. We
should ask upstream what the situation is and if there's a way to get
the behavior we want.
client.getContext().then(c => {
setMetric(c.widget?.definition.options?.metric);
});
if (result.type === 'initialized') {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

❓ It kind of feels like we should take this opportunity to show some decent patterns for dealing with the other two states: initializing, and handshake failure. I didn't want to make too big of a change in case that wasn't wanted. What do y'all think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

The funny thing is that when the frame is in loading or error state, the iframe is invisible so it almost doesn't matter what the app does, short of blowing up. The right pattern is honestly probably just

if result.type === 'initialized' {
  return null
}

// skip all other logic

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh right! Makes me wish we didn't have to do something about those other states and could just return the context only.

Maybe what would be more useful for actually rendering a component is not the hook for context, but a component that passes down the context once it's ready and only renders once it has a context. So you'd do something like:

import { WithContext } from '@datadog/ui-extensions-react';
import { Context, init } from '@datadog/ui-extensions-sdk';

type WidgetProps = {
    context: Context;
};

function Widget(props: WidgetProps) {
    // Work with the actual `Context` in `props.context`.
    
}

export default function render() {
    ReactDOM.render(
        <React.StrictMode>
            <WithContext client={client}>
                <Widget />
            </WithContext>
        </React.StrictMode>,
        document.getElementById('root')
    );
}

Or something, general idea being that the hook isn't as helpful as it could be since it's still too low-level.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Or maybe, we just don't deal with the error at all and the return type of useContext becomes Context | undefined?

Comment on lines +40 to +41
"@datadog/ui-extensions-sdk": "0.25.0",
"react": "^17.0.2"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These are peer dependencies because we depend on their APIs for this package's own APIs.

Comment on lines +12 to +15
filename: 'ui-extensions-react.min.js',
path: path.resolve(__dirname, 'dist'),
library: 'DD_REACT',
libraryTarget: 'umd'
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was mostly lifted from the SDK config. Does the global (DD_REACT) make sense here?

Comment on lines 9 to 12
+ // Don't force a major bump if the dependent is a peer deependency and it's an exact version.
+ if (depType === "peerDependencies" && versionRange === nextRelease.oldVersion) {
+ return false;
+ }
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Just noting that this is explained more fully in the patches/README.md file.

This was a copy/paste from the SDK package.
We don't want to include these dependencies in our bundle.

The purpose of this package is to provide additional help atop these
other two packages. We don't want to also bring them in with us for a
few reasons:
1. The `@datadog/ui-extensions-react` package has these as peer
   dependencies.
    If we're bundling our peer dependencies, it defeats the purpose.
2. If we're bundling the `@datadog/ui-extensions-sdk`, we can run into
   issues.
   E.g. the `init` function will not work correctly if someone has both
   `@datadog/ui-extensions-react` and `@datadog/ui-extensions-sdk`
   installed and uses them both.

We tell `webpack` that these are external dependencies and how to load
them (by their same package name).
We had an infinite loop before.

The previous code was a mistake, we accidentally were calling
`setMetric` in a way that forced us to continue calling `setMetric`.

To alleviate that, we move the initial metric value up above the call to
`useState`, and use it there.

Likely, we should take this opportunity to provide better handling of
the different states the context can be in. If we're still initializing,
we can provide a different interface that if the handshake failed, and
the normal interface when things are initialized.
This should slot it in with the other docs.
We make a quick hack to get things working again with `yarn workspaces`.

The issue with the `yarn workspaces` command is that it takes whatever
it finds in the `"workspaces"` field and uses that in the order it's
written. It does not look at any of the dependencies of the packages
referenced and sort them topologically.

Because we finally added a package that doesn't happen to have the same
ordering lexicographically as it does topologically, all of the
`yarn workspaces` command were failing.

We first tried to use `lerna` for its topological sorting, but it both
only looks at `dependencies` (not `peerDependencies` or even
`devDependencies`) and also was ignoring all of the `examples` packages.

We considered bringing in another tool to help with this, but found out
that the `yarn workspaces` command will respect the ordering of the
`"workspaces"` field in the manifest. We do a semi-topological sort on
the packages, and let `yarn workspaces` take care of the rest.

We should circle back to this at some point, but this should be enough
to get us unblocked from being completely broken.
We want people to have an easier time getting started.
RELEASE.md Outdated
Comment on lines 27 to 32
The alternative would be to allow different versions for each package.
That allows more flexibility and lower version churn at the expense of confusion with version updates.
A similar example to the above might be worded like,
"Check out this wonderful feature in 0.36.0 of `@datadog/ui-extensions-sdk`, 2.3.0 of `@datadog/ui-extensions-react`, 1.46.0 of `@datadog/ui-extensions-vue`, etc."
While that's a model that many other groups of packages follow,
we're opting for keeping all the versions the same.
Copy link
Collaborator

Choose a reason for hiding this comment

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

🔊

@@ -5,6 +5,6 @@
"changelog": "@changesets/cli/changelog",
"commit": false,
"ignore": ["datadog-app-example-*"],
"linked": [],
"linked": [["@datadog/ui-extensions-*"]],
Copy link
Collaborator

Choose a reason for hiding this comment

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

cool!

Comment on lines +7 to +30
type Context =
| ContextHandshakeFailure
| ContextInitialized
| ContextInitializing;

/**
* There was some kind of failure with the handshake.
* There's no useful {@link uiExtensionsSDK.Context}, but there is an error.
*/
type ContextHandshakeFailure = { type: 'handshake failure'; error: unknown };

/**
* The handshake is still being preformed.
* There's no useful {@link uiExtensionsSDK.Context} yet.
*/
type ContextInitializing = { type: 'initializing' };

/**
* The handshake has succeeded and there is a {@link uiExtensionsSDK.Context} that can be used.
*/
type ContextInitialized = {
type: 'initialized';
context: uiExtensionsSDK.Context;
};
Copy link
Collaborator

@nmuldavin nmuldavin Jan 14, 2022

Choose a reason for hiding this comment

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

I have an alternative schema proposal: Many react developers are familiar with hook patterns for async operations that look like what you end up using from useQuery:

const { data, isLoading, error } = `useQuery(...)`;

Rather than using a discriminated union, we could try to mimix that pattern:

interface useContextResult {
  isLoading: boolean;
  context: uiExtensionsSDK.Context | null;
  error: Error | null
}

const { isLoading, error, context } = useContext(client);

What do you think? Of course it's the same information in the end, just attempting to follow existing patterns more closely

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tl;dr; I'm okay with whatever here. I'll update this next week unless the longer response compels you otherwise. You don't have to read the rest of this, but can if you'd like.


I have a more thoughts about this, but this response ended up being long enough as it is. If you'd like me to go into even more detail lemme know.

Whatever the API of this ends up being, there's maybe a more important point to make. I don't see this particular hook being that useful for App developers in their day-to-day work, but very useful for IDX. There's at least two reasons why I say this:

  1. The SDK Context mostly has optional data, and trying to drill down on that seems harder than it needs to be.
  2. I think most App developers would rather use something else aside from Context.

The second point I think is where we're going to get the most benefit out of having a package like this. I was playing around with a useCustomWidgetOptions hook that built on top of this useContext hook. It seemed to remove the majority of the explicit references to the Context. I feel like it'd be similar for most other hooks we'd build: useTimeframe, useColorTheme, etc. All of these have decent defaults and can plaster over the different states of useContext no matter what its API looks like.

Context gives a lot of data, and it can be useful to solve a lot of problems we run into. For sure we should make it available to App developers can unblock themselves if we don't provide an easy thing to use. But, it feels more like a low-level primitive than something we expect people to work with in day-to-day. And we should try to capture those patterns that people do instead. I didn't want to add that to the documentation because there's nothing else to point people to at the moment.

To that end, I feel like we should optimize correctness over familiarity. In this situation, familiarity makes it harder to know you're doing the right thing when using useContext. There's eight different states to deal with:

isLoading context error
false null null
false null Error
false Context null
false Context Error
true null null
true null Error
true Context null
true Context Error

Whereas with the discriminated union there's only three actual states:

isLoading context error Actual states
false null null "initializing"
false null Error
false Context null
false Context Error
true null null
true null Error "handshake failure"
true Context null "initialized"
true Context Error

For something that seems like it'll end up being more internal than external, it seems like we should optimize for simplifying on our end.

Of course, if my read on useContext and the SDK Context in general is off, then yeah we should optimize for App developer familiarity.

Copy link
Collaborator

Choose a reason for hiding this comment

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

A summary of in-person discussions:

  1. I like the pattern of this hook returning Context | null to start with
  2. If we need extra hooks for handling handshake errors on the developer's end, we can do so later and explicitly
  3. I like that we have a data model with shape, it makes stuff discoverable. The data model will come to be more and more complex over time. Ideally there is a way to both have a general and explorable data model and specificity for common usecases in a way that feels layered rather than disjoint. One idea discussed was a context selector pattern: useContextSelector(getDashboardTimeframe).
  4. I like the idea of automatically calling init inside this hook or another wrapper. The one usecase it won't cover is defining an auth provider, but this is generally done outside react at present. Later we may move to a setProvider() model anyways

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I tried to do the fourth thing on the list, calling init within the hook. But I can't get the tests to pass in that case. Leads me to one of two ideas: either we need to mock things differently (most likely), or this might be a bug in jest that's fixed in a newer version (less likely).

In either case, I'm going to punt on it until we can add some tests for that behavior that pass.

);

return () => {
// We flip the flag to let any in-flight promises know we're unmounted.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Good catch, I probably would have forgotten to do this

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Credit goes to the testing package. It pointed out so many failures and misunderstandings while trying to build this hook. It's way more complex than I'd thought!

`useState` doesn't work the way it seems!

This seems to always bite my (joneshf-dd) whenever I use `useState`. It
always seems like you can pass in a value and it'll work properly, but
it really doesn't.

The value that's passed in is only used on initial render and never
again. So in this case, we were never setting the metric once the
context was initialized.

We move the setting into the `useEffect` hook so it actually works
properly.
Missed this examples when doing the initial pass.

These are again showing the benefit to having this package. We don't
have to place a huge burden on App developers to understand how to work
with the `Context`. Instead, the hook can take care of most of the
intricacies and then using it is fairly straight-forward.
We don't want App developers to worry about the other two states.

Through discussing the previous API of `useContext`, we decided to
return an optional `Context`. The main factor in this is that when the
App is actually used in the Datadog platform, it'll never actually be
rendered unless the `Context` exists.

If there's not a `Context` available yet (because the handshake hasn't
finished), we don't render the App at all. If there's a failure in the
handshake, we also don't render the App at all. From the perspective of
the App developer, it's a bit of a mis-step to attempt to render
something if there's no `Context`. Rather than supporting that pattern,
we combine all the non-existent states together into `undefined`.

There's still more we can do here, like not rendering the component at
all if it doesn't have a context. But, we hold off on any of that until
later.
@nmuldavin
Copy link
Collaborator

Maybe the react api patterns being debated here can be an engineering sync topic?

Copy link
Collaborator

@nmuldavin nmuldavin left a comment

Choose a reason for hiding this comment

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

I think this is ready to merge, press the button! I'm really excited about this getting out, i will use it!

We should poll the team about whether they would prefer a useContext hook or a component wrapper, but my take is that having both is a good thing depending on user preference, so this work is valuable anyways

@joneshf-dd
Copy link
Collaborator Author

Thanks for the multiple reviews and helping figure out things!

@joneshf-dd joneshf-dd merged commit ff9dd5e into master Jan 25, 2022
@joneshf-dd joneshf-dd deleted the hardy.jones/uiapps-104/create-react-package-for-ui-extensions branch January 25, 2022 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants