From 01219c5fe6a23fdcc53ba96cf5b355dbd3edfb6b Mon Sep 17 00:00:00 2001 From: YuGilJong <101111364+XionWCFM@users.noreply.github.com> Date: Sun, 11 Aug 2024 01:50:17 +0900 Subject: [PATCH] [2024.08.11] ReadMe Update (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :construction: Create funnel client * :construction: Createstep api 변경 * :construction: App router nested example * :construction: Default step example add * :construction: Readme update --- .github/workflows/release.yml | 88 ++++---- README.md | 199 +++++++++++++++++- .../app/default-step/page.tsx | 12 ++ apps/app-router-example/app/funnel.tsx | 93 -------- apps/app-router-example/app/funnel/page.tsx | 4 +- apps/app-router-example/app/guard/page.tsx | 12 ++ apps/app-router-example/app/nested/page.tsx | 12 ++ apps/app-router-example/app/page.tsx | 14 +- apps/app-router-example/package.json | 2 + apps/app-router-example/src/basic-funnel.tsx | 59 ++++++ .../src/default-step-funnel.tsx | 65 ++++++ apps/app-router-example/src/example-funnel.ts | 8 - apps/app-router-example/src/guard-funnel.tsx | 90 ++++++++ apps/app-router-example/src/nested-funnel.tsx | 94 +++++++++ biome.json | 5 +- .../funnel-app-router-adapter/package.json | 2 + .../use-funnel-app-router-adapter.tsx | 48 +---- packages/funnel-client/package.json | 38 ++++ .../src/external/funnel-client.ts | 59 ++++++ packages/funnel-client/src/index.ts | 2 + packages/funnel-client/tsconfig.json | 25 +++ packages/funnel-client/tsup.config.ts | 11 + packages/funnel-core/package.json | 5 +- .../src/external/external-utils.ts | 46 ---- packages/funnel-core/src/external/guard.tsx | 52 ++--- packages/funnel-core/src/external/types.ts | 20 +- .../src/external/use-core-funnel.tsx | 13 +- packages/funnel-core/src/index.ts | 4 +- pnpm-lock.yaml | 50 ++++- thumbnail.png | Bin 0 -> 23605 bytes 30 files changed, 832 insertions(+), 300 deletions(-) create mode 100644 apps/app-router-example/app/default-step/page.tsx delete mode 100644 apps/app-router-example/app/funnel.tsx create mode 100644 apps/app-router-example/app/guard/page.tsx create mode 100644 apps/app-router-example/app/nested/page.tsx create mode 100644 apps/app-router-example/src/basic-funnel.tsx create mode 100644 apps/app-router-example/src/default-step-funnel.tsx delete mode 100644 apps/app-router-example/src/example-funnel.ts create mode 100644 apps/app-router-example/src/guard-funnel.tsx create mode 100644 apps/app-router-example/src/nested-funnel.tsx create mode 100644 packages/funnel-client/package.json create mode 100644 packages/funnel-client/src/external/funnel-client.ts create mode 100644 packages/funnel-client/src/index.ts create mode 100644 packages/funnel-client/tsconfig.json create mode 100644 packages/funnel-client/tsup.config.ts create mode 100644 thumbnail.png diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 036fd6d..20178bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,44 +1,44 @@ -name: Release - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: Setup pnpm 8 - uses: pnpm/action-setup@v3 - with: - version: 8 - - - name: Setup Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - - - name: Install Dependencies - run: pnpm i - - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@v1 - with: - # This expects you to have a script called release which does a build for your packages and calls changeset publish - publish: pnpm release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Send a Slack notification if a publish happens - if: steps.changesets.outputs.published == 'true' - # You can do something when a publish happens. - run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" +# name: Release + +# on: +# push: +# branches: +# - main + +# concurrency: ${{ github.workflow }}-${{ github.ref }} + +# jobs: +# release: +# name: Release +# runs-on: ubuntu-latest +# steps: +# - name: Checkout Repo +# uses: actions/checkout@v4 + +# - name: Setup pnpm 8 +# uses: pnpm/action-setup@v3 +# with: +# version: 8 + +# - name: Setup Node.js 20.x +# uses: actions/setup-node@v4 +# with: +# node-version: 20.x + +# - name: Install Dependencies +# run: pnpm i + +# - name: Create Release Pull Request or Publish to npm +# id: changesets +# uses: changesets/action@v1 +# with: +# # This expects you to have a script called release which does a build for your packages and calls changeset publish +# publish: pnpm release +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + +# - name: Send a Slack notification if a publish happens +# if: steps.changesets.outputs.published == 'true' +# # You can do something when a publish happens. +# run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" diff --git a/README.md b/README.md index bec0b13..b37c2f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,199 @@ -# Summery +![usefunnel thumbnail](/thumbnail.png) -Funnel의 Best Practice를 구합니다. +# useFunnel +Manage your funnels declaratively and explicitly. + +# Quick Start + +## next.js app router + +### Installation + +``` +npm i qs @xionhub/funnel-core @xionhub/funnel-client @xionhub/funnel-app-router-adapter +``` + +``` +yarn add qs @xionhub/funnel-core @xionhub/funnel-client @xionhub/funnel-app-router-adapter +``` + +``` +pnpm i qs @xionhub/funnel-core @xionhub/funnel-client @xionhub/funnel-app-router-adapter +``` + +### create funnelOptions + +```tsx +import { funnelOptions } from "@xionhub/funnel-core"; + +const basicFunnelOptions = () => + funnelOptions({ + steps: ["a", "b", "c"] as const, + funnelId: "hello-this-is-funnel-id", + }); +``` + +### import useFunnel + +```tsx +"use client"; +import { useFunnel } from "@xionhub/funnel-app-router-adapter"; +import { useRouter } from "next/navigation"; + +export const BasicFunnel = () => { + const [Funnel, controller] = useFunnel(basicFunnelOptions()); + const router = useRouter(); + return ( + + + { + router.push(`/funnel?${controller.createStep("b")}`); + }} + step="a" + /> + + + { + router.push(`/funnel?${controller.createStep("c")}`); + }} + step="b" + /> + + + { + router.push(`/funnel?${controller.createStep("a")}`); + }} + step="c" + /> + + + ); +}; + +const FunnelItem = ({ setStep, step }: Props) => { + return ( +
+
current location {step}
+ +
+ ); +}; +``` + +### Wrapping Suspense + +```tsx +import { Suspense } from "react"; +import { BasicFunnel } from "~/src/basic-funnel"; + +export default function Page() { + return ( +
+ + + +
+ ); +} +``` + +# API + +## useCoreFunnel + +```tsx +declare const useCoreFunnel: >( + options: FunnelOptions & { + onStepChange?: FunnelStepChangeFunction; + } +) => [ + ((props: RouteFunnelProps) => JSX.Element) & { + Step: (props: StepProps) => JSX.Element; + Guard: (props: GuardProps) => JSX.Element; + }, + { + funnelId: string; + step: Steps[number] | undefined; + onStepChange: FunnelStepChangeFunction; + steps: Steps; + }, +]; +``` + +## Guard + +```tsx +interface GuardProps { + condition: (() => T) | (() => Promise); + children?: ReactNode; + onRestrict?: (param: Awaited) => void; + conditionBy?: (param: Awaited) => boolean; + fallback?: ReactNode; +} +``` + +`condition` is a function that must return whether the funnel can be accessed. + +`onRestrict` runs when condition is false. + +`conditionBy` is required if the value returned by condition is not boolean. It should return a boolean. + +`fallback` is the fallback that will be displayed when the condition is Falsy. + +## useFunnel For App Router + +```tsx +declare const useFunnel: >( + options: Omit, "step"> +) => [ + ((props: RouteFunnelProps) => JSX.Element) & { + Step: (props: StepProps) => JSX.Element; + Guard: (props: _GuardProps) => JSX.Element; + }, + { + createStep: ( + step: Steps[number], + searchParams?: URLSearchParams, + deleteQueryParams?: string[] | string + ) => string; + funnelId: string; + step: Steps[number] | undefined; + steps: Steps; + }, +]; +``` + +`createStep` is the same API as FunnelClient.createStep. + +Using createStep, you can create, delete, or update the query string for the next step. + +## Funnel Client + +### FunnelClient.createStep + +```tsx +createStep(step:string , options:{ + searchParams?: URLSearchParams; + deleteQueryParams?: string[] | string; + qsOptions?: QueryString.IStringifyBaseOptions; + }) +``` + +For deleteQueryParams, enter the key value of queryParams you want to delete. qsOptions uses StringifyBaseOptions from the qs library. + +# Get More Example + +[App Router Example](https://github.com/XionWCFM/funnel/tree/main/apps/app-router-example) + +# License + +Licensed under the MIT license. diff --git a/apps/app-router-example/app/default-step/page.tsx b/apps/app-router-example/app/default-step/page.tsx new file mode 100644 index 0000000..d799185 --- /dev/null +++ b/apps/app-router-example/app/default-step/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { DefaultStepFunnel } from "~/src/default-step-funnel"; + +export default function Page() { + return ( +
+ + + +
+ ); +} diff --git a/apps/app-router-example/app/funnel.tsx b/apps/app-router-example/app/funnel.tsx deleted file mode 100644 index 5f70124..0000000 --- a/apps/app-router-example/app/funnel.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { useFunnel } from "@xionhub/funnel-app-router-adapter"; -import { overlay } from "overlay-kit"; -import { exampleFunnelOptions } from "~/src/example-funnel"; - -export default function ExampleFunnel() { - const [Funnel, controller] = useFunnel(exampleFunnelOptions()); - - return ( -
- - - controller.onStepChange("b")} /> - - - - { - if (Math.random() > 0.5) { - return true; - } - await new Promise((res) => setTimeout(res, 1000)); - return false; - }} - onRestrict={async () => { - await overlay.openAsync(({ close, unmount }) => ( -
-
접근할 수 없는 상태에요
- -
- )); - controller.onStepChange("a", { type: "replace" }); - }} - fallback={
fallback..
} - > - controller.onStepChange("c")} /> -
-
- - - controller.onStepChange("a")} /> - -
-
- ); -} - -type FunnelProps = { - nextStep: () => void; -}; - -const FunnelA = (props: FunnelProps) => { - const { nextStep } = props; - return ( -
-
current A
- -
- ); -}; -const FunnelB = (props: FunnelProps) => { - const { nextStep } = props; - return ( -
-
current B
- -
- ); -}; -const FunnelC = (props: FunnelProps) => { - const { nextStep } = props; - return ( -
-
current C
- -
- ); -}; diff --git a/apps/app-router-example/app/funnel/page.tsx b/apps/app-router-example/app/funnel/page.tsx index c8dacac..56f39cc 100644 --- a/apps/app-router-example/app/funnel/page.tsx +++ b/apps/app-router-example/app/funnel/page.tsx @@ -1,11 +1,11 @@ import { Suspense } from "react"; -import ExampleFunnel from "../funnel"; +import { BasicFunnel } from "~/src/basic-funnel"; export default function Page() { return (
- +
); diff --git a/apps/app-router-example/app/guard/page.tsx b/apps/app-router-example/app/guard/page.tsx new file mode 100644 index 0000000..02bb459 --- /dev/null +++ b/apps/app-router-example/app/guard/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { GuardFunnel } from "~/src/guard-funnel"; + +export default function Page() { + return ( +
+ + + +
+ ); +} diff --git a/apps/app-router-example/app/nested/page.tsx b/apps/app-router-example/app/nested/page.tsx new file mode 100644 index 0000000..a924f5d --- /dev/null +++ b/apps/app-router-example/app/nested/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { NestedFunnel } from "~/src/nested-funnel"; + +export default function Page() { + return ( +
+ + + +
+ ); +} diff --git a/apps/app-router-example/app/page.tsx b/apps/app-router-example/app/page.tsx index 663f7e4..fcdd0e4 100644 --- a/apps/app-router-example/app/page.tsx +++ b/apps/app-router-example/app/page.tsx @@ -1,13 +1,17 @@ "use client"; -import { FunnelClient } from "@xionhub/funnel-core"; +import { FunnelClient } from "@xionhub/funnel-client"; import Link from "next/link"; -import { exampleFunnelOptions } from "~/src/example-funnel"; +import { basicFunnelOptions } from "~/src/basic-funnel"; +import { guardFunnelOptions } from "~/src/guard-funnel"; +import { aFunnelOptions } from "~/src/nested-funnel"; export default function Home() { - const funnelClient = new FunnelClient(exampleFunnelOptions()); return ( -
- 퍼널로 이동하기 +
+ Go To Basic Funnel + Go To Nested Funnel + Go To Guard Funnel + Go To Default Step Funnel
); } diff --git a/apps/app-router-example/package.json b/apps/app-router-example/package.json index be83c25..946edf3 100644 --- a/apps/app-router-example/package.json +++ b/apps/app-router-example/package.json @@ -11,6 +11,8 @@ "dependencies": { "@xionhub/funnel-app-router-adapter": "workspace:*", "@xionhub/funnel-core": "workspace:*", + "@xionhub/funnel-client": "workspace:*", + "jotai": "^2.9.2", "next": "14.2.5", "overlay-kit": "^1.4.1", "qs": "^6.13.0", diff --git a/apps/app-router-example/src/basic-funnel.tsx b/apps/app-router-example/src/basic-funnel.tsx new file mode 100644 index 0000000..5aaa8a1 --- /dev/null +++ b/apps/app-router-example/src/basic-funnel.tsx @@ -0,0 +1,59 @@ +"use client"; +import { useFunnel } from "@xionhub/funnel-app-router-adapter"; +import { funnelOptions } from "@xionhub/funnel-core"; +import { useRouter } from "next/navigation"; + +export const basicFunnelOptions = () => + funnelOptions({ + steps: ["a", "b", "c"] as const, + funnelId: "hello-this-is-funnel-id", + }); + +type Props = { + setStep: () => void; + step: string; +}; + +export const BasicFunnel = () => { + const [Funnel, controller] = useFunnel(basicFunnelOptions()); + const router = useRouter(); + return ( + + + { + router.push(`/funnel?${controller.createStep("b")}`); + }} + step="a" + /> + + + { + router.push(`/funnel?${controller.createStep("c")}`); + }} + step="b" + /> + + + { + router.push(`/funnel?${controller.createStep("a")}`); + }} + step="c" + /> + + + ); +}; + +const FunnelItem = ({ setStep, step }: Props) => { + return ( +
+
current location {step}
+ +
+ ); +}; diff --git a/apps/app-router-example/src/default-step-funnel.tsx b/apps/app-router-example/src/default-step-funnel.tsx new file mode 100644 index 0000000..162a082 --- /dev/null +++ b/apps/app-router-example/src/default-step-funnel.tsx @@ -0,0 +1,65 @@ +"use client"; +import { useFunnel } from "@xionhub/funnel-app-router-adapter"; +import { funnelOptions } from "@xionhub/funnel-core"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export const defaultStepFunnelOptions = () => + funnelOptions({ + steps: ["a", "b", "c"] as const, + funnelId: "default-step", + }); + +type Props = { + setStep: () => void; + step: string; +}; + +export const DefaultStepFunnel = () => { + const [Funnel, controller] = useFunnel(defaultStepFunnelOptions()); + const router = useRouter(); + useEffect(() => { + if (controller.step === undefined) { + router.replace(`/default-step?${controller.createStep("a")}`); + } + }); + return ( + + + { + router.push(`/default-step?${controller.createStep("b")}`); + }} + step="a" + /> + + + { + router.push(`/default-step?${controller.createStep("c")}`); + }} + step="b" + /> + + + { + router.push(`/default-step?${controller.createStep("a")}`); + }} + step="c" + /> + + + ); +}; + +const FunnelItem = ({ setStep, step }: Props) => { + return ( +
+
current location {step}
+ +
+ ); +}; diff --git a/apps/app-router-example/src/example-funnel.ts b/apps/app-router-example/src/example-funnel.ts deleted file mode 100644 index 37a93a2..0000000 --- a/apps/app-router-example/src/example-funnel.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FunnelClient, funnelOptions } from "@xionhub/funnel-core"; - -const EXAMPLE_FUNNEL_ID = "hello-this-is-funnel-id"; -export const exampleFunnelOptions = () => - funnelOptions({ - steps: ["a", "b", "c"] as const, - funnelId: EXAMPLE_FUNNEL_ID, - }); diff --git a/apps/app-router-example/src/guard-funnel.tsx b/apps/app-router-example/src/guard-funnel.tsx new file mode 100644 index 0000000..be5a9fd --- /dev/null +++ b/apps/app-router-example/src/guard-funnel.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useFunnel } from "@xionhub/funnel-app-router-adapter"; +import { funnelOptions } from "@xionhub/funnel-core"; +import { useRouter } from "next/navigation"; +import { overlay } from "overlay-kit"; + +export const guardFunnelOptions = () => + funnelOptions({ + steps: ["a", "b", "c"] as const, + funnelId: "guard-funnel", + }); + +export const GuardFunnel = () => { + const [Funnel, controller] = useFunnel(guardFunnelOptions()); + const router = useRouter(); + return ( + + + { + router.push(`/guard?${controller.createStep("b")}`); + }} + step="a" + /> + + + { + if (Math.random() > 0.5) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + return false; + }} + onRestrict={async () => { + const confirm = await overlay.openAsync( + ({ close, isOpen }) => + isOpen && ( +
+
Funnel is restricted
+ + +
+ ), + ); + if (confirm) { + router.push(`/guard?${controller.createStep("a")}`); + } else { + router.push("/"); + } + }} + fallback={<>... funnel validation} + > + { + router.push(`/guard?${controller.createStep("c")}`); + }} + step="b" + /> +
+
+ + + { + router.push(`/guard?${controller.createStep("a")}`); + }} + step="c" + /> + +
+ ); +}; + +type Props = { + setStep: () => void; + step: string; +}; + +const FunnelItem = ({ setStep, step }: Props) => { + return ( +
+
current location {step}
+ +
+ ); +}; diff --git a/apps/app-router-example/src/nested-funnel.tsx b/apps/app-router-example/src/nested-funnel.tsx new file mode 100644 index 0000000..b5733b0 --- /dev/null +++ b/apps/app-router-example/src/nested-funnel.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useFunnel } from "@xionhub/funnel-app-router-adapter"; +import { funnelOptions } from "@xionhub/funnel-core"; +import { useRouter, useSearchParams } from "next/navigation"; + +export const aFunnelOptions = () => funnelOptions({ funnelId: "sadkl", steps: ["astart", "ado", "aend"] as const }); + +export const bFunnelOptions = () => funnelOptions({ funnelId: "sadkl2", steps: ["bstart", "bdo", "bend"] as const }); + +export const NestedFunnel = () => { + const [AFunnel, aController] = useFunnel(aFunnelOptions()); + const [BFunnel, bController] = useFunnel(bFunnelOptions()); + const searchParams = useSearchParams(); + const router = useRouter(); + + return ( +
+ + + { + router.push(`/nested?${aController.createStep("ado")}`); + }} + step={"astart"} + /> + + + { + router.push( + `/nested?${bController.createStep("bstart", { searchParams, deleteQueryParams: aController.funnelId })}`, + ); + }} + step={"ado"} + /> + + + { + router.push(`/nested?${aController.createStep("astart")}`); + }} + step={"aend"} + /> + + + + + + { + router.push(`/nested?${bController.createStep("bdo")}`); + }} + step={"bstart"} + /> + + + { + router.push(`/nested?${bController.createStep("bend")}`); + }} + step={"bdo"} + /> + + + { + router.push( + `/nested?${aController.createStep("aend", { searchParams, deleteQueryParams: bController.funnelId })}`, + ); + }} + step={"bend"} + /> + + +
+ ); +}; + +type Props = { + setStep: () => void; + step: string; +}; + +const FunnelItem = ({ setStep, step }: Props) => { + return ( +
+
current location {step}
+ +
+ ); +}; diff --git a/biome.json b/biome.json index 86a5f12..cde4e69 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "a11y": { + "useButtonType": "off" + } } }, "formatter": { diff --git a/packages/funnel-app-router-adapter/package.json b/packages/funnel-app-router-adapter/package.json index b6ee24c..d375822 100644 --- a/packages/funnel-app-router-adapter/package.json +++ b/packages/funnel-app-router-adapter/package.json @@ -25,6 +25,7 @@ "@types/qs": "^6.9.15", "@types/react": "^18", "@xionhub/funnel-core": "workspace:*", + "@xionhub/funnel-client": "workspace:*", "next": "^14.2.5", "qs": "^6.13.0", "tsup": "^8.1.0", @@ -32,6 +33,7 @@ }, "peerDependencies": { "@xionhub/funnel-core": "^0", + "@xionhub/funnel-client": "^0", "next": "^13", "qs": "^6", "react": "^16", diff --git a/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx b/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx index a8259be..3eb5d29 100644 --- a/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx +++ b/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx @@ -1,53 +1,15 @@ -import { - type CreateFunnelStepFunction, - FunnelClient, - type FunnelOptions, - type FunnelStepChangeFunction, - type NonEmptyArray, - useCoreFunnel, -} from "@xionhub/funnel-core"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { FunnelClient } from "@xionhub/funnel-client"; +import { type FunnelOptions, type NonEmptyArray, useCoreFunnel } from "@xionhub/funnel-core"; +import { useSearchParams } from "next/navigation"; export const useFunnelAppRouterAdapter = >( options: Omit, "step">, ) => { const funnelId = options.funnelId; - const router = useRouter(); const searchParams = useSearchParams(); const queryStep = searchParams.get(funnelId); const funnelClient = new FunnelClient(options); - const pathname = usePathname(); const step = (queryStep ?? undefined) as Steps[number] | undefined; - const [Funnel, controller] = useCoreFunnel({ ...options, step }); - - const createFunnelStep: CreateFunnelStepFunction = (step, options) => { - if (!options?.searchParams) { - return { [funnelId]: step }; - } - const allQueryString = funnelClient.getQueryString(options?.searchParams); - - const deleteKeyList = Array.isArray(options?.deleteQueryParams) - ? options?.deleteQueryParams - : ([options?.deleteQueryParams].filter(Boolean) as string[]); - - const stepObject = funnelClient.createStepObject(step, funnelClient.deleteStep(allQueryString, deleteKeyList)); - return stepObject; - }; - - const onStepChange: FunnelStepChangeFunction = (newStep, options) => { - const stepObject = createFunnelStep(newStep, { ...options, searchParams }); - const newUrl = `${pathname}${funnelClient.stringifyStep(stepObject)}`; - - if (options?.type === "replace") { - return router.replace(newUrl); - } - - if (options?.type === "back") { - return router.back(); - } - - return router.push(newUrl); - }; - - return [Funnel, { ...controller, onStepChange, createFunnelStep }] as const; + const [Funnel, { onStepChange, ...controller }] = useCoreFunnel({ ...options, step }); + return [Funnel, { ...controller, createStep: funnelClient.createStep }] as const; }; diff --git a/packages/funnel-client/package.json b/packages/funnel-client/package.json new file mode 100644 index 0000000..3314d5e --- /dev/null +++ b/packages/funnel-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@xionhub/funnel-client", + "version": "0.0.0", + "private": true, + "license": "MIT", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "build": "tsup" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/qs": "^6.9.15", + "@types/react": "^18", + "qs": "^6.13.0", + "tsup": "^8.1.0", + "typescript": "latest", + "@xionhub/funnel-core": "workspace:*" + }, + "peerDependencies": { + "react": "^16", + "react-dom": "^16", + "qs": "^6", + "@xionhub/funnel-core": "^0" + } +} diff --git a/packages/funnel-client/src/external/funnel-client.ts b/packages/funnel-client/src/external/funnel-client.ts new file mode 100644 index 0000000..bcac342 --- /dev/null +++ b/packages/funnel-client/src/external/funnel-client.ts @@ -0,0 +1,59 @@ +import type { FunnelOptions, NonEmptyArray } from "@xionhub/funnel-core"; +import type QueryString from "qs"; +import qs from "qs"; + +type CreateStepOptionsType = { + searchParams?: URLSearchParams; + deleteQueryParams?: string[] | string; + qsOptions?: QueryString.IStringifyBaseOptions; +}; + +export class FunnelClient> { + funnelId: string; + steps: T; + + constructor(props: FunnelOptions) { + this.funnelId = props.funnelId; + this.steps = props.steps; + } + + createStep(step: T[number], options?: CreateStepOptionsType) { + const { searchParams, deleteQueryParams, qsOptions } = options ?? {}; + const deleteList = ( + Array.isArray(deleteQueryParams) ? deleteQueryParams : [deleteQueryParams].filter(Boolean) + ) as string[]; + const searchParamToObj = this.getQueryString(searchParams ?? new URLSearchParams()); + return this.stringifyStep(this.deleteStep(this.createStepObject(step, searchParamToObj), deleteList), qsOptions); + } + + getQueryString>(searchParams: URLSearchParams) { + const result = {} as Record; + searchParams.forEach((value, key) => { + result[key] = value; + }); + return result as T; + } + + createStepObject(value: T[number], context?: Record) { + return { ...context, [this.funnelId]: value } as const; + } + + // biome-ignore lint/suspicious/noExplicitAny: + deleteStep, K extends keyof T>(obj: T, keys: K[]): Omit { + const result = { ...obj }; + + for (const key of keys) { + delete result[key]; + } + + return result as Omit; + } + + stringifyStep(context: Record, options?: QueryString.IStringifyBaseOptions) { + return qs.stringify(context, options); + } + + parseQueryString(queryString: string, options?: QueryString.IStringifyBaseOptions) { + return qs.parse(queryString, options) as T; + } +} diff --git a/packages/funnel-client/src/index.ts b/packages/funnel-client/src/index.ts new file mode 100644 index 0000000..c5fd954 --- /dev/null +++ b/packages/funnel-client/src/index.ts @@ -0,0 +1,2 @@ +import { FunnelClient } from "./external/funnel-client"; +export { FunnelClient }; diff --git a/packages/funnel-client/tsconfig.json b/packages/funnel-client/tsconfig.json new file mode 100644 index 0000000..244bc1d --- /dev/null +++ b/packages/funnel-client/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "jsx": "react-jsx", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/funnel-client/tsup.config.ts b/packages/funnel-client/tsup.config.ts new file mode 100644 index 0000000..3e69dbd --- /dev/null +++ b/packages/funnel-client/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + format: ["cjs", "esm"], + entry: ["./src/index.ts"], + sourcemap: true, + dts: true, + clean: true, + minify: true, + treeshake: true, +}); diff --git a/packages/funnel-core/package.json b/packages/funnel-core/package.json index 78eac72..cbef53e 100644 --- a/packages/funnel-core/package.json +++ b/packages/funnel-core/package.json @@ -22,15 +22,12 @@ "access": "public" }, "devDependencies": { - "@types/qs": "^6.9.15", "@types/react": "^18", - "qs": "^6.13.0", "tsup": "^8.1.0", "typescript": "latest" }, "peerDependencies": { "react": "^16", - "react-dom": "^16", - "qs": "^6" + "react-dom": "^16" } } diff --git a/packages/funnel-core/src/external/external-utils.ts b/packages/funnel-core/src/external/external-utils.ts index c2bfa6d..1959cfd 100644 --- a/packages/funnel-core/src/external/external-utils.ts +++ b/packages/funnel-core/src/external/external-utils.ts @@ -1,49 +1,3 @@ -import qs from "qs"; import type { FunnelOptions, NonEmptyArray } from "./types"; export const funnelOptions = >(props: FunnelOptions): FunnelOptions => props; - -export class FunnelClient> { - funnelId: string; - steps: T; - - constructor(props: FunnelOptions) { - this.funnelId = props.funnelId; - this.steps = props.steps; - } - - createStep(value: T[number], context?: Record) { - return this.stringifyStep(this.createStepObject(value, context)); - } - - getQueryString>(searchParams: URLSearchParams) { - const result = {} as Record; - searchParams.forEach((value, key) => { - result[key] = value; - }); - return result as T; - } - - createStepObject(value: T[number], context?: Record) { - return { ...context, [this.funnelId]: value } as const; - } - - // biome-ignore lint/suspicious/noExplicitAny: - deleteStep, K extends keyof T>(obj: T, keys: K[]): Omit { - const result = { ...obj }; - - for (const key of keys) { - delete result[key]; - } - - return result as Omit; - } - - stringifyStep(context: Record) { - return qs.stringify(context, { addQueryPrefix: true }); - } - - parseQueryString(queryString: string) { - return qs.parse(queryString, { ignoreQueryPrefix: true }) as T; - } -} diff --git a/packages/funnel-core/src/external/guard.tsx b/packages/funnel-core/src/external/guard.tsx index 31acb45..63dcfa7 100644 --- a/packages/funnel-core/src/external/guard.tsx +++ b/packages/funnel-core/src/external/guard.tsx @@ -1,44 +1,34 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; +import { useIsomorphicLayoutEffect } from "../internal/use-isomorphic-layout-effect"; import type { GuardProps } from "./types"; -export const Guard = ({ condition, children, fallback, onRestrict }: GuardProps) => { +export const Guard = (props: GuardProps) => { + const { condition, onRestrict, fallback, children } = props; const [isRender, setIsRender] = useState(false); const isOnce = useRef(true); - const canImmediateRender = - (typeof condition === "boolean" && condition) || - (typeof condition === "function" && typeof condition() === "boolean" && condition()); - useEffect(() => { - let result: boolean; - const check = async () => { - if (canImmediateRender) { - return () => {}; + useIsomorphicLayoutEffect(() => { + if (!isOnce.current) { + return () => {}; + } + const callCondition = async () => { + isOnce.current = false; + const result = typeof condition === "function" ? await condition() : condition; + const byResult = props?.conditionBy ? props?.conditionBy?.(result as Awaited) : result; + if (typeof byResult !== "boolean") { + throw new Error("condition should be boolean"); } - - if (typeof condition === "function") { - result = await condition(); - if (result === false) { - onRestrict?.(); - } else { - setIsRender(true); - } + if (byResult) { + setIsRender(true); } - if (typeof condition === "boolean") { - if (condition === false) { - onRestrict?.(); - } else { - setIsRender(true); - } + if (!byResult) { + onRestrict?.(result as Awaited); } }; + callCondition(); + }, []); - if (isOnce.current) { - check(); - isOnce.current = false; - } - }, [canImmediateRender, condition, onRestrict]); - - return canImmediateRender || isRender ? children : fallback; + return isRender ? children : fallback; }; diff --git a/packages/funnel-core/src/external/types.ts b/packages/funnel-core/src/external/types.ts index 759bdec..0c691e3 100644 --- a/packages/funnel-core/src/external/types.ts +++ b/packages/funnel-core/src/external/types.ts @@ -4,12 +4,7 @@ export type NonEmptyArray = readonly [T, ...T[]]; export type RoutesEventType = "replace" | "push" | "back"; -export type DeleteQueryParams = { deleteQueryParams?: string[] | string }; - -export type FunnelStepChangeFunction> = ( - step: T[number], - options?: { type?: RoutesEventType } & DeleteQueryParams, -) => void; +export type FunnelStepChangeFunction> = (step: T[number]) => void; export type RouteFunnelProps> = Omit, "steps" | "step">; @@ -24,22 +19,23 @@ export interface StepProps> { children: React.ReactNode; } -export interface GuardProps { - condition: boolean | (() => boolean | Promise); +export type GuardProps = { + condition: (() => T) | (() => Promise) | boolean | (() => boolean) | (() => Promise); children?: ReactNode; - onRestrict?: () => void; + onRestrict?: (param: Awaited) => void; + conditionBy?: (param: Awaited) => boolean; fallback?: ReactNode; -} +}; export type CreateFunnelStepFunction> = ( step: Steps[number], options?: { deleteQueryParams?: string[] | string; searchParams?: URLSearchParams }, ) => Record; -export type FunnelAdapterReturnType> = [ +export type FunnelAdapterReturnType, T = unknown> = [ ((props: RouteFunnelProps) => JSX.Element) & { Step: (props: StepProps) => JSX.Element; - Guard: (props: GuardProps) => JSX.Element; + Guard: (props: GuardProps) => JSX.Element; }, { funnelId: string; diff --git a/packages/funnel-core/src/external/use-core-funnel.tsx b/packages/funnel-core/src/external/use-core-funnel.tsx index 7d63193..bc612cd 100644 --- a/packages/funnel-core/src/external/use-core-funnel.tsx +++ b/packages/funnel-core/src/external/use-core-funnel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useDraft } from "../internal/use-draft"; import { Funnel } from "./funnel"; import { Guard } from "./guard"; @@ -16,11 +16,12 @@ export const useCoreFunnel = >( options: FunnelOptions & { onStepChange?: FunnelStepChangeFunction }, ) => { const [_step, _setStep] = useDraft(options?.step); + const steps = options.steps; - const step = options?.step; - const funnelId = options?.funnelId; + const step = options?.step ?? _step; + const funnelId = options.funnelId; - const _onStepChange: FunnelStepChangeFunction = (param: Steps[number], routeOptions) => { + const _onStepChange: FunnelStepChangeFunction = (param: Steps[number]) => { _setStep(param); }; @@ -35,12 +36,12 @@ export const useCoreFunnel = >( Step: (props: StepProps) => { return ; }, - Guard: (props: GuardProps) => { + Guard: (props: GuardProps) => { return ; }, }, ); }, [step, steps]); - return [FunnelComponent, { funnelId, step, onStepChange }] as const; + return [FunnelComponent, { funnelId, step, onStepChange, steps }] as const; }; diff --git a/packages/funnel-core/src/index.ts b/packages/funnel-core/src/index.ts index 8dc0875..5766af2 100644 --- a/packages/funnel-core/src/index.ts +++ b/packages/funnel-core/src/index.ts @@ -1,4 +1,4 @@ -import { FunnelClient, funnelOptions } from "./external/external-utils"; +import { funnelOptions } from "./external/external-utils"; import { Guard as FunnelGuard } from "./external/guard"; import type { CreateFunnelStepFunction, @@ -14,7 +14,7 @@ import type { } from "./external/types"; import { useCoreFunnel } from "./external/use-core-funnel"; -export { useCoreFunnel, FunnelGuard, funnelOptions, FunnelClient }; +export { useCoreFunnel, FunnelGuard, funnelOptions }; export type { NonEmptyArray, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65e107..54b598d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,15 @@ importers: '@xionhub/funnel-app-router-adapter': specifier: workspace:* version: link:../../packages/funnel-app-router-adapter + '@xionhub/funnel-client': + specifier: workspace:* + version: link:../../packages/funnel-client '@xionhub/funnel-core': specifier: workspace:* version: link:../../packages/funnel-core + jotai: + specifier: ^2.9.2 + version: 2.9.2(@types/react@18.3.3)(react@18.3.1) next: specifier: 14.2.5 version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -91,6 +97,9 @@ importers: '@types/react': specifier: ^18 version: 18.3.3 + '@xionhub/funnel-client': + specifier: workspace:* + version: link:../funnel-client '@xionhub/funnel-core': specifier: workspace:* version: link:../funnel-core @@ -107,7 +116,7 @@ importers: specifier: latest version: 5.5.4 - packages/funnel-core: + packages/funnel-client: dependencies: react: specifier: ^16 @@ -122,6 +131,9 @@ importers: '@types/react': specifier: ^18 version: 18.3.3 + '@xionhub/funnel-core': + specifier: workspace:* + version: link:../funnel-core qs: specifier: ^6.13.0 version: 6.13.0 @@ -132,6 +144,25 @@ importers: specifier: latest version: 5.5.4 + packages/funnel-core: + dependencies: + react: + specifier: ^16 + version: 16.14.0 + react-dom: + specifier: ^16 + version: 16.14.0(react@16.14.0) + devDependencies: + '@types/react': + specifier: ^18 + version: 18.3.3 + tsup: + specifier: ^8.1.0 + version: 8.2.4(jiti@1.21.6)(postcss@8.4.40)(typescript@5.5.4)(yaml@2.5.0) + typescript: + specifier: latest + version: 5.5.4 + packages: '@aashutoshrathi/word-wrap@1.2.6': @@ -1575,6 +1606,18 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + jotai@2.9.2: + resolution: {integrity: sha512-jIBXEadOHCziOuMY6HAy2KQcHipGhnsbF+twqh8Lcmcz/Yei0gdBtW5mOYdKmbQxGqkvfvXM3w/oHtJ2WNGSFg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4134,6 +4177,11 @@ snapshots: jiti@1.21.6: {} + jotai@2.9.2(@types/react@18.3.3)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.3 + react: 18.3.1 + joycon@3.1.1: {} js-tokens@4.0.0: {} diff --git a/thumbnail.png b/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..a8d767ef6a0613b82a66317e1598f6d279452586 GIT binary patch literal 23605 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 literal 0 HcmV?d00001