Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/petite-clubs-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/clerk-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"dequal": "2.0.3"
},
"devDependencies": {
"@clerk/msw": "workspace:^",
"@clerk/testing": "workspace:^",
"@rsdoctor/rspack-plugin": "^0.4.13",
"@rspack/cli": "^1.6.0",
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ const devConfig = ({ mode, env }) => {
...(isSandbox
? {
historyApiFallback: true,
static: ['sandbox/public'],
}
: {}),
},
Expand Down
41 changes: 41 additions & 0 deletions packages/clerk-js/sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `clerk-js` Sandbox

This folder contains a sandbox environment for iterating on the Clerk UI components. Each main top-level component gets its own page.

## Running the sandbox

You can start the sandbox by running `pnpm dev:sandbox` **in the root of the `javascript` repo**. This will start the server on <a href="http://localhost:4000"><code>http://localhost:4000</code></a>. It will also run the development server for `@clerk/ui`.

## Setting component props

You can pass specific props to a given component by running the following in the console:

```
components.<componentName>.setProps({ ... });
```

For example, to set props for the `SignIn` component:

```js
components.signIn.setProps({
/* ... */
});
```

Doing so will change the URL of the page you're on to include the configured props as a URL query parameter. This allows you to share a link to the specific configuration of the props you've set.

## Activating API mocking scenarios

You can also activate specific API mocking scenarios to avoid making calls to the Clerk API. Activate a scenario with the following:

```js
scenario.setScenario('ScenarioName');
```

You can also use `scenario.availableScenarios` to see a list of valid scenarios. You can also pass this to `setScenario`:

```js
scenario.setScenario(scenario.UserButtonLoggedIn);
```

Like `setProps`, this command will persist the active scenario to the URL.
122 changes: 95 additions & 27 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { PageMocking, type MockScenario } from '@clerk/msw';
import * as l from '../../localizations';
import type { Clerk as ClerkType } from '../';

const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];

function fillLocalizationSelect() {
const select = document.getElementById('localizationSelect') as HTMLSelectElement;

for (const locale of AVAILABLE_LOCALES) {
if (locale === 'enUS') {
select.add(new Option(locale, locale, true, true));
continue;
}

select.add(new Option(locale, locale));
}
}
import * as scenarios from './scenarios';

interface ComponentPropsControl {
setProps: (props: unknown) => void;
getProps: () => any | null;
}

interface ScenarioControls {
setScenario: (scenario: AvailableScenario | null) => void;
availableScenarios: typeof AVAILABLE_SCENARIOS;
}

const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';

const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];

const AVAILABLE_COMPONENTS = [
'clerk', // While not a component, we want to support passing options to the Clerk class.
'signIn',
Expand All @@ -39,17 +35,57 @@ const AVAILABLE_COMPONENTS = [
'taskChooseOrganization',
'taskResetPassword',
] as const;
type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number];

const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
const AVAILABLE_SCENARIOS = Object.keys(scenarios) as (keyof typeof scenarios)[];
type AvailableScenario = (typeof AVAILABLE_SCENARIOS)[number];

const urlParams = new URL(window.location.href).searchParams;
for (const [component, encodedProps] of urlParams.entries()) {
if (AVAILABLE_COMPONENTS.includes(component as (typeof AVAILABLE_COMPONENTS)[number])) {
localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-${component}`, encodedProps);
function fillLocalizationSelect() {
const select = document.getElementById('localizationSelect') as HTMLSelectElement;

for (const locale of AVAILABLE_LOCALES) {
if (locale === 'enUS') {
select.add(new Option(locale, locale, true, true));
continue;
}

select.add(new Option(locale, locale));
}
}

function getScenario(): (() => MockScenario) | null {
const scenarioName = localStorage.getItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`);
if (scenarioName && AVAILABLE_SCENARIOS.includes(scenarioName as AvailableScenario)) {
return scenarios[scenarioName as AvailableScenario];
}
return null;
}

function setScenario(scenario: AvailableScenario | null) {
if (!scenario) {
localStorage.removeItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`);
const url = new URL(window.location.href);
url.searchParams.delete('scenario');
window.location.href = url.toString();
return;
}

if (!AVAILABLE_SCENARIOS.includes(scenario)) {
throw new Error(`Invalid scenario: "${scenario}". Available scenarios: ${AVAILABLE_SCENARIOS.join(', ')}`);
}
localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`, scenario);

const url = new URL(window.location.href);
url.searchParams.set('scenario', scenario);
window.location.href = url.toString();
}

function setComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number], props: unknown) {
const scenarioControls: ScenarioControls = {
setScenario,
availableScenarios: AVAILABLE_SCENARIOS,
};

function setComponentProps(component: AvailableComponent, props: unknown) {
const encodedProps = JSON.stringify(props);

const url = new URL(window.location.href);
Expand All @@ -58,7 +94,7 @@ function setComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number], pro
window.location.href = url.toString();
}

function getComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number]): unknown | null {
function getComponentProps(component: AvailableComponent): unknown | null {
const url = new URL(window.location.href);
const encodedProps = url.searchParams.get(component);
if (encodedProps) {
Expand All @@ -73,7 +109,7 @@ function getComponentProps(component: (typeof AVAILABLE_COMPONENTS)[number]): un
return null;
}

function buildComponentControls(component: (typeof AVAILABLE_COMPONENTS)[number]): ComponentPropsControl {
function buildComponentControls(component: AvailableComponent): ComponentPropsControl {
return {
setProps(props) {
setComponentProps(component, props);
Expand All @@ -84,7 +120,7 @@ function buildComponentControls(component: (typeof AVAILABLE_COMPONENTS)[number]
};
}

const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], ComponentPropsControl> = {
const componentControls: Record<AvailableComponent, ComponentPropsControl> = {
clerk: buildComponentControls('clerk'),
signIn: buildComponentControls('signIn'),
signUp: buildComponentControls('signUp'),
Expand All @@ -105,11 +141,21 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component

declare global {
interface Window {
components: Record<(typeof AVAILABLE_COMPONENTS)[number], ComponentPropsControl>;
components: Record<AvailableComponent, ComponentPropsControl>;
scenario: typeof scenarioControls;
AVAILABLE_SCENARIOS: Record<AvailableScenario, AvailableScenario>;
}
}

window.components = componentControls;
window.scenario = scenarioControls;
window.AVAILABLE_SCENARIOS = AVAILABLE_SCENARIOS.reduce(
(acc, scenario) => {
acc[scenario] = scenario;
return acc;
},
{} as Record<AvailableScenario, AvailableScenario>,
);

const Clerk = window.Clerk;
function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
Expand All @@ -118,8 +164,6 @@ function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
}
}

const app = document.getElementById('app') as HTMLDivElement;

function mountIndex(element: HTMLDivElement) {
assertClerkIsLoaded(Clerk);
const user = Clerk.user;
Expand Down Expand Up @@ -267,6 +311,17 @@ function otherOptions() {
return { updateOtherOptions };
}

const urlParams = new URL(window.location.href).searchParams;
for (const [component, encodedProps] of urlParams.entries()) {
if (AVAILABLE_COMPONENTS.includes(component as AvailableComponent)) {
localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-${component}`, encodedProps);
}

if (component === 'scenario' && AVAILABLE_SCENARIOS.includes(encodedProps as AvailableScenario)) {
localStorage.setItem(`${COMPONENT_PROPS_NAMESPACE}-scenario`, encodedProps);
}
}

void (async () => {
assertClerkIsLoaded(Clerk);
fillLocalizationSelect();
Expand All @@ -280,6 +335,8 @@ void (async () => {
}
});

const app = document.getElementById('app') as HTMLDivElement;

const routes = {
'/': () => {
mountIndex(app);
Expand Down Expand Up @@ -373,6 +430,17 @@ void (async () => {
if (route in routes) {
const renderCurrentRoute = routes[route];
addCurrentRouteIndicator(route);

const scenario = getScenario();
if (scenario) {
const mocking = new PageMocking({
onStateChange: state => {
console.log('Mocking state changed:', state);
},
});
await mocking.initialize(route, { scenario });
}

await Clerk.load({
...(componentControls.clerk.getProps() ?? {}),
signInUrl: '/sign-in',
Expand Down
Loading
Loading