Skip to content

Static Webapps

Static webapps host an HTML/CSS/JavaScript bundle inside an OpenHEXA workspace, served on its own subdomain, and can query OpenHEXA on behalf of viewing users via a pre-authenticated GraphQL endpoint — great for dashboards and custom forms.

OpenHEXA supports three webapp types:

  • Static (this page): you provide the source files; OpenHEXA serves them and proxies API access for you.
  • Iframe: OpenHEXA embeds an external URL.
  • Superset: OpenHEXA embeds a Superset dashboard.

Creating a static webapp

From the workspace UI: Web Apps → Create → Static, then either drop in your files or start from the default index.html template.

Programmatically: use the create_static_webapp MCP tool, or the GraphQL createWebapp mutation with a static source carrying a list of {path, content} files. An index.html at the root is required.

Project structure

Files live at the paths you provide. index.html is the entry point; everything else (CSS, JS, images, JSON fixtures) is served as-is from the same origin.

index.html
app.js
style.css
assets/logo.svg

Reference assets with relative paths (<script src="app.js">, <link href="style.css">) so they work both in preview and once published.

URL and subdomain

Each webapp gets a subdomain under the workspace's webapps domain — e.g. my-webapp.webapps.example.com. The subdomain defaults to the slug derived from the webapp name and can be edited in the webapp settings.

Public vs private

  • Private (default): viewers must be authenticated workspace members. The browser session cookie is what authorises requests, so private static webapps can also call OpenHEXA's GraphQL API directly (see below).
  • Public: anyone with the URL can view the webapp. Public webapps cannot call the GraphQL proxy — if you need to expose workspace data publicly, generate a static export from a pipeline and serve it as a file inside the webapp.

Calling the OpenHEXA GraphQL API

Private static webapps can call the platform's GraphQL API directly from their JavaScript code to read and write workspace data. The rest of this page covers that API in detail — public webapps and iframe webapps cannot use this endpoint.

How it works

  • Endpoint: POST /graphql/ on the webapp's own URL (same-origin). For example, a webapp served at https://my-webapp.webapps.example.com/ calls https://my-webapp.webapps.example.com/graphql/.
  • Authentication: handled by the webapp session — the user's browser session cookie is attached automatically. Do not send Authorization headers; do not embed tokens in your code.
  • Origin check: only requests whose Origin matches the webapp's own origin are allowed. Cross-site calls are rejected.
  • Scope-gated: each webapp declares an allowed_operations list. Only GraphQL top-level fields covered by those scopes are allowed; everything else returns a 403.
  • JSON body: same as any GraphQL POST — {"query": "...", "variables": {...}}.

Enabling the API on a webapp

By default a static webapp has an empty allowed_operations list, which means it cannot call the API. Enable scopes from the workspace UI ("Webapp settings → API access") or via the GraphQL updateWebapp mutation.

Scope reference

Scope What it grants
USER_READ me, workspace
PIPELINES_READ pipeline, pipelines, pipelineByCode, pipelineRun, pipelineVersion
PIPELINES_RUN runPipeline, stopPipeline
FILES_READ getFileByPath, readFileContent, prepareObjectDownload
FILES_WRITE prepareObjectUpload, createBucketFolder, writeFileContent
DATASETS_READ dataset, datasets, datasetVersion, datasetLink
DATASETS_WRITE createDataset, updateDataset, createDatasetVersion, updateDatasetVersion, createDatasetVersionFile

Introspection fields __typename, __schema, __type are always allowed.

Exploring the schema

The fastest way to design queries for a webapp is the interactive GraphQL playground at https://app.openhexa.org/graphql/ (or /graphql/ on your own install).

Note that the playground shows the full schema, not just what the webapp proxy allows. A query that works there can still return 403 from a webapp at runtime if its top-level field isn't covered by the webapp's scopes — cross-check before pasting into webapp code.

The window.OPENHEXA global

When OpenHEXA serves your static webapp's HTML it injects a small script before </head> that exposes:

window.OPENHEXA = Object.freeze({
  workspaceSlug: "my-workspace",   // slug of the workspace owning this webapp
  webappSlug: "my-webapp",         // this webapp's own slug
  isPublic: false,                 // true for public webapps
});

The examples below read workspaceSlug from this global, so they're copy-pasteable into any webapp without having to edit a constant. The injection only touches text/html responses; CSS, JS, and JSON files are untouched.


Example webapps

Each example below is a complete index.html you can drop into a static webapp. Every example inlines the same tiny gql() helper so it's standalone, and reads its workspace slug from window.OPENHEXA.

USER_READ — Who am I?

Displays the current user and workspace on load.

Schema
type Query {
  me: Me!
  workspace(slug: String!): Workspace
}

type Me {
  user: User
  features: [FeatureFlag!]!
  permissions: MePermissions!
}

type User {
  id: UUID!
  email: String!
  firstName: String
  lastName: String
  displayName: String!
  language: String!
  avatar: Avatar!
}

type Workspace {
  slug: String!
  name: String!
  description: String
  countries: [Country!]!
  organization: Organization
  createdAt: DateTime!
  updatedAt: DateTime
  createdBy: User!
}

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Who am I?</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
    pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; }
  </style>
</head>
<body>
  <h1>Who am I?</h1>
  <pre id="out">Loading…</pre>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    (async () => {
      const data = await gql(`
        query($slug: String!) {
          me { user { id email displayName } }
          workspace(slug: $slug) { slug name description }
        }
      `, { slug: workspaceSlug });
      document.getElementById("out").textContent = JSON.stringify(data, null, 2);
    })();
  </script>
</body>
</html>

PIPELINES_READ — List pipelines

Lists every pipeline in the workspace.

Schema
type Query {
  pipelines(
    workspaceSlug: String
    name: String
    search: String
    tags: [String!]
    functionalType: PipelineFunctionalType
    lastRunStates: [PipelineRunStatus!]
    page: Int
    perPage: Int
    orderBy: PipelineOrderBy
  ): PipelinesPage!
}

type PipelinesPage {
  items: [Pipeline!]!
  pageNumber: Int!
  totalPages: Int!
  totalItems: Int!
}

type Pipeline {
  id: UUID!
  code: String!
  name: String
  description: String
  schedule: String
  currentVersion: PipelineVersion
  type: PipelineType!
}

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Pipelines</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    li { margin-bottom: 0.75rem; }
    code { background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px; }
  </style>
</head>
<body>
  <h1>Pipelines</h1>
  <ul id="list"><li>Loading…</li></ul>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    (async () => {
      const { pipelines } = await gql(`
        query($slug: String!) {
          pipelines(workspaceSlug: $slug, page: 1, perPage: 50) {
            items { id code name description schedule }
          }
        }
      `, { slug: workspaceSlug });

      const list = document.getElementById("list");
      list.innerHTML = "";
      for (const p of pipelines.items) {
        const li = document.createElement("li");
        li.innerHTML = `<strong>${p.name}</strong> <code>${p.code}</code><br><small>${p.description ?? ""}</small>`;
        list.appendChild(li);
      }
    })();
  </script>
</body>
</html>

PIPELINES_READ + PIPELINES_RUN — Pick a pipeline and run it

Loads the list of pipelines on page open, lets you pick one from a dropdown, and runs it with a JSON config. Polls the run status until it terminates. Requires both PIPELINES_READ (to list) and PIPELINES_RUN (to launch).

Schema
type Query {
  pipelines(workspaceSlug: String, page: Int, perPage: Int): PipelinesPage!
  pipeline(id: UUID!): Pipeline
  pipelineRun(id: UUID!): PipelineRun
}

type Mutation {
  runPipeline(input: RunPipelineInput): RunPipelineResult!
  stopPipeline(input: StopPipelineInput!): StopPipelineResult!
}

input RunPipelineInput {
  id: UUID!
  versionId: UUID
  config: JSON!
  sendMailNotifications: Boolean
  enableDebugLogs: Boolean
}

input StopPipelineInput {
  runId: UUID!
}

type RunPipelineResult {
  success: Boolean!
  errors: [PipelineError!]!
  run: PipelineRun
}

type StopPipelineResult {
  success: Boolean!
  errors: [PipelineError!]!
}

type PipelineRun {
  id: UUID!
  status: PipelineRunStatus!
  progress: Int!
  executionDate: DateTime
  duration: Int
  outputs: [PipelineRunOutput!]!
}

union PipelineRunOutput = BucketObject | GenericOutput | DatabaseTable

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Run a pipeline</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
    label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; }
    select, textarea { width: 100%; padding: 0.4rem; box-sizing: border-box; font-family: inherit; }
    button { margin-top: 1rem; padding: 0.5rem 1rem; }
    button[disabled] { opacity: 0.5; cursor: not-allowed; }
    #status { margin-top: 1rem; font-weight: 600; }
  </style>
</head>
<body>
  <h1>Run a pipeline</h1>

  <label>Pipeline
    <select id="pipeline" disabled><option>Loading…</option></select>
  </label>
  <label>Config (JSON)
    <textarea id="cfg" rows="4">{}</textarea>
  </label>
  <button id="runBtn" disabled onclick="runIt()">Run</button>

  <p id="status"></p>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    // 1. Load the pipeline list and populate the dropdown.
    (async () => {
      const { pipelines } = await gql(`
        query($slug: String!) {
          pipelines(workspaceSlug: $slug, page: 1, perPage: 100) {
            items { id code name }
          }
        }
      `, { slug: workspaceSlug });

      const select = document.getElementById("pipeline");
      if (!pipelines.items.length) {
        select.innerHTML = '<option>(no pipelines in this workspace)</option>';
        return;
      }
      select.innerHTML = pipelines.items
        .map(p => `<option value="${p.id}">${p.name} (${p.code})</option>`)
        .join("");
      select.disabled = false;
      document.getElementById("runBtn").disabled = false;
    })();

    // 2. Run the selected pipeline and poll until it terminates.
    async function runIt() {
      const id = document.getElementById("pipeline").value;
      const config = JSON.parse(document.getElementById("cfg").value || "{}");
      const status = document.getElementById("status");

      const { runPipeline } = await gql(`
        mutation($input: RunPipelineInput!) {
          runPipeline(input: $input) {
            success errors run { id status }
          }
        }
      `, { input: { id, config } });

      if (!runPipeline.success) {
        status.textContent = "Error: " + runPipeline.errors.join(", ");
        return;
      }

      const runId = runPipeline.run.id;
      status.textContent = "Running…";

      while (true) {
        await new Promise(r => setTimeout(r, 2000));
        const { pipelineRun } = await gql(
          `query($id: UUID!) { pipelineRun(id: $id) { status } }`,
          { id: runId },
        );
        status.textContent = "Status: " + pipelineRun.status;
        if (["SUCCESS", "FAILED", "STOPPED"].includes(pipelineRun.status)) break;
      }
    }
  </script>
</body>
</html>

USER_READ + FILES_READ — Pick a CSV from the workspace bucket and preview it

Lists CSV files in the workspace bucket on page load, lets you pick one from a dropdown, and renders the first 100 lines as a table. Requires both USER_READ (to list files via workspace.bucket.objects) and FILES_READ (to read content).

Schema
type Query {
  getFileByPath(workspaceSlug: String!, path: String!): BucketObject
  readFileContent(
    workspaceSlug: String!
    filePath: String!
    startLine: Int
    endLine: Int
  ): ReadFileContentResult!
}

type Mutation {
  prepareObjectDownload(input: PrepareObjectDownloadInput!): PrepareObjectDownloadResult!
}

input PrepareObjectDownloadInput {
  workspaceSlug: String!
  objectKey: String!
  forceAttachment: Boolean = true
}

type BucketObject {
  key: String!
  name: String!
  path: String!
  size: BigInt
  updatedAt: DateTime
  type: BucketObjectType!
}

type ReadFileContentResult {
  success: Boolean!
  errors: [ReadFileContentError!]!
  content: String
  size: Int
}

type PrepareObjectDownloadResult {
  success: Boolean!
  downloadUrl: URL
  errors: [PrepareObjectDownloadError!]!
}

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Preview CSV</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
    label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; }
    select { width: 100%; padding: 0.4rem; box-sizing: border-box; font-family: inherit; }
    button { margin-top: 0.75rem; padding: 0.5rem 1rem; }
    button[disabled] { opacity: 0.5; cursor: not-allowed; }
    table { border-collapse: collapse; margin-top: 1rem; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; font-size: 0.9rem; }
    th { background: #f5f5f5; }
    #status { margin-top: 1rem; color: #b91c1c; }
  </style>
</head>
<body>
  <h1>Preview CSV</h1>

  <label>File
    <select id="file" disabled><option>Loading…</option></select>
  </label>
  <button id="loadBtn" disabled onclick="load()">Load</button>

  <p id="status"></p>
  <div id="table"></div>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    // 1. List CSV files in the bucket and populate the dropdown.
    (async () => {
      const { workspace } = await gql(`
        query($slug: String!) {
          workspace(slug: $slug) {
            bucket {
              objects(page: 1, perPage: 200, ignoreHiddenFiles: true) {
                items { key name path type }
              }
            }
          }
        }
      `, { slug: workspaceSlug });

      const csvFiles = workspace.bucket.objects.items
        .filter(o => o.type === "FILE" && o.name.toLowerCase().endsWith(".csv"));

      const select = document.getElementById("file");
      if (!csvFiles.length) {
        select.innerHTML = '<option>(no CSV files in this workspace)</option>';
        return;
      }
      select.innerHTML = csvFiles
        .map(f => `<option value="${f.key}">${f.key}</option>`)
        .join("");
      select.disabled = false;
      document.getElementById("loadBtn").disabled = false;
    })();

    // 2. Fetch and render the selected file as a table.
    async function load() {
      const path = document.getElementById("file").value;
      const status = document.getElementById("status");
      const out = document.getElementById("table");
      status.textContent = "";
      out.innerHTML = "";

      const { readFileContent } = await gql(`
        query($slug: String!, $path: String!) {
          readFileContent(workspaceSlug: $slug, filePath: $path, startLine: 1, endLine: 100) {
            success content
          }
        }
      `, { slug: workspaceSlug, path });

      if (!readFileContent.success || !readFileContent.content) {
        status.textContent = "Could not read this file (it may be empty or unreadable as text).";
        return;
      }

      const rows = readFileContent.content.trim().split("\n").map(l => l.split(","));
      const [header, ...body] = rows;
      out.innerHTML = [
        "<table><thead><tr>",
        header.map(h => `<th>${h}</th>`).join(""),
        "</tr></thead><tbody>",
        body.map(r => "<tr>" + r.map(c => `<td>${c}</td>`).join("") + "</tr>").join(""),
        "</tbody></table>",
      ].join("");
    }
  </script>
</body>
</html>

FILES_WRITE — Upload a file to the workspace bucket

Pick a file, upload it via a presigned URL.

Schema
type Mutation {
  prepareObjectUpload(input: PrepareObjectUploadInput!): PrepareObjectUploadResult!
  writeFileContent(input: WriteFileContentInput!): WriteFileContentResult!
  createBucketFolder(input: CreateBucketFolderInput!): CreateBucketFolderResult!
}

input PrepareObjectUploadInput {
  workspaceSlug: String!
  objectKey: String!
  contentType: String
}

input WriteFileContentInput {
  workspaceSlug: String!
  filePath: String!
  content: String!
  overwrite: Boolean = false
}

input CreateBucketFolderInput { workspaceSlug: String!, folderKey: String! }

type PrepareObjectUploadResult {
  uploadUrl: URL
  headers: JSON
  success: Boolean!
  errors: [PrepareObjectUploadError!]!
}

type WriteFileContentResult { success: Boolean!, errors: [WriteFileContentError!]!, filePath: String, size: Int }
type CreateBucketFolderResult { success: Boolean!, errors: [CreateBucketFolderError!]!, folder: BucketObject }

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Upload a file</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
    input { width: 100%; padding: 0.4rem; box-sizing: border-box; }
    button { margin-top: 0.5rem; padding: 0.5rem 1rem; }
    #status { margin-top: 1rem; font-weight: 600; }
  </style>
</head>
<body>
  <h1>Upload a file</h1>

  <label>Destination key (path in the bucket)
    <input id="key" value="uploads/example.bin">
  </label>
  <input type="file" id="file">
  <button onclick="upload()">Upload</button>

  <p id="status"></p>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    async function upload() {
      const status = document.getElementById("status");
      const blob = document.getElementById("file").files[0];
      const key = document.getElementById("key").value.trim();
      if (!blob) { status.textContent = "Pick a file first."; return; }

      const { prepareObjectUpload } = await gql(`
        mutation($input: PrepareObjectUploadInput!) {
          prepareObjectUpload(input: $input) { success uploadUrl headers }
        }
      `, { input: { workspaceSlug, objectKey: key, contentType: blob.type } });

      const res = await fetch(prepareObjectUpload.uploadUrl, {
        method: "PUT",
        headers: { ...prepareObjectUpload.headers, "Content-Type": blob.type },
        body: blob,
      });
      status.textContent = res.ok ? "Uploaded ✓" : `Failed: HTTP ${res.status}`;
    }
  </script>
</body>
</html>

DATASETS_READ — List datasets

Lists datasets visible to the workspace and their latest version.

Schema
type Query {
  datasets(query: String, page: Int = 1, perPage: Int = 15): DatasetPage!
  dataset(id: ID!): Dataset
  datasetVersion(id: ID!): DatasetVersion
  datasetLink(id: ID!): DatasetLink
}

type DatasetPage {
  totalPages: Int!
  totalItems: Int!
  pageNumber: Int!
  items: [Dataset!]!
}

type Dataset {
  id: ID!
  slug: String!
  name: String!
  description: String
  createdAt: DateTime!
  updatedAt: DateTime!
  createdBy: User
  workspace: Workspace
  versions(page: Int = 1, perPage: Int = 15): DatasetVersionPage!
  latestVersion: DatasetVersion
  links(page: Int = 1, perPage: Int = 15): DatasetLinkPage!
}

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Datasets</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    li { margin-bottom: 0.75rem; }
    small { color: #666; }
  </style>
</head>
<body>
  <h1>Datasets</h1>
  <ul id="list"><li>Loading…</li></ul>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    (async () => {
      const { workspace } = await gql(`
        query($slug: String!) {
          workspace(slug: $slug) {
            datasets(page: 1, perPage: 50) {
              items {
                dataset {
                  id slug name description
                  latestVersion { name createdAt }
                }
              }
            }
          }
        }
      `, { slug: workspaceSlug });

      const list = document.getElementById("list");
      list.innerHTML = "";
      for (const item of workspace.datasets.items) {
        const d = item.dataset;
        const li = document.createElement("li");
        const v = d.latestVersion ? `${d.latestVersion.name}${new Date(d.latestVersion.createdAt).toLocaleDateString()}` : "no version yet";
        li.innerHTML = `<strong>${d.name}</strong><br><small>Latest: ${v}</small>`;
        list.appendChild(li);
      }
    })();
  </script>
</body>
</html>

DATASETS_WRITE — Create a new dataset

Tiny form that creates a dataset and prints the new id/slug.

Schema
type Mutation {
  createDataset(input: CreateDatasetInput!): CreateDatasetResult!
  createDatasetVersion(input: CreateDatasetVersionInput!): CreateDatasetVersionResult!
  createDatasetVersionFile(input: CreateDatasetVersionFileInput!): CreateDatasetVersionFileResult!
  updateDataset(input: UpdateDatasetInput!): UpdateDatasetResult!
  updateDatasetVersion(input: UpdateDatasetVersionInput!): UpdateDatasetVersionResult!
}

input CreateDatasetInput {
  workspaceSlug: String!
  name: String!
  description: String
  files: [DatasetVersionFileContentInput!]
}

input DatasetVersionFileContentInput { uri: String!, contentType: String!, content: String! }

input CreateDatasetVersionInput {
  datasetId: ID!
  name: String!
  changelog: String
  files: [DatasetVersionFileContentInput!]
}

input CreateDatasetVersionFileInput { versionId: ID!, contentType: String!, uri: String! }

type CreateDatasetResult { link: DatasetLink, dataset: Dataset, success: Boolean!, errors: [CreateDatasetError!]! }
type CreateDatasetVersionResult { version: DatasetVersion, success: Boolean!, errors: [CreateDatasetVersionError!]! }
type CreateDatasetVersionFileResult { file: DatasetVersionFile, uploadUrl: String!, success: Boolean!, errors: [CreateDatasetVersionFileError!]! }
type UpdateDatasetResult { dataset: Dataset, success: Boolean!, errors: [UpdateDatasetError!]! }
type UpdateDatasetVersionResult { version: DatasetVersion, success: Boolean!, errors: [UpdateDatasetVersionError!]! }

Browse the full schema in the playground →

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Create a dataset</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
    label { display: block; margin: 0.5rem 0 0.25rem; }
    input, textarea { width: 100%; padding: 0.4rem; box-sizing: border-box; font-family: inherit; }
    button { margin-top: 1rem; padding: 0.5rem 1rem; }
    #out { margin-top: 1rem; }
  </style>
</head>
<body>
  <h1>Create a dataset</h1>

  <label>Name
    <input id="name" placeholder="Survey results">
  </label>
  <label>Description
    <textarea id="desc" rows="3"></textarea>
  </label>
  <button onclick="create()">Create</button>

  <p id="out"></p>

  <script>
    const { workspaceSlug } = window.OPENHEXA;

    async function gql(query, variables = {}) {
      const res = await fetch("/graphql/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query, variables }),
      });
      const json = await res.json();
      if (json.errors) throw new Error(json.errors.map(e => e.message).join("; "));
      return json.data;
    }

    async function create() {
      const out = document.getElementById("out");
      const name = document.getElementById("name").value.trim();
      const description = document.getElementById("desc").value.trim();
      if (!name) { out.textContent = "Name is required."; return; }

      const { createDataset } = await gql(`
        mutation($input: CreateDatasetInput!) {
          createDataset(input: $input) {
            success errors dataset { id slug name }
          }
        }
      `, { input: { workspaceSlug, name, description } });

      if (!createDataset.success) {
        out.textContent = "Error: " + (createDataset.errors || []).join(", ");
        return;
      }
      out.textContent = `Created: ${createDataset.dataset.name} (slug: ${createDataset.dataset.slug})`;
    }
  </script>
</body>
</html>