Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
6208a59
Create Store.ts
kasper573 Mar 1, 2023
ab6aded
Submit DeepReadonly.ts
kasper573 Mar 1, 2023
ac29152
Stub ModelStore
kasper573 Mar 1, 2023
4bff28d
Model instantiation
kasper573 Mar 1, 2023
c73b25d
instance destruction
kasper573 Mar 1, 2023
a688e14
Improved type inference
kasper573 Mar 1, 2023
3d4755d
Rename to InstanceStore
kasper573 Mar 1, 2023
e715fa1
Refactor
kasper573 Mar 1, 2023
a818dc0
Composition over inheritance
kasper573 Mar 1, 2023
1fd347a
Use cypress instead of jest for component testing
kasper573 Mar 2, 2023
7369d3f
Implement mount and trigger
kasper573 Mar 3, 2023
66159fe
Implement rest of the basic test cases
kasper573 Mar 3, 2023
69e4812
Multi instance
kasper573 Mar 3, 2023
f0db180
Refactor
kasper573 Mar 3, 2023
774730d
No default outlet renderer
kasper573 Mar 3, 2023
4f6cf42
Refactor
kasper573 Mar 3, 2023
63df8b9
Test instance order
kasper573 Mar 3, 2023
966a2bf
Option to auto remove
kasper573 Mar 3, 2023
f19a162
Make imperative injectable
kasper573 Mar 3, 2023
6be0b7c
test opt out of auto removing instances
kasper573 Mar 3, 2023
0639db2
Test manual remove
kasper573 Mar 3, 2023
0cdda96
Refactor
kasper573 Mar 3, 2023
16562cb
Refactor
kasper573 Mar 3, 2023
ea06b82
Rest manual remove for one of many instances
kasper573 Mar 3, 2023
1402415
Refactor
kasper573 Mar 3, 2023
33004f5
Create test category for auto removal of instances
kasper573 Mar 3, 2023
002e2ba
Refactor
kasper573 Mar 3, 2023
ad2baa2
Add test case for reject while opted out of auto remove instance
kasper573 Mar 3, 2023
1ee5106
More test categories
kasper573 Mar 3, 2023
9b63345
Reorder
kasper573 Mar 3, 2023
e407d9f
Test auto remove of components
kasper573 Mar 3, 2023
8b9163c
Test auto remove for one of many components
kasper573 Mar 3, 2023
458b08c
Test opting out of auto removal of components
kasper573 Mar 3, 2023
0807231
Remove unused
kasper573 Mar 3, 2023
8f77f4b
Rearrange tests
kasper573 Mar 3, 2023
67f227c
Remove auto remove components option
kasper573 Mar 3, 2023
e859ca4
Persisted instances
kasper573 Mar 3, 2023
72b929b
Refactor
kasper573 Mar 3, 2023
7e2deb4
Rearrange tests
kasper573 Mar 3, 2023
4eae4a3
Refactor
kasper573 Mar 3, 2023
88ea105
Nested mutations
kasper573 Mar 3, 2023
1235c4d
Make default store configurable
kasper573 Mar 3, 2023
dac7774
Transaction
kasper573 Mar 3, 2023
533bbb6
Test memory cleanup of components
kasper573 Mar 3, 2023
db8be54
Refactor
kasper573 Mar 3, 2023
bed171e
Test default props
kasper573 Mar 3, 2023
1038f67
Test own and merged props
kasper573 Mar 3, 2023
42cfaf5
Test changed component and default props
kasper573 Mar 3, 2023
233d0f4
Use createImperative to implement useModal
kasper573 Mar 4, 2023
2343327
Refactor
kasper573 Mar 4, 2023
5eb5080
Remove input feature (props solves it already)
kasper573 Mar 4, 2023
cf4ac97
Stop using input feature
kasper573 Mar 4, 2023
2ed68c1
Remove auto remove instance option and prepare for delay promise repl…
kasper573 Mar 4, 2023
4fa7086
Only use resolve
kasper573 Mar 4, 2023
a209863
Test delay promise and rearrange tests
kasper573 Mar 4, 2023
ca70f83
Refactor
kasper573 Mar 4, 2023
485aa34
Remove unused
kasper573 Mar 4, 2023
6fff872
Wording
kasper573 Mar 4, 2023
e175011
Refactor
kasper573 Mar 4, 2023
b09fa0f
Type improvements
kasper573 Mar 5, 2023
bdd7e27
More type improvements
kasper573 Mar 5, 2023
93ccac9
Update modal types across project to match new api
kasper573 Mar 5, 2023
7ee9b8d
Remove unused import
kasper573 Mar 5, 2023
2bb635b
Change back to node test env
kasper573 Mar 5, 2023
f1e3ccb
Revert to cypress 11
kasper573 Mar 5, 2023
a5b7d7b
Improve type inference
kasper573 Mar 5, 2023
825efb8
Fix webstorm specific ts error
kasper573 Mar 5, 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
327 changes: 327 additions & 0 deletions cypress/component/useImperativeComponent.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
/* eslint-disable react/prop-types,react/display-name */
import type { ComponentProps, ComponentType, ReactNode } from "react";
import { createElement, useState } from "react";

import { createImperative } from "../../src/lib/use-imperative-component/useImperativeComponent";
import { createNamedFunctions } from "../../src/lib/namedFunctions";
import { ComponentStore } from "../../src/lib/use-imperative-component/ComponentStore";
import type {
OutletRenderer,
ImperativeComponentProps,
} from "../../src/lib/use-imperative-component/types";

describe("useImperativeComponent", () => {
let App: ReturnType<typeof createTestApp>;
beforeEach(() => {
App = createTestApp();
});

it("mount does not create instance", () => {
cy.mount(<App />);
$.dialog().should("not.exist");
});

it("trigger creates instance", () => {
cy.mount(<App />);
$.trigger().click();
$.dialog().should("exist");
});

it("instance component can be changed", () => {
const imp = createImperative(ImperativeOutlet);
cy.mount(<App />);
cy.findByText("trigger").click();
cy.findByText("Component1").should("exist");
cy.findByText("Component2").should("not.exist");

cy.findByText("change").click();
cy.findByText("Component1").should("not.exist");
cy.findByText("Component2").should("exist");

function App() {
const [component, setComponent] = useState(() => Component1);
const trigger = imp.useComponent(component);
return (
<>
<button onClick={() => trigger()}>trigger</button>
<button onClick={() => setComponent(() => Component2)}>change</button>
<imp.Outlet />
</>
);
}

function Component1() {
return <div>Component1</div>;
}
function Component2() {
return <div>Component2</div>;
}
});

it("can resolve value", () => {
cy.mount(<App />);
$.trigger().click();
$.dialog().within(() => {
$.response().type("value");
$.resolve().click();
});
$.result().should("have.text", "value");
});

it("can have multiple instances", () => {
cy.mount(<App />);
$.trigger().click();
$.trigger().click();
$.dialog().should("have.length", 2);
});

it("instances are rendered in the order they are created", () => {
let count = 0;
cy.mount(<App props={() => ({ name: `${count++}` })} />);
$.trigger().click();
$.trigger().click();
$.dialog().eq(0).should("have.attr", "aria-label", "0");
$.dialog().eq(1).should("have.attr", "aria-label", "1");
});

describe("properties", () => {
it("instance can use default props", () => {
cy.mount(<App defaultProps={{ prop: "default" }} />);
$.trigger().click();
$.prop().should("have.text", "default");
});

it("instance can receive changed default props", () => {
cy.mount(<AppWithChanges />);
$.trigger().click();
cy.findByText("change").click();
$.prop().should("have.text", "changed");

function AppWithChanges() {
const [prop, setProp] = useState("default");
return (
<>
<App defaultProps={{ prop }} />
<button onClick={() => setProp("changed")}>change</button>
</>
);
}
});

it("instance can use own props", () => {
cy.mount(<App props={() => ({ prop: "own" })} />);
$.trigger().click();
$.prop().should("have.text", "own");
});

it("instance own props override default props", () => {
cy.mount(
<App
props={() => ({ prop: "own" })}
defaultProps={{ prop: "default" }}
/>
);
$.trigger().click();
$.prop().should("have.text", "own");
});

it("multiple instances can have separate props", () => {
let count = 0;
cy.mount(<App props={() => ({ name: `${count++}` })} />);
$.trigger().click();
$.trigger().click();
$.dialog("0").should("exist");
$.dialog("1").should("exist");
});
});

describe("lifecycles", () => {
it("unmounting a component with pending instances does not remove its instances", () => {
cy.mount(<App />);
$.trigger().click();
$.trigger().click();
$.trigger().click();
$.unmount().click();
$.dialog().should("have.length", 3);
});

it("removes unmounted component from state once the final related instance is removed", () => {
const imp = createImperative(ImperativeOutlet);
const store = new ComponentStore();
App = createTestApp(imp);

cy.mount(
<imp.Context.Provider value={store}>
<App />
</imp.Context.Provider>
);
$.trigger().click();
$.trigger().click();

$.unmount().click();
$.dialog()
.should("have.length", 2)
.then(() => expectComponentCount(1));

$.resolve().first().click();
$.dialog()
.should("have.length", 1)
.then(() => expectComponentCount(1));

$.resolve().first().click();
$.dialog()
.should("have.length", 0)
.then(() => expectComponentCount(0));

function expectComponentCount(n: number) {
expect(Object.keys(store.state).length).to.equal(n);
}
});

it("resolving a lone instance removes it", () => {
cy.mount(<App />);
$.trigger().click();
$.resolve().click();
$.dialog().should("not.exist");
});

it("resolving one of many instances removes the right instance", () => {
let count = 0;
cy.mount(<App props={() => ({ name: `${count++}` })} />);
$.trigger().click();
$.trigger().click();
$.dialog("0").within(() => $.resolve().click());
$.dialog("1").should("exist");
});

it("can delay removal when resolving", async () => {
let resolveDelay = () => {};
const delayPromise = new Promise<void>((r) => (resolveDelay = r));
cy.mount(<App defaultProps={{ delayPromise }} />);

// Open and resolve dialog immediately
$.trigger().click();
$.resolve().click();

// Confirm that the dialog is still present after resolve
cy.wait(100);
$.dialog()
.should("exist")

// End the delay and confirm that it removes the dialog
.then(resolveDelay)
.then(() => $.dialog().should("not.exist"));
});
});
});

function createTestApp(
{ Outlet, useComponent } = createImperative(ImperativeOutlet)
) {
return function App({
children,
...props
}: ComponentProps<typeof Mount> & {
children?: (renderMount: ComponentType) => ReactNode;
}) {
const renderMount = () => <Mount {...props} />;
return (
<>
{children !== undefined ? children(renderMount) : renderMount()}
<Outlet />
</>
);
};

function Mount(props: ComponentProps<typeof HookConsumer>) {
const [isMounted, setMounted] = useState(true);
return (
<>
{isMounted && <HookConsumer {...props} />}
<button onClick={() => setMounted(false)}>{$.unmount.name}</button>
</>
);
}

function HookConsumer<T extends string>({
props,
defaultProps,
}: {
props?: () => DialogProps;
defaultProps?: DialogProps;
}) {
const [result, setResult] = useState<T>();
// prettier-ignore
const trigger = useComponent((Dialog<T>), defaultProps);
return (
<>
{result && <div data-testid={$.result.name}>{String(result)}</div>}
<button onClick={() => trigger(props?.()).then(setResult)}>
trigger
</button>
</>
);
}
}

interface DialogProps {
prop?: string;
name?: string;
delayPromise?: Promise<void>;
}

function Dialog<T extends string>({
resolve,
name,
prop,
delayPromise,
}: DialogProps & ImperativeComponentProps<T>) {
const [response, setResponse] = useState("");
return (
<div role="dialog" aria-label={name}>
<input
aria-label={$.response.name}
value={response}
onChange={(e) => setResponse(e.target.value)}
/>
<div data-testid={$.prop.name}>{prop}</div>
<button onClick={() => resolve(response as T, delayPromise)}>
{$.resolve.name}
</button>
</div>
);
}

function ImperativeOutlet(state: ComponentProps<OutletRenderer>) {
return (
<>
{Object.entries(state).flatMap(
([componentId, { instances, component, defaultProps }]) =>
Object.entries(instances).map(
([instanceId, { props, state, resolve }]) =>
createElement(component, {
key: `${componentId}-${instanceId}`,
...defaultProps,
...props,
state,
resolve,
})
)
)}
</>
);
}

const $ = createNamedFunctions()
.add("dialog", (role, name?: string) => cy.findAllByRole(role, { name }))
.add("resolve", roleByName("button"))
.add("unmount", roleByName("button"))
.add("response", roleByName("textbox"))
.add("result", cy.findByTestId)
.add("trigger", roleByName("button"))
.add("prop", cy.findByTestId)
.build();

function roleByName<Role extends string>(role: Role) {
return (name: string) => cy.findAllByRole(role, { name });
}
17 changes: 5 additions & 12 deletions cypress/component/useModal.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,12 @@ describe("useModal.cy.ts", () => {
});
});

type ExampleModalProps = ModalProps<
string,
{
count: number;
title: string;
}
>;
interface ExampleModalProps extends ModalProps<string> {
count: number;
title: string;
}

function ExampleModal({
open,
input: { title, count },
resolve,
}: ExampleModalProps) {
function ExampleModal({ open, title, count, resolve }: ExampleModalProps) {
return (
<div role="modal" style={{ display: open ? "block" : "none" }}>
<h1>{title}</h1>
Expand Down
13 changes: 8 additions & 5 deletions src/app/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import Snackbar from "@mui/material/Snackbar";
import type { ReactNode } from "react";
import type { ModalProps } from "../../lib/useModal";

export type ToastProps = ModalProps<
void,
{ variant?: AlertColor; content: ReactNode; duration?: number }
>;
export interface ToastProps extends ModalProps {
variant?: AlertColor;
content: ReactNode;
duration?: number;
}

export function Toast({
open,
resolve,
input: { variant = "success", content, duration = 6000 },
variant = "success",
content,
duration = 6000,
}: ToastProps) {
return (
<Snackbar
Expand Down
Loading