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 0000000..a8d767e Binary files /dev/null and b/thumbnail.png differ