Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ARG Z2JH_VERSION=4.3.1
# then copy over just the built wheel files for the final image.
# This keeps image size low while also making the whole process
# reasonably simple
FROM quay.io/jupyterhub/k8s-hub:${Z2JH_VERSION} as builder
FROM quay.io/jupyterhub/k8s-hub:${Z2JH_VERSION} AS builder

USER root

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Here are some quick links:

1. **Enhanced Profile Selection** - Better looking UI for KubeSpawner's `profileList` with rich descriptions
2. **Dynamic Image Building** - Optional integration with BinderHub to build images on-demand
3. **Permalinks and Auto-Start** - Automatically start pre-defined environments via permalinks.

See the [User Guide](./docs/guide.md) for more details.

Expand Down
2 changes: 2 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The package interprets your existing KubeSpawner `profile_list` configuration an
- Better looking and more featureful interface
- Descriptions for various options
- Better descriptions for "write-in" choices
- Permalink generation for pre-selected profile configurations
- Auto-Start via Permalink Feature

You configure profiles the same way as standard KubeSpawner—`jupyterhub-fancy-profiles` provides the enhanced UI!

Expand Down
96 changes: 95 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ When enabled, integrates with BinderHub (deployed as a JupyterHub service) to al
:::

:::{card} ⚙️ Standard KubeSpawner config
Works with your existing KubeSpawner configuration—no need to change how you define profiles.
Works with your existing KubeSpawner configuration, no need to change how you define profiles.
:::

:::{card} 🔗 Auto-start permalinks
Generate shareable URLs that automatically launch servers with pre-configured options, perfect for workshops, courses, or shared environments.
:::

::::
Expand Down Expand Up @@ -85,6 +89,96 @@ View source and report issues

---

## Auto-start feature

The auto-start feature allows you to create shareable URLs that automatically launch a JupyterHub server with pre-configured settings. This is particularly useful for:

- **Workshops and tutorials**: Provide participants with a link that starts their environment with the exact configuration needed
- **Course materials**: Embed links in course content that launch students directly into the right environment
- **Shared environments**: Create standardized setups for teams or projects

### How it works

1. Configure your desired server options in the profile form (profile type, image, resources, etc.)
2. Click the "Copy Link" button to copy a permalink to your clipboard
3. The copied URL includes `autoStart=false` by default, change this to `autoStart=true` in the URL to enable automatic launching
4. Share the URL with others or bookmark it for yourself

When someone visits a URL with `autoStart=true`, the form will automatically populate with the saved configuration and submit itself after a brief timeout, launching the server immediately.

```{tip}
The permalink button automatically sets `autoStart=false` to make it easy to create auto-start URLs. Simply change `false` to `true` in the copied URL to enable the auto-start behavior.
```

### Advanced: Combining with nbgitpuller URLs

If your JupyterHub environment has [nbgitpuller](https://github.com/jupyterhub/nbgitpuller) installed, you can combine auto-start permalinks with git-pull functionality to create URLs that both clone a repository and launch a pre-configured server. The nbgitpuller extension enables the `/hub/user-redirect/git-pull` URLs and automatically handles login redirects.

This is especially useful for distributing workshop materials or course notebooks where you want participants to have both the right environment and the right content.

Here's a Python script that generates such URLs:

```python
import urllib.parse
import re

# Configuration
notebook_url = "https://github.com/org/repo/blob/branch/notebooks/example.ipynb"
permalink = "" # Permalink copied from fancy profiles form
jupyterhub_url = "https://jupyterhub.example.org"

# Extract repository information from notebook URL
git_repo_url = notebook_url.split("/blob/")[0]
branch = notebook_url.split("/blob/")[1].split("/")[0]
notebook_path = "/".join(notebook_url.split("/blob/")[1].split("/")[1:])
notebook_suburl = git_repo_url.split("/")[-1] + "/" + notebook_path

# Build git-pull URL (requires nbgitpuller)
gitpull_next = (
f"/hub/user-redirect/git-pull"
f"?repo={git_repo_url}"
f"&branch={branch}"
f"&urlpath=lab/tree/{notebook_suburl}"
)

# Build spawn URL with git-pull as next parameter
spawn_next = "/hub/spawn?next=" + urllib.parse.quote(gitpull_next, safe="")

# Extract fancy-forms-config from permalink and enable auto-start
config_match = re.search(r"%23.+?(?=%7D)", permalink)
if config_match:
fancy_forms_config = config_match[0].replace(
"%22autoStart%22%3A%22false%22",
"%22autoStart%22%3A%22true%22"
)
# Construct final URL
final_url = (
f"{jupyterhub_url}/hub/login"
f"?next={urllib.parse.quote(spawn_next, safe='')}"
f"{fancy_forms_config}%7D"
)
print(final_url)
else:
print("Error: Could not extract config from permalink")
```

**How it works:**

1. The script extracts the git repository URL and branch from a GitHub notebook URL
2. It creates a git-pull URL that clones the repository and opens the specified notebook (enabled by nbgitpuller)
3. It combines this with the fancy profiles configuration from your permalink
4. It sets `autoStart=true` to automatically launch the server
5. The nbgitpuller extension handles the login redirect flow automatically
6. The result is a single URL that authenticates users, clones the repository, configures the server environment, and launches it automatically

```{note}
This requires [nbgitpuller](https://github.com/jupyterhub/nbgitpuller) to be installed in your JupyterHub environment. nbgitpuller provides the `/hub/user-redirect/git-pull` endpoint and handles the authentication redirect flow.
```

**Example use case:** Share a link with workshop participants that gives them the right computational environment and opens the workshop notebook automatically.

---

## Compatibility

```{note}
Expand Down
4 changes: 4 additions & 0 deletions setupTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();

HTMLCanvasElement.prototype.getContext = () => {};

// Mock window.scrollTo
window.scrollTo = jest.fn();

Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
Expand Down
86 changes: 61 additions & 25 deletions src/ImageBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import renderWithContext from "./test/renderWithContext";
test("select repository by org/repo", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needing to wrap the <form> around the <ProfileForm> in the tests makes sense to me - since fancy-profiles gets "injected" onto a page that already contains the container <form> tag, the <ProfileForm> expects to be rendered inside an existing <form> - since that does not exist during tests, we add it this way.

This looks a little awkward, but makes sense to me. Just giving @hanbyul-here some context while reviewing - not sure if there's a way to do this that feels more elegant, but this seems okay to me.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 imo, it is ideal if the test's scope is narrowed enough that the test doesn't have to cover any unnecessary tasks. Most tests were scoped down well enough that it doesn't need additional wrapper. I understand some tests might need this wrapper now since this PR makes form submission this application's responsibility. but I still feel like we can get rid of this additional wrapper for the ones that don't need one?

I think one of the biggest change this PR introduces is the programatic form submission -
There’s something nice about letting HTML handle form submission — it keeps the application’s responsibility focused on processing and cleaning the data. This PR expands that responsibility. That’s not necessarily a bad thing, but we would at least need a test for it.
Maybe we can add an explicit test to check if form submission worked with this additional wrapper?

<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -33,7 +37,11 @@ test("select repository by org/repo", async () => {
test("select repository by https://github.com/org/repo", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -58,7 +66,11 @@ test("select repository by https://github.com/org/repo", async () => {
test("select repository by https://www.github.com/org/repo", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -83,7 +95,11 @@ test("select repository by https://www.github.com/org/repo", async () => {
test("select repository by github.com/org/repo", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -108,7 +124,11 @@ test("select repository by github.com/org/repo", async () => {
test("select repository by www.github.com/org/repo", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -132,7 +152,11 @@ test("select repository by www.github.com/org/repo", async () => {
test("invalid org/repo string (not matching pattern)", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -148,16 +172,16 @@ test("invalid org/repo string (not matching pattern)", async () => {
await user.click(document.body);

expect(
screen.getByText(
screen.getAllByText(
"Provide the repository as the format 'organization/repository'.",
),
);
).length,
).toBeGreaterThan(0);
});

test("repofield trims leading/trailing spaces", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(<form><ProfileForm /></form>);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -171,15 +195,15 @@ test("repofield trims leading/trailing spaces", async () => {
const repoField = screen.getByLabelText("Repository");
await user.type(repoField, " extra/spaces ");
await user.click(document.body);
await user.click(screen.getByRole("button", { name: "Build image" }));
await user.click(screen.getByRole("button", { name: "Build Image and Start" }));

expect(repoField).toHaveValue("extra/spaces");
});

test("ref trims leading/trailing spaces", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(<form><ProfileForm /></form>);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -194,15 +218,19 @@ test("ref trims leading/trailing spaces", async () => {
await user.clear(refField);
await user.type(refField, " branch ");
await user.click(document.body);
await user.click(screen.getByRole("button", { name: "Build image" }));
await user.click(screen.getByRole("button", { name: "Build Image and Start" }));

expect(refField).toHaveValue("branch");
});

test("invalid org/repo string (wrong base URL)", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -218,16 +246,20 @@ test("invalid org/repo string (wrong base URL)", async () => {
await user.click(document.body);

expect(
screen.getByText(
screen.getAllByText(
"Provide the repository as the format 'organization/repository'.",
),
);
).length,
).toBeGreaterThan(0);
});

test("no org/repo provided", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -237,19 +269,23 @@ test("no org/repo provided", async () => {
await user.click(select);

await user.click(screen.getByText("Build your own image"));
await user.click(screen.getByRole("button", { name: "Build image" }));
await user.click(screen.getByRole("button", { name: "Build Image and Start" }));

expect(
screen.getByText(
screen.getAllByText(
"Provide the repository as the format 'organization/repository'.",
),
);
).length,
).toBeGreaterThan(0);
});

test("no branch selected", async () => {
const user = userEvent.setup();

renderWithContext(<ProfileForm />);
renderWithContext(
<form>
<ProfileForm />
</form>
);
const radio = screen.getByRole("radio", {
name: "CPU only No GPU, only CPU",
});
Expand All @@ -265,7 +301,7 @@ test("no branch selected", async () => {
await user.click(document.body);

await user.clear(screen.queryByLabelText("Git Ref"));
await user.click(screen.getByRole("button", { name: "Build image" }));
await user.click(screen.getByRole("button", { name: "Build Image and Start" }));

expect(screen.getByText("Enter a git ref."));
expect(screen.getAllByText("Enter a git ref.").length).toBeGreaterThan(0);
});
Loading