diff --git a/.changeset/quick-gorillas-grin.md b/.changeset/quick-gorillas-grin.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quick-gorillas-grin.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index e52e50fc912..72411241c94 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -10,10 +10,6 @@ inputs: description: 'Enable Playwright?' required: false default: 'false' - cache-suffix: - description: 'The cache suffix to use' - required: false - default: '' turbo-summarize: description: 'The token to use for Turbo task summaries' required: false @@ -97,23 +93,12 @@ runs: - name: Setup NodeJS ${{ inputs.node-version }} uses: actions/setup-node@v4 with: + cache: 'npm' node-version: ${{ inputs.node-version }} registry-url: ${{ inputs.registry-url }} - - name: Cache node_modules - uses: actions/cache@v3 - id: npm-cache - with: - path: ./node_modules - key: ${{ runner.os }}-node-${{ inputs.node-version }}-${{ hashFiles('.github/.cache-version') }}-node-modules-${{ hashFiles('package-lock.json') }}${{ inputs.cache-suffix }} - - name: Install NPM Dependencies - if: steps.npm-cache.outputs.cache-hit != 'true' - run: npm ci --audit=false --fund=false - shell: bash - - - name: Lint GitHub Actions Workflows - run: npx eslint .github + run: npm ci --audit=false --fund=false --prefer-offline shell: bash - name: Get Playwright Version @@ -128,7 +113,7 @@ runs: id: playwright-cache with: path: ~/.cache/ms-playwright - key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-${{ hashFiles('.github/.cache-version') }}-playwright-${{ steps.playwright-version.outputs.VERSION }} + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-playwright-${{ steps.playwright-version.outputs.VERSION }} - name: Install Playwright Browsers if: inputs.playwright-enabled == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d0b3ca3411..5d0afad0afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,11 +39,19 @@ jobs: turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} + - name: List node_modules + run: npm ls + shell: bash + - name: Require Changeset timeout-minutes: ${{ fromJSON(vars.TIMEOUT_MINUTES_SHORT) }} if: ${{ !(github.event_name == 'merge_group') }} run: if [ "${{ github.event.pull_request.user.login }}" = "clerk-cookie" ]; then echo 'Skipping' && exit 0; else npx changeset status --since=origin/main; fi + - name: Lint GitHub Actions Workflows + run: npx eslint .github + shell: bash + - name: Check Formatting timeout-minutes: ${{ fromJSON(vars.TIMEOUT_MINUTES_SHORT) }} run: npm run format:check diff --git a/.github/workflows/preview.retheme.yml b/.github/workflows/preview.retheme.yml index a443321a05c..6e584342631 100644 --- a/.github/workflows/preview.retheme.yml +++ b/.github/workflows/preview.retheme.yml @@ -29,7 +29,6 @@ jobs: id: config uses: ./.github/actions/init with: - cache-suffix: "-retheme" turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} turbo-team: ${{ vars.TURBO_TEAM }} turbo-token: ${{ secrets.TURBO_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 9dd06d27117..b3b97d4b917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5866,6 +5866,134 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz", + "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz", + "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz", + "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz", + "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz", + "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz", + "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz", + "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz", + "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "license": "MIT", @@ -32767,9 +32895,10 @@ } }, "node_modules/xstate": { - "version": "4.38.2", + "version": "4.38.3", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.38.3.tgz", + "integrity": "sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==", "dev": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" @@ -33292,6 +33421,7 @@ "dependencies": { "@clerk/nextjs": "^5.0.0-alpha-v5.12", "@radix-ui/react-form": "^0.0.3", + "@radix-ui/react-slot": "^1.0.2", "@xstate/react": "^4.0.1", "clsx": "^2.0.0", "react-children-utilities": "^2.9.0", diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index 9b17f599a2b..e598afa5cb9 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -1,18 +1,63 @@ -import { SignIn, SignInFactorOne, SignInFactorTwo, SignInSSOCallback, SignInStart } from '@clerk/elements'; +import { + Field, + Form, + Input, + Label, + SignIn, + SignInFactorOne, + SignInFactorTwo, + SignInSSOCallback, + SignInStart, + Submit, +} from '@clerk/elements'; + +import { Debug } from '@/components/debug'; export default function SignInPage() { return ( - -

Start child

-
- -

Factor one child

-
- -

Factor two child

-
- +
+ +

Start

+ +
+ + + + + + + + + + + + Sign In + +
+
+ +

Factor one child

+
+ +

Factor two child

+
+ +
+ +
); } diff --git a/packages/elements/examples/nextjs/components/debug.tsx b/packages/elements/examples/nextjs/components/debug.tsx new file mode 100644 index 00000000000..d5c56c6320f --- /dev/null +++ b/packages/elements/examples/nextjs/components/debug.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { SignedIn } from '@clerk/clerk-react'; +import { useSignInFlow } from '@clerk/elements'; +import { SignOutButton } from '@clerk/nextjs'; + +export function Button(props: React.ComponentProps<'button'>) { + return ( + + + + + + + + + + + ); +} diff --git a/packages/elements/package.json b/packages/elements/package.json index da93239f88c..c166efe1dbd 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -60,6 +60,7 @@ "dependencies": { "@clerk/nextjs": "^5.0.0-alpha-v5.12", "@radix-ui/react-form": "^0.0.3", + "@radix-ui/react-slot": "^1.0.2", "@xstate/react": "^4.0.1", "clsx": "^2.0.0", "react-children-utilities": "^2.9.0", diff --git a/packages/elements/src/common/form.tsx b/packages/elements/src/common/form.tsx new file mode 100644 index 00000000000..444493a16b7 --- /dev/null +++ b/packages/elements/src/common/form.tsx @@ -0,0 +1,33 @@ +import type { FormControlProps, FormProps } from '@radix-ui/react-form'; +import { Control, Field, Form as RadixForm, Label, Submit } from '@radix-ui/react-form'; +import { Slot } from '@radix-ui/react-slot'; + +import { useForm, useInput } from '../internals/machines/sign-in.context'; + +function Input({ asChild, ...rest }: FormControlProps) { + const { type, value } = rest; + const field = useInput({ type, value }); + + const Comp = asChild ? Slot : Control; + + return ( + + ); +} + +function Form({ asChild, ...rest }: FormProps) { + const form = useForm(); + + const Comp = asChild ? Slot : RadixForm; + return ( + + ); +} + +export { Form, Input, Field, Label, Submit }; diff --git a/packages/elements/src/index.tsx b/packages/elements/src/index.tsx index a1cc5e46c38..0fec957e5b8 100644 --- a/packages/elements/src/index.tsx +++ b/packages/elements/src/index.tsx @@ -2,5 +2,8 @@ import { useNextRouter } from './internals/router'; export * from './sign-in'; +export * from './common/form'; + +export { useSignInFlow, useSignInFlowSelector } from './internals/machines/sign-in.context'; export { useNextRouter }; diff --git a/packages/elements/src/internals/machines/sign-in.context.ts b/packages/elements/src/internals/machines/sign-in.context.ts index e3467bfa7c0..54f96d7e1bf 100644 --- a/packages/elements/src/internals/machines/sign-in.context.ts +++ b/packages/elements/src/internals/machines/sign-in.context.ts @@ -1,5 +1,94 @@ import { createActorContext } from '@xstate/react'; +import { useCallback, useEffect } from 'react'; +import type { SnapshotFrom } from 'xstate'; import { SignInMachine } from './sign-in.machine'; +import type { FieldDetails } from './sign-in.types'; -export const SignInActor = createActorContext(SignInMachine); +export const { + Provider: SignInFlowProvider, + useActorRef: useSignInFlow, + useSelector: useSignInFlowSelector, +} = createActorContext(SignInMachine); + +// TODO: Move selectors +const fieldHasValueSelector = (type: string | undefined) => (state: SnapshotFrom) => + type ? Boolean(state.context.fields.get(type)?.value) : false; + +const fieldErrorSelector = (type: string | undefined) => (state: SnapshotFrom) => + type ? Boolean(state.context.fields.get(type)?.error) : undefined; + +const globalErrorSelector = (state: SnapshotFrom) => state.context.error; + +export const useField = ({ type }: Partial>) => { + const hasValue = useSignInFlowSelector(fieldHasValueSelector(type)); + const error = useSignInFlowSelector(fieldErrorSelector(type)); + + const shouldBeHidden = false; // TODO: Implement clerk-js utils + const validity = error ? 'invalid' : 'valid'; + + return { + hasValue, + props: { + [`data-${validity}`]: true, + 'data-hidden': shouldBeHidden ? true : undefined, + tabIndex: shouldBeHidden ? -1 : 0, + }, + }; +}; + +export const useInput = ({ type, value: initialValue }: Partial>) => { + const ref = useSignInFlow(); + const hasValue = useSignInFlowSelector(fieldHasValueSelector(type)); + + useEffect(() => { + if (!type || ref.getSnapshot().context.fields.get(type)) return; + + ref.send({ type: 'FIELD.ADD', field: { type, value: initialValue } }); + + return () => ref.send({ type: 'FIELD.REMOVE', field: { type } }); + }, [ref]); // eslint-disable-line react-hooks/exhaustive-deps + + const onChange = useCallback( + (event: React.ChangeEvent) => { + if (!type) return; + ref.send({ type: 'FIELD.UPDATE', field: { type, value: event.target.value } }); + }, + [ref, type], + ); + + // TODO: Implement clerk-js utils + const shouldBeHidden = false; + + return { + hasValue, + props: { + 'data-hidden': shouldBeHidden ? true : undefined, + 'data-has-value': hasValue ? true : undefined, + onChange, + tabIndex: shouldBeHidden ? -1 : 0, + }, + }; +}; + +export const useForm = () => { + const ref = useSignInFlow(); + const error = useSignInFlowSelector(globalErrorSelector); + + const validity = error ? 'invalid' : 'valid'; + + const onSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + ref.send({ type: 'SUBMIT' }); + }, + [ref], + ); + + return { + props: { + [`data-${validity}`]: true, + onSubmit, + }, + }; +}; diff --git a/packages/elements/src/internals/machines/sign-in.machine.ts b/packages/elements/src/internals/machines/sign-in.machine.ts index b68cf133033..c3ff6c97e22 100644 --- a/packages/elements/src/internals/machines/sign-in.machine.ts +++ b/packages/elements/src/internals/machines/sign-in.machine.ts @@ -11,7 +11,36 @@ import { prepareFirstFactor, prepareSecondFactor, } from './sign-in.actors'; -import type { SignInClient } from './sign-in.types'; +import type { FieldDetails, SignInClient } from './sign-in.types'; + +export interface SignInMachineContext { + client: SignInClient; + router: ClerkHostRouter; + error?: Error | ClerkAPIResponseError; + resource?: SignInResource; + fields: Map; +} + +export interface SignInMachineInput { + client: SignInClient; + router: ClerkHostRouter; +} + +export type SignInMachineEvents = + | { type: 'START' } + | { type: 'SUBMIT' } + | { type: 'NEXT' } + | { type: 'RETRY' } + | { type: 'FIELD.ADD'; field: Pick } + | { type: 'FIELD.REMOVE'; field: Pick } + | { + type: 'FIELD.UPDATE'; + field: Pick; + } + | { + type: 'FIELD.ERROR'; + field: Pick; + }; export const STATES = { Init: 'Init', @@ -58,7 +87,7 @@ export const SignInMachine = setup({ navigateTo: ({ context }, { path }: { path: string }) => context.router.replace(path), clearFields: assign({ - fields: {}, + fields: new Map(), }), }, guards: { @@ -71,27 +100,68 @@ export const SignInMachine = setup({ : false, }, types: { - context: {} as { - client: SignInClient; - router: ClerkHostRouter; - error?: Error | ClerkAPIResponseError; - resource?: SignInResource; - fields: Record; - }, - input: {} as { - client: SignInClient; - router: ClerkHostRouter; - }, - events: {} as { type: 'START' } | { type: 'SUBMIT' } | { type: 'NEXT' } | { type: 'RETRY' } | { type: 'ASSIGN' }, + context: {} as SignInMachineContext, + input: {} as SignInMachineInput, + events: {} as SignInMachineEvents, }, }).createMachine({ context: ({ input }) => ({ client: input.client, router: input.router, currentFactor: null, - fields: {}, + fields: new Map(), }), initial: STATES.Init, + on: { + 'FIELD.ADD': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.type) throw new Error('Field type is required'); + + context.fields.set(event.field.type, event.field); + return context.fields; + }, + }), + }, + 'FIELD.UPDATE': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.type) throw new Error('Field type is required'); + + if (context.fields.has(event.field.type)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + context.fields.get(event.field.type)!.value = event.field.value; + } + + return context.fields; + }, + }), + }, + 'FIELD.REMOVE': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.type) throw new Error('Field type is required'); + + context.fields.delete(event.field.type); + return context.fields; + }, + }), + }, + 'FIELD.ERROR': { + actions: assign({ + fields: ({ context, event }) => { + if (!event.field.type) throw new Error('Field type is required'); + + if (context.fields.has(event.field.type)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + context.fields.get(event.field.type)!.error = event.field.error; + } + + return context.fields; + }, + }), + }, + }, states: { [STATES.Init]: { always: 'Start', @@ -110,8 +180,8 @@ export const SignInMachine = setup({ input: ({ context }) => ({ client: context.client, params: { - identifier: 'tom@clerk.dev', - password: 'tom@clerk.dev', + identifier: context.fields.get('identifier')?.value as string, + password: context.fields.get('identifier')?.value as string, strategy: 'password', }, }), diff --git a/packages/elements/src/internals/machines/sign-in.types.ts b/packages/elements/src/internals/machines/sign-in.types.ts index 526335aceca..c6f873868f0 100644 --- a/packages/elements/src/internals/machines/sign-in.types.ts +++ b/packages/elements/src/internals/machines/sign-in.types.ts @@ -1,5 +1,11 @@ import type { ClientResource } from '@clerk/types'; +export type FieldDetails = { + type?: string; + value?: string | readonly string[] | number; + error?: Error; +}; + export type SignInClient = ClientResource; export type SignInResourceParams = { diff --git a/packages/elements/src/sign-in/index.tsx b/packages/elements/src/sign-in/index.tsx index ce8c432f39b..0b3fa247f86 100644 --- a/packages/elements/src/sign-in/index.tsx +++ b/packages/elements/src/sign-in/index.tsx @@ -3,7 +3,7 @@ import { useClerk } from '@clerk/nextjs'; import { useEffect } from 'react'; -import { SignInActor } from '../internals/machines/sign-in.context'; +import { SignInFlowProvider, useSignInFlow } from '../internals/machines/sign-in.context'; import { useNextRouter } from '../internals/router'; import { Route, Router } from '../internals/router-react'; @@ -25,43 +25,23 @@ export function SignIn({ children }: { children: React.ReactNode }): JSX.Element router={router} basePath='/sign-in' > - {children} + {children} ); } -export function SubmitButton() { - const ref = SignInActor.useActorRef(); - // const fields = SignInRootMachine.useSelector((state) => state.context.fields['identifier']); - // console.log(ref.getSnapshot()); - - return ( - - ); -} - export function SignInStartInner({ children }: { children: React.ReactNode }) { - const ref = SignInActor.useActorRef(); + const ref = useSignInFlow(); useEffect(() => ref.send({ type: 'START' }), [ref]); - return <>{children}; + return children; } export function SignInStart({ children }: { children: React.ReactNode }) { return ( - -

Start

- {children} - -
+ {children}
); }