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

feat: adds the ability to render an encapsulated Unified UI Shell #2373

Merged
merged 38 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
38eb873
feat: adds the ability to render the Unified UI Shell using a simple …
KevinGhadyani-Okta Sep 27, 2024
0ba1a51
feat: broke out Okta UI Shell in pieces and wrote tests for shadow-dom
KevinGhadyani-Okta Oct 14, 2024
78f705d
fix: minor fix to remove eslint-disable in vite.config.ts
KevinGhadyani-Okta Oct 15, 2024
a4d219e
build: switched to using happy-dom instead of JSDOM
KevinGhadyani-Okta Oct 15, 2024
34fa26f
fix: fixed OktaLogo errors because SVG code was in HTML format, not R…
KevinGhadyani-Okta Oct 15, 2024
e0c8789
feat: adds style encapsulation to renderReactInWebComponent
KevinGhadyani-Okta Oct 15, 2024
927a03e
fix: adds unit tests to web component rendering
KevinGhadyani-Okta Oct 15, 2024
920b76b
fix: adds tests for OktaUiShell
KevinGhadyani-Okta Oct 15, 2024
c91a40c
feat: adds packages/ui-shell and configures the bundle output
KevinGhadyani-Okta Oct 15, 2024
3134a62
build: adds ui-shell to publish-packages.sh
KevinGhadyani-Okta Oct 15, 2024
b9e8639
fix: issues with IntersectionObserver
KevinGhadyani-Okta Oct 15, 2024
d899c0d
build: removes requirement for process.env.NODE_ENV
KevinGhadyani-Okta Oct 15, 2024
2f409eb
feat: complete reworking of OktaUiShell with an improved UI and messa…
KevinGhadyani-Okta Oct 16, 2024
a3b7011
fix: adds missing test for return value of renderReactInWebComponent
KevinGhadyani-Okta Oct 16, 2024
256b598
feat: adds error boundaries to OktaUiShell
KevinGhadyani-Okta Oct 17, 2024
e3658a6
fix: skips broken unit test because of happy-dom
KevinGhadyani-Okta Oct 17, 2024
da51606
feat: adds the rest of the customizable slots to OktaUiShell
KevinGhadyani-Okta Oct 17, 2024
9ecaad6
Merge remote-tracking branch 'origin/main' into kg-unified-ui-wrapper…
KevinGhadyani-Okta Oct 17, 2024
d5f7bd3
feat: adds renderReactInWebComponent export
KevinGhadyani-Okta Oct 17, 2024
87d77ef
fix: moves around a bunch of files to separate ui-shell from Odyssey
KevinGhadyani-Okta Oct 18, 2024
46ae2d3
fix: fixes typecheck and testing errors
KevinGhadyani-Okta Oct 18, 2024
f76374e
feat: added ability for OktaUiShell subscriber to take a function wit…
KevinGhadyani-Okta Oct 21, 2024
c6b5f85
feat: removes the need for captureConsoleError
KevinGhadyani-Okta Oct 21, 2024
348ea67
fix: restores previous console.error
KevinGhadyani-Okta Oct 21, 2024
ac48ca5
fix: renames OktaUiShell to UiShell
KevinGhadyani-Okta Oct 21, 2024
55328e3
fix: renamed some variables
KevinGhadyani-Okta Oct 21, 2024
253ac3b
fix: fixes package.json version for @okta/ui-shell
KevinGhadyani-Okta Oct 21, 2024
f436ab1
fix: ui-shell build changes
KevinGhadyani-Okta Oct 22, 2024
cbe1f8f
build: removes @okta/ui-shell package export
KevinGhadyani-Okta Oct 22, 2024
77a251e
feat: adds appRootElement export to renderUiShell
KevinGhadyani-Okta Oct 22, 2024
24253da
fix: fixes bug where app doesn't have 100% of the available width
KevinGhadyani-Okta Oct 22, 2024
931a5a2
feat: removes createShadowDomElements
KevinGhadyani-Okta Oct 23, 2024
f2a373f
fix: adds JSDocs to all functions
KevinGhadyani-Okta Oct 23, 2024
8bd12c7
fix: re-adds createShadowDomElements which was incorrectly removed in…
KevinGhadyani-Okta Oct 23, 2024
dc18d41
fix: adds deprecation message on createShadowDomElements
KevinGhadyani-Okta Oct 23, 2024
3eda78d
fix: refactors where we create react root elements
KevinGhadyani-Okta Oct 23, 2024
4be8aa6
fix: adds doc suggestions from Ben Schell
KevinGhadyani-Okta Oct 23, 2024
55384ea
fix: minor adjustment to OdysseyCacheProvider props in OdysseyProvider
KevinGhadyani-Okta Oct 23, 2024
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
7 changes: 4 additions & 3 deletions packages/odyssey-eslint-config/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ module.exports = {
"error",
{
devDependencies: [
"**/*.ts",
"**/*.stories.*",
"**/*.docgen.*",
"**/jest.setup.js",
"**/*.stories.*",
"**/*.test.*",
"**/*.ts",
"**/jest.setup.js",
"**/scripts/*",
"**/vite.config.js",
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion packages/odyssey-react-mui/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@

module.exports = {
setupFilesAfterEnv: ["./jest.setup.js"],
testEnvironment: "jsdom",
testEnvironment: "@happy-dom/jest-environment",
};
3 changes: 0 additions & 3 deletions packages/odyssey-react-mui/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,3 @@
import "regenerator-runtime/runtime";
import "@testing-library/jest-dom";
import "jest-axe/extend-expect";

import * as ResizeObserverModule from "resize-observer-polyfill";
global.ResizeObserver = ResizeObserverModule.default;
3 changes: 1 addition & 2 deletions packages/odyssey-react-mui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"devDependencies": {
"@babel/cli": "^7.23.9",
"@babel/core": "^7.23.9",
"@happy-dom/jest-environment": "^15.7.4",
"@okta/browserslist-config-odyssey": "workspace:*",
"@okta/odyssey-babel-preset": "workspace:*",
"@okta/odyssey-icons": "workspace:*",
Expand All @@ -114,12 +115,10 @@
"eslint": "^8.56.0",
"jest": "^29.7.0",
"jest-axe": "^5.0.1",
"jest-environment-jsdom": "^29.7.0",
"properties": "1.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"regenerator-runtime": "^0.14.1",
"resize-observer-polyfill": "^1.5.1",
"rimraf": "^5.0.1",
"stylelint": "^14.13.0",
"tsx": "^4.7.3",
Expand Down
100 changes: 100 additions & 0 deletions packages/odyssey-react-mui/src/OktaUiShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*!
* Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import { render } from "@testing-library/react";

import { Dialog } from "./Dialog";
import {
defaultComponentProps,
OktaUiShell,
OktaUiShellProps,
} from "./OktaUiShell";

describe("OktaUiShell", () => {
test("renders the `appRootElement`", async () => {
const subscribeToPropChanges: OktaUiShellProps["subscribeToPropChanges"] = (
subscription,
) => {
subscription({
...defaultComponentProps,
topNavProps: {
...defaultComponentProps.topNavProps,
SearchFieldComponent: (
<Dialog
children={undefined}
title="Hello World!"
isOpen
onClose={() => {}}
/>
),
},
});

return () => {};
};

const appRootElement = document.createElement("div");

render(
<OktaUiShell
appRootElement={appRootElement}
emotionRootElement={document.createElement("div")}
subscribeToPropChanges={subscribeToPropChanges}
/>,
);

expect(Array.from(appRootElement.children)).toHaveLength(1);
expect(appRootElement).toHaveTextContent("Hello World!");
});

test("renders the `emotionRootElement`", async () => {
const rootElement = document.createElement("div");

// If this isn't appended to the DOM, the React app won't exist because of how Web Components run.
document.body.append(rootElement);

const emotionRootElement = document.createElement("div");

render(
<OktaUiShell
appRootElement={document.createElement("div")}
emotionRootElement={emotionRootElement}
subscribeToPropChanges={() => () => {}}
/>,
);

expect(Array.from(emotionRootElement.children).length).toBeGreaterThan(0);
});

test("Unsubscribes from prop changes when unmounted", async () => {
const rootElement = document.createElement("div");

// If this isn't appended to the DOM, the React app won't exist because of how Web Components run.
document.body.append(rootElement);

const unsubscribeFromPropChanges = jest.fn();
const subscribeToPropChanges = jest.fn(() => unsubscribeFromPropChanges);

const { unmount } = render(
<OktaUiShell
appRootElement={document.createElement("div")}
emotionRootElement={document.createElement("div")}
subscribeToPropChanges={subscribeToPropChanges}
/>,
);

unmount();

expect(subscribeToPropChanges).toHaveBeenCalledTimes(1);
expect(unsubscribeFromPropChanges).toHaveBeenCalledTimes(1);
});
});
94 changes: 94 additions & 0 deletions packages/odyssey-react-mui/src/OktaUiShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*!
* Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import { memo, useEffect, useState, type ReactNode } from "react";

import { SideNav, type SideNavProps } from "./labs/SideNav";
import { TopNav, type TopNavProps } from "./labs/TopNav";
import { OdysseyProvider } from "./OdysseyProvider";
import { ShadowDomElements } from "./shadow-dom";

const containerStyles = {
display: "flex",
};

export type OktaUiShellComponentProps = {
sideNavProps: SideNavProps;
topNavProps: TopNavProps;
};

export type OktaUiShellProps = {
appComponent?: ReactNode;
onSubscriptionCreated: () => void;
subscribeToPropChanges: (
subscription: (componentProps: OktaUiShellComponentProps) => void,
) => () => void;
} & ShadowDomElements;

export const defaultComponentProps: OktaUiShellComponentProps = {
sideNavProps: {
navHeaderText: "",
sideNavItems: [],
},
topNavProps: {
topNavLinkItems: [],
},
};

export const defaultProps = {
appComponent: <slot />,
onSubscriptionCreated: () => {},
};

const OktaUiShell = ({
appComponent = defaultProps.appComponent,
appRootElement,
emotionRootElement,
onSubscriptionCreated = defaultProps.onSubscriptionCreated,
subscribeToPropChanges,
}: OktaUiShellProps) => {
const [componentProps, setComponentProps] = useState(defaultComponentProps);

useEffect(() => {
const unsubscribe = subscribeToPropChanges((componentProps) => {
setComponentProps(componentProps);
});

onSubscriptionCreated();
KevinGhadyani-Okta marked this conversation as resolved.
Show resolved Hide resolved

return () => {
unsubscribe();
};
}, [onSubscriptionCreated, subscribeToPropChanges]);

return (
<OdysseyProvider
emotionRootElement={emotionRootElement}
shadowRootElement={appRootElement}
>
<div style={containerStyles}>
<SideNav {...componentProps.sideNavProps} />

<div>
<TopNav {...componentProps.topNavProps} />

{appComponent}
</div>
</div>
</OdysseyProvider>
);
};

const MemoizedOktaUiShell = memo(OktaUiShell);
MemoizedOktaUiShell.displayName = "OktaUiShell";

export { MemoizedOktaUiShell as OktaUiShell };
79 changes: 79 additions & 0 deletions packages/odyssey-react-mui/src/bufferUntil.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*!
* Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import { bufferUntil } from "./bufferUntil";
import { createMessageBus } from "./createMessageBus";

describe("bufferUntil", () => {
test("calls subscription after ready", async () => {
const { publish: publish1, subscribe: subscribe1 } = createMessageBus();

const { publish: publish2, subscribe: subscribe2 } = createMessageBus();

const subscription = jest.fn();

subscribe1(subscription);

const bufferedPublish1 = bufferUntil({
publish: publish1,
subscribe: subscribe2,
});

publish2();
bufferedPublish1();

expect(subscription).toHaveBeenCalledTimes(1);
});

test("calls subscription before ready", async () => {
const { publish: publish1, subscribe: subscribe1 } = createMessageBus();

const { publish: publish2, subscribe: subscribe2 } = createMessageBus();

const subscription = jest.fn();

subscribe1(subscription);

const bufferedPublish1 = bufferUntil({
publish: publish1,
subscribe: subscribe2,
});

bufferedPublish1();
publish2();

expect(subscription).toHaveBeenCalledTimes(1);
});

test("keeps only the last value passed when ready", async () => {
const { publish: publish1, subscribe: subscribe1 } =
createMessageBus<string>();

const { publish: publish2, subscribe: subscribe2 } = createMessageBus();

const subscription = jest.fn();

subscribe1(subscription);

const bufferedPublish1 = bufferUntil({
publish: publish1,
subscribe: subscribe2,
});

bufferedPublish1("a");
bufferedPublish1("b");
publish2();

expect(subscription).toHaveBeenCalledWith("b");
expect(subscription).toHaveBeenCalledTimes(1);
});
});
49 changes: 49 additions & 0 deletions packages/odyssey-react-mui/src/bufferUntil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*!
* Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import { createStore } from "./createStore";

export const bufferUntil = <Value>({
publish,
subscribe,
}: {
publish: (value: Value) => void;
subscribe: (subscriber: () => void) => () => void;
}) => {
const store = createStore<{
bufferedValue?: Value;
isReactAppReadyForProps: boolean;
}>({
isReactAppReadyForProps: false,
});

const unsubscribe = subscribe(() => {
unsubscribe();

store.setState("isReactAppReadyForProps", true);
store.getState("bufferedValue");
if (store.hasState("bufferedValue")) {
// If we have a state, then the value in here is what we want. TypeScript expects this to possibly be `undefined` because the type we sent to `createStore` listed `bufferedValue` as optional. TypeScript doesn't seem to have any way of knowing we passed in the value at some point.
publish(store.getState("bufferedValue")!);
}
});

const publishWhenReady: typeof publish = (value) => {
if (store.getState("isReactAppReadyForProps")) {
publish(value);
} else {
store.setState("bufferedValue", value);
}
};

return publishWhenReady;
};
Loading