diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts index 6927822..153fff0 100644 --- a/build/esbuild-build.ts +++ b/build/esbuild-build.ts @@ -1,5 +1,6 @@ -import esbuild, { BuildOptions } from "esbuild"; import * as dotenv from "dotenv"; +import esbuild, { BuildOptions } from "esbuild"; +import MINIMAL_PREDEFINED_CONFIG from "../static/minimal-predefined.json"; dotenv.config(); const ENTRY_POINTS = { @@ -17,6 +18,7 @@ export const esbuildOptions: BuildOptions = { loader: Object.fromEntries(DATA_URL_LOADERS.map((ext) => [ext, "dataurl"])), outdir: "static/dist", define: createEnvDefines([], { + MINIMAL_PREDEFINED_CONFIG: JSON.stringify(MINIMAL_PREDEFINED_CONFIG), SUPABASE_STORAGE_KEY: generateSupabaseStorageKey(), NODE_ENV: process.env.NODE_ENV || "development", SUPABASE_URL: process.env.SUPABASE_URL || "https://wfzpewmlyiozupulbuur.supabase.co", diff --git a/static/main.ts b/static/main.ts index 6da8f06..03016dd 100644 --- a/static/main.ts +++ b/static/main.ts @@ -1,7 +1,7 @@ import { AuthService } from "./scripts/authentication"; import { ManifestFetcher } from "./scripts/fetch-manifest"; import { ManifestRenderer } from "./scripts/render-manifest"; -import { renderOrgPicker } from "./scripts/rendering/org-select"; +import { renderOrgSelector } from "./scripts/rendering/org-select"; import { toastNotification } from "./utils/toaster"; async function handleAuth() { @@ -27,14 +27,14 @@ export async function mainModule() { const userOrgRepos = await auth.getGitHubUserOrgRepos(userOrgs); localStorage.setItem("orgRepos", JSON.stringify(userOrgRepos)); - renderOrgPicker(renderer, userOrgs); + renderOrgSelector(renderer, userOrgs); await fetcher.fetchOrgsUbiquityOsConfigs(); await fetcher.fetchMarketplaceManifests(); renderer.manifestGuiBody.dataset.loading = "false"; killNotification(); } else { - renderOrgPicker(renderer, []); + renderOrgSelector(renderer, []); } } catch (error) { if (error instanceof Error) { diff --git a/static/manifest-gui.css b/static/manifest-gui.css index f58756e..1dfb6c5 100644 --- a/static/manifest-gui.css +++ b/static/manifest-gui.css @@ -138,6 +138,13 @@ header #uos-logo path { scrollbar-color: #303030 #101010; } +.template-buttons { + display: flex; + justify-content: center; + gap: 8px; + margin: 8px 0; +} + #manifest-gui.plugin-editor { max-height: calc(100vh - 192px); overflow: auto; diff --git a/static/minimal-predefined.json b/static/minimal-predefined.json new file mode 100644 index 0000000..fd7f552 --- /dev/null +++ b/static/minimal-predefined.json @@ -0,0 +1,11 @@ +{ + "text-conversation-rewards": { + "yamlConfig": "- uses:\n - plugin: ubiquity-os-marketplace/text-conversation-rewards@development\n skipBotEvents: false\n with:\n logLevel: \"debug\"\n evmNetworkId: 100\n evmPrivateEncrypted: \"gdo_iiUND1poZaibNme5oUsG1g8RDEmtI41uLgZjxW8WwxnQZb0DHkOBcISuwobxyKEyzeGQC9KzjkWXv0_OCv-kuUHy4myWNIhs4j3odyvh1XUP7pZFeuVEiASmKQBGkzlKRii5dA0liXtHnhciZQi5N8E7-cdOMbA\" # https://github.com/ubiquibot/conversation-rewards/pull/111#issuecomment-2348639931\n erc20RewardToken: \"0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d\"\n incentives:\n requirePriceLabel: true\n contentEvaluator: {}\n userExtractor:\n enabled: true\n redeemTask: true\n dataPurge: {}\n formattingEvaluator:\n multipliers:\n - role:\n - ISSUE_SPECIFICATION\n multiplier: 3\n rewards:\n wordValue: 0.1\n html:\n br:\n score: 0\n countWords: true\n code:\n score: 5\n countWords: false\n p:\n score: 0\n countWords: true\n em:\n score: 0\n countWords: true\n img:\n score: 5\n countWords: true\n strong:\n score: 0\n countWords: false\n blockquote:\n score: 0\n countWords: false\n h1:\n score: 1\n countWords: true\n h2:\n score: 1\n countWords: true\n h3:\n score: 1\n countWords: true\n h4:\n score: 1\n countWords: true\n h5:\n score: 1\n countWords: true\n h6:\n score: 1\n countWords: true\n a:\n score: 5\n countWords: true\n li:\n score: 0.5\n countWords: true\n ul:\n score: 0\n countWords: true\n td:\n score: 0\n countWords: true\n hr:\n score: 0\n countWords: true\n pre:\n score: 0\n countWords: false\n ol:\n score: 0\n countWords: true\n - role:\n - ISSUE_AUTHOR\n - ISSUE_COLLABORATOR\n - PULL_COLLABORATOR\n multiplier: 1\n rewards:\n wordValue: 0.1\n html:\n br:\n score: 0\n countWords: true\n code:\n score: 5\n countWords: false\n p:\n score: 0\n countWords: true\n em:\n score: 0\n countWords: true\n img:\n score: 5\n countWords: true\n strong:\n score: 0\n countWords: false\n blockquote:\n score: 0\n countWords: false\n h1:\n score: 1\n countWords: true\n h2:\n score: 1\n countWords: true\n h3:\n score: 1\n countWords: true\n h4:\n score: 1\n countWords: true\n h5:\n score: 1\n countWords: true\n h6:\n score: 1\n countWords: true\n a:\n score: 5\n countWords: true\n li:\n score: 0.5\n countWords: true\n ul:\n score: 0\n countWords: true\n td:\n score: 0\n countWords: true\n hr:\n score: 0\n countWords: true\n pre:\n score: 0\n countWords: false\n ol:\n score: 0\n countWords: true\n - role:\n - ISSUE_CONTRIBUTOR\n - ISSUE_ASSIGNEE\n multiplier: 0.25\n rewards:\n wordValue: 0.1\n html:\n br:\n score: 0\n countWords: true\n code:\n score: 5\n countWords: false\n p:\n score: 0\n countWords: true\n em:\n score: 0\n countWords: true\n img:\n score: 5\n countWords: true\n strong:\n score: 0\n countWords: false\n blockquote:\n score: 0\n countWords: false\n h1:\n score: 1\n countWords: true\n h2:\n score: 1\n countWords: true\n h3:\n score: 1\n countWords: true\n h4:\n score: 1\n countWords: true\n h5:\n score: 1\n countWords: true\n h6:\n score: 1\n countWords: true\n a:\n score: 5\n countWords: true\n li:\n score: 0.5\n countWords: true\n ul:\n score: 0\n countWords: true\n td:\n score: 0\n countWords: true\n hr:\n score: 0\n countWords: true\n pre:\n score: 0\n countWords: false\n ol:\n score: 0\n countWords: true\n - role:\n - PULL_SPECIFICATION\n - PULL_AUTHOR\n - PULL_CONTRIBUTOR\n - PULL_ASSIGNEE\n multiplier: 0\n rewards:\n wordValue: 0\n html:\n br:\n score: 0\n countWords: true\n code:\n score: 5\n countWords: false\n p:\n score: 0\n countWords: true\n em:\n score: 0\n countWords: true\n img:\n score: 5\n countWords: true\n strong:\n score: 0\n countWords: false\n blockquote:\n score: 0\n countWords: false\n h1:\n score: 1\n countWords: true\n h2:\n score: 1\n countWords: true\n h3:\n score: 1\n countWords: true\n h4:\n score: 1\n countWords: true\n h5:\n score: 1\n countWords: true\n h6:\n score: 1\n countWords: true\n a:\n score: 5\n countWords: true\n li:\n score: 0.5\n countWords: true\n ul:\n score: 0\n countWords: true\n td:\n score: 0\n countWords: true\n hr:\n score: 0\n countWords: true\n pre:\n score: 0\n countWords: false\n ol:\n score: 0\n countWords: true\n wordCountExponent: 0.85\n permitGeneration:\n enabled: true\n githubComment:\n post: true\n debug: false\n dataCollection:\n maxAttempts: 10\n delayMs: 1000" + }, + "command-start-stop": { + "yamlConfig": "- skipBotEvents: false\n uses:\n - plugin: https://ubiquity-os-command-start-stop-development.ubiquity.workers.dev\n with:\n reviewDelayTolerance: \"3 Days\"\n taskStaleTimeoutDuration: \"30 Days\"\n startRequiresWallet: true\n maxConcurrentTasks:\n member: 2\n contributor: 2\n emptyWalletText: \"Please set your wallet address with the /wallet command first and try again.\"\n rolesWithReviewAuthority:\n - COLLABORATOR\n - OWNER\n - MEMBER\n - ADMIN\n requiredLabelsToStart: [\"Priority: 3 (High)\", \"Priority: 4 (Urgent)\", \"Priority: 5 (Emergency)\"]" + }, + "daemon-pricing": { + "yamlConfig": "- uses:\n - plugin: https://ubiquity-os-daemon-pricing-development.ubiquity.workers.dev\n with:\n labels:\n time:\n - \"Time: <15 Minutes\"\n - \"Time: <1 Hour\"\n - \"Time: <2 Hours\"\n - \"Time: <4 Hours\"\n - \"Time: <1 Day\"\n - \"Time: <1 Week\"\n - \"Time: <2 Weeks\"\n - \"Time: <1 Month\"\n priority:\n - \"Priority: 1 (Normal)\"\n - \"Priority: 2 (Medium)\"\n - \"Priority: 3 (High)\"\n - \"Priority: 4 (Urgent)\"\n - \"Priority: 5 (Emergency)\"\n basePriceMultiplier: 2\n publicAccessControl:\n setLabel: true\n fundExternalClosedIssue: false" + } +} diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts index d850e23..3daa290 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -127,7 +127,11 @@ export class ConfigParser { return this.createOrUpdateFileContents(org, repo, path, octokit); } - async createOrUpdateFileContents(org: string, repo: string, path: string, octokit: Octokit) { + async createOrUpdateFileContents(org: string, repo: string, path: string, octokit: Octokit | null) { + if (!octokit) { + throw new Error("Failed to create or update file contents: Octokit not found"); + } + if (org === repo) { repo = CONFIG_ORG_REPO; } @@ -189,7 +193,11 @@ export class ConfigParser { this.saveConfig(); } - loadConfig(): string { + loadConfig(config?: string) { + if (config) { + this.saveConfig(config); + } + if (!this.newConfigYml) { this.newConfigYml = localStorage.getItem("config") as string; } @@ -205,7 +213,10 @@ export class ConfigParser { return this.newConfigYml; } - saveConfig() { + saveConfig(config?: string) { + if (config) { + this.newConfigYml = config; + } if (this.newConfigYml) { localStorage.setItem("config", this.newConfigYml); } diff --git a/static/scripts/predefined-configs/template-handler.ts b/static/scripts/predefined-configs/template-handler.ts new file mode 100644 index 0000000..c776cbd --- /dev/null +++ b/static/scripts/predefined-configs/template-handler.ts @@ -0,0 +1,272 @@ +import YAML from "yaml"; +import { AnySchemaObject } from "ajv"; +import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; +import { AuthService } from "../authentication"; +import { ManifestRenderer } from "../render-manifest"; +import { controlButtons } from "../rendering/control-buttons"; +import { parseConfigInputs } from "../rendering/input-parsing"; +import { addTrackedEventListener, updateGuiTitle } from "../rendering/utils"; +import { Manifest, ManifestPreDecode, Plugin, PluginConfig } from "../../types/plugins"; +import { createConfigParamTooltip, createElement, createInputRow } from "../../utils/element-helpers"; +import { getManifestCache } from "../../utils/storage"; +import { STRINGS } from "../../utils/strings"; +import { toastNotification } from "../../utils/toaster"; + +type TemplateTypes = "minimal" | "full-defaults" | "custom"; +declare const MINIMAL_PREDEFINED_CONFIG: string; + +export async function configTemplateHandler(type: TemplateTypes, renderer: ManifestRenderer) { + const org = localStorage.getItem("selectedOrg"); + + if (!org) { + throw new Error("No selected org found"); + } + + const octokit = renderer.auth.octokit; + if (!octokit) { + throw new Error("Octokit not found"); + } + + const userInstalledConfig = await renderer.configParser.fetchUserInstalledConfig(org, octokit); + + if (userInstalledConfig.length > 12) { + toastNotification("Configuration File Detected: This will be overwritten if you continue.", { type: "warning", shouldAutoDismiss: true }); + } + + if (type === "minimal") { + await writeTemplate(renderer, await handleMinimalTemplate(), type, renderer.auth.octokit, org); + } else if (type === "full-defaults") { + await handleFullDefaultsTemplate(renderer); + } else { + throw new Error("Invalid template type"); + } +} + +async function writeTemplate(renderer: ManifestRenderer, config: string, type: TemplateTypes, octokit: AuthService["octokit"], org: string) { + try { + renderer.configParser.saveConfig(config); + toastNotification(`Successfully loaded ${type} template. Do you want to push to GitHub? `, { + type: "success", + actionText: "Push to GitHub", + action: async () => { + try { + await renderer.configParser.createOrUpdateFileContents(org, CONFIG_ORG_REPO, CONFIG_FULL_PATH, octokit); + } catch (error) { + console.error("Error pushing config to GitHub:", error); + toastNotification("An error occurred while pushing the configuration to GitHub.", { + type: "error", + shouldAutoDismiss: true, + }); + return; + } + toastNotification("Configuration pushed to GitHub successfully.", { + type: "success", + shouldAutoDismiss: true, + }); + }, + }); + } catch (error) { + toastNotification(STRINGS.FAILED_TO_LOAD_TEMPLATE, { type: "error" }); + throw error; + } +} + +async function handleMinimalTemplate(): Promise { + try { + const obj = JSON.parse(MINIMAL_PREDEFINED_CONFIG); + const parts = Array.from(Object.entries(obj)).map(([, value]) => { + return `\n ${typeof value === "object" && value && "yamlConfig" in value && value.yamlConfig ? value.yamlConfig : ""}`; + }); + + return `plugins:${parts.join("")}`; + } catch (error) { + toastNotification("Failed to fetch minimal predefined config", { type: "error" }); + throw error; + } +} + +async function handleFullDefaultsTemplate(renderer: ManifestRenderer): Promise { + renderer.configParser.writeBlankConfig(); + + const response = await fetch("https://raw.githubusercontent.com/ubiquity/onboard.ubq.fi/development/static/types/default-configuration.yml"); + + if (!response.ok) { + throw new Error("Failed to fetch full defaults template"); + } + + // if there was no required field we would just return the config + const config = await response.text(); + + const manifestCache = getManifestCache(); + const plugins = Object.keys(manifestCache).map((key) => manifestCache[key]); + const pluginWithDefaults: { name: string; defaults: ManifestPreDecode }[] = []; + + plugins.forEach((plugin) => { + const { + manifest: { configuration }, + } = plugin; + if (!configuration) { + return; + } + pluginWithDefaults.push({ + name: plugin.homepageUrl || plugin.manifest.name, + defaults: buildDefaultValues(configuration), + }); + }); + + await renderRequiredFields(renderer, pluginWithDefaults).catch((error) => { + console.error("Error rendering required fields:", error); + toastNotification("An error occurred while rendering the required fields.", { + type: "error", + shouldAutoDismiss: true, + }); + }); + + return config; +} + +/** + * undefined === not a required field, can be omitted + * null === required field, but no default value + * + * for each null value, we need to render an input for that field + * referencing the plugin name. Expect there to be lots but in reality + * there are only a few. + * + */ + +async function renderRequiredFields(renderer: ManifestRenderer, plugins: { name: string; defaults: Manifest["configuration"] }[]) { + const configDefaults: Record = {}; + const pluginWithDefaults: { name: string; defaults: Manifest["configuration"] }[] = []; + renderer.manifestGuiBody.innerHTML = null; + + plugins.forEach((plugin) => { + const { name, defaults } = plugin; + pluginWithDefaults.push({ name, defaults }); + }); + + for (const plugin of pluginWithDefaults) { + const { defaults } = plugin; + + for (const [key, prop] of Object.entries(defaults || {})) { + if (prop === null) { + const row = createElement("tr", { className: "config-row" }); + const headerCell = createElement("td", { className: "table-data-header" }); + headerCell.textContent = key.replace(/([A-Z])/g, " $1"); + createConfigParamTooltip(headerCell, prop); + row.appendChild(headerCell); + createInputRow(key, prop, configDefaults, undefined, undefined, true); + } + } + } + + updateGuiTitle("Fill in required fields"); + controlButtons({ hide: false }); + + const resetToDefaultButton = document.getElementById("reset-to-default") as HTMLButtonElement; + if (!resetToDefaultButton) { + throw new Error("Reset to default button not found"); + } + + resetToDefaultButton.addEventListener("click", () => { + renderRequiredFields(renderer, plugins).catch((error) => { + console.error("Error rendering required fields:", error); + toastNotification("An error occurred while rendering the required fields.", { + type: "error", + shouldAutoDismiss: true, + }); + }); + }); + + const add = document.getElementById("add") as HTMLButtonElement; + const remove = document.getElementById("remove") as HTMLButtonElement; + if (!add || !remove) { + throw new Error("Add or remove button not found"); + } + remove.classList.add("disabled"); + + addTrackedEventListener(add, "click", () => { + writeRequiredConfig(plugins, renderer); + }); + + renderer.manifestGui?.classList.add("plugin-editor"); + renderer.manifestGui?.classList.add("rendered"); +} + +function writeRequiredConfig(plugins: { name: string; defaults: Manifest["configuration"] }[], renderer: ManifestRenderer) { + const configInputs = document.querySelectorAll(".config-input"); + const newConfig = parseConfigInputs(configInputs, {} as Manifest, plugins); + + const manifestCache = getManifestCache(); + const pluginNames = Object.values(manifestCache).map((plugin) => plugin.homepageUrl || plugin.manifest.name); + + const pluginArr: Plugin[] = []; + + for (const [name, config] of Object.entries(newConfig.config)) { + // this relies on the worker deployment url containing the plugin name + const pluginUrl = pluginNames.find((url) => { + return url.includes(name); + }); + + if (!pluginUrl) { + toastNotification(`No plugin URL found for ${name}.`, { + type: "error", + shouldAutoDismiss: true, + }); + + return; + } + + const plugin: Plugin = { + uses: [ + { + plugin: pluginUrl, + with: config as Record, + }, + ], + }; + + pluginArr.push(plugin); + } + + const pluginConfig: PluginConfig = { + plugins: pluginArr, + }; + + const org = localStorage.getItem("selectedOrg"); + if (!org) { + throw new Error("No selected org found"); + } + + writeTemplate(renderer, YAML.stringify(pluginConfig), "full-defaults", renderer.auth.octokit, localStorage.getItem("selectedOrg") || "").catch((error) => { + console.error("Error writing template:", error); + toastNotification("An error occurred while writing the template.", { + type: "error", + shouldAutoDismiss: true, + }); + }); +} + +function buildDefaultValues(schema: AnySchemaObject): T { + const defaults: Partial = {}; + const requiredProps = schema.required || []; + + for (const key of Object.keys(schema.properties)) { + if (Reflect.has(schema.properties, key)) { + const hasDefault = "default" in schema.properties[key]; + const value = schema.properties[key].default; + + const _key = key as keyof T; + + if (hasDefault && value) { + defaults[_key] = value; + } else if (requiredProps.includes(_key)) { + defaults[_key] = null as unknown as T[keyof T]; + } else { + defaults[_key] = undefined; + } + } + } + + return defaults as T; +} diff --git a/static/scripts/render-manifest.ts b/static/scripts/render-manifest.ts index 09219f3..7af5f3b 100644 --- a/static/scripts/render-manifest.ts +++ b/static/scripts/render-manifest.ts @@ -2,7 +2,7 @@ import { ConfigParser } from "./config-parser"; import { AuthService } from "./authentication"; import { ExtendedHtmlElement } from "../types/github"; import { controlButtons } from "./rendering/control-buttons"; -import { createBackButton } from "./rendering/navigation"; +import { createBackButton, NavSteps } from "./rendering/navigation"; /** * More of a controller than a renderer, this is responsible for rendering the manifest GUI @@ -15,7 +15,7 @@ export class ManifestRenderer { private _configDefaults: { [key: string]: { type: string; value: string; items: { type: string } | null } } = {}; private _auth: AuthService; private _backButton: HTMLButtonElement; - private _currentStep: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" = "orgPicker"; + private _currentStep: NavSteps = "orgSelector"; private _orgs: string[] = []; constructor(auth: AuthService) { @@ -31,7 +31,7 @@ export class ManifestRenderer { this._manifestGuiBody = manifestGuiBody as HTMLElement; controlButtons({ hide: true }); - this.currentStep = "orgPicker"; + this.currentStep = "orgSelector"; const title = manifestGui.querySelector("#manifest-gui-title"); this._backButton = createBackButton(this); title?.previousSibling?.appendChild(this._backButton); @@ -45,11 +45,11 @@ export class ManifestRenderer { this._orgs = orgs; } - get currentStep(): "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" { + get currentStep(): "orgSelector" | "repoSelector" | "pluginSelector" | "configEditor" | "templateSelector" { return this._currentStep; } - set currentStep(step: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor") { + set currentStep(step: "orgSelector" | "repoSelector" | "pluginSelector" | "configEditor" | "templateSelector") { this._currentStep = step; } diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts index 1bd5869..4dcf259 100644 --- a/static/scripts/rendering/input-parsing.ts +++ b/static/scripts/rendering/input-parsing.ts @@ -91,15 +91,24 @@ export function processProperties( */ export function parseConfigInputs( configInputs: NodeListOf, - manifest: Manifest + manifest: Manifest, + fullDefaultTemplate?: { + name: string; + defaults: Record | undefined; + }[] ): { config: Record; missing: string[] } { const config: Record = {}; const { configuration } = manifest; - if (!configuration) { + if (!configuration && !fullDefaultTemplate) { throw new Error("No schema found in manifest"); } - const required = configuration.required || []; + + if (fullDefaultTemplate) { + return { config: handleFullDefault(configInputs, fullDefaultTemplate), missing: [] }; + } + + const required = configuration?.required || []; const validate = ajv.compile(configuration as AnySchemaObject); let tempConfig: Record = {}; @@ -130,7 +139,7 @@ export function parseConfigInputs( if (validate(tempConfig)) { const missing = []; for (const key of required) { - const isBoolean = configuration.properties && configuration.properties[key] && configuration.properties[key].type === "boolean"; + const isBoolean = configuration?.properties && configuration.properties[key] && configuration.properties[key].type === "boolean"; if ((isBoolean && config[key] === false) || config[key] === true) { continue; } @@ -158,6 +167,61 @@ export function parseConfigInputs( } } +function handleFullDefault( + configInputs: NodeListOf, + fullDefaultTemplate: { + name: string; + defaults: Record | undefined; + }[] +) { + configInputs.forEach((input) => { + const key = input.getAttribute("data-config-key"); + if (!key) { + throw new Error("Input key is required"); + } + + const template = fullDefaultTemplate.find((plugin) => Object.keys(plugin.defaults || {}).includes(key)); + if (!template) { + throw new Error(`No template found for key: ${key}`); + } + + let value: unknown; + const expectedType = input.getAttribute("data-type"); + + if (expectedType === "boolean") { + value = (input as HTMLInputElement).checked; + } else if (expectedType === "object" || expectedType === "array") { + try { + value = JSON.parse((input as HTMLTextAreaElement).value); + } catch (e) { + console.error(e); + throw new Error( + `Invalid JSON input for ${expectedType} at key "${key}": ${"value" in input ? (input as HTMLTextAreaElement).value : (input as HTMLDivElement).textContent}` + ); + } + } else { + value = (input as HTMLInputElement).value; + } + + template.defaults ??= {}; + template.defaults[key] = value; + + fullDefaultTemplate.forEach((plugin) => { + if (plugin.name === template.name) { + plugin.defaults = template.defaults; + } + }); + }); + + return fullDefaultTemplate.reduce( + (acc, curr) => { + acc[curr.name] = curr.defaults; + return acc; + }, + {} as Record + ); +} + function processExpectedType( input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement, expectedType: string | null, diff --git a/static/scripts/rendering/navigation.ts b/static/scripts/rendering/navigation.ts index c53be20..466e9dd 100644 --- a/static/scripts/rendering/navigation.ts +++ b/static/scripts/rendering/navigation.ts @@ -1,8 +1,11 @@ import { createElement } from "../../utils/element-helpers"; import { ManifestRenderer } from "../render-manifest"; -import { renderOrgPicker } from "./org-select"; +import { renderOrgSelector } from "./org-select"; import { renderPluginSelector } from "./plugin-select"; import { renderRepoPicker } from "./repo-select"; +import { renderTemplateSelector } from "./template-selector"; + +export type NavSteps = "orgSelector" | "pluginSelector" | "templateSelector" | "configEditor" | "repoSelector" | "templateSelector"; export function createBackButton(renderer: ManifestRenderer): HTMLButtonElement { const backButton = createElement("button", { @@ -22,12 +25,14 @@ export function handleBackButtonClick(renderer: ManifestRenderer): void { if (readmeContainer) { readmeContainer.remove(); } - // "pluginSelector" | "configEditor" + const step = renderer.currentStep; - if (step === "repoPicker" || step === "orgPicker") { - renderOrgPicker(renderer, renderer.orgs); - } else if (step === "pluginSelector") { + if (step === "repoSelector" || step === "orgSelector") { + renderOrgSelector(renderer, renderer.orgs); + } else if (step === "templateSelector") { renderRepoPicker(renderer, JSON.parse(localStorage.getItem("orgRepos") || "{}")); + } else if (step === "pluginSelector") { + renderTemplateSelector(renderer); } else if (step === "configEditor") { renderPluginSelector(renderer); } diff --git a/static/scripts/rendering/org-select.ts b/static/scripts/rendering/org-select.ts index 3062032..1707da9 100644 --- a/static/scripts/rendering/org-select.ts +++ b/static/scripts/rendering/org-select.ts @@ -8,8 +8,8 @@ import { closeAllSelect, updateGuiTitle } from "./utils"; /** * Renders the orgs for the authenticated user to select from. */ -export function renderOrgPicker(renderer: ManifestRenderer, orgs: string[]) { - renderer.currentStep = "orgPicker"; +export function renderOrgSelector(renderer: ManifestRenderer, orgs: string[]) { + renderer.currentStep = "orgSelector"; controlButtons({ hide: true }); renderer.backButton.style.display = "none"; renderer.manifestGui?.classList.add("rendering"); @@ -84,6 +84,7 @@ function handleOrgSelection(renderer: ManifestRenderer, org: string): void { throw new Error("No org selected"); } localStorage.setItem("selectedOrg", org); - const repos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); - renderRepoPicker(renderer, repos); + + const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); + renderRepoPicker(renderer, orgRepos); } diff --git a/static/scripts/rendering/repo-select.ts b/static/scripts/rendering/repo-select.ts index 675a7f6..07f3438 100644 --- a/static/scripts/rendering/repo-select.ts +++ b/static/scripts/rendering/repo-select.ts @@ -4,10 +4,11 @@ import { toastNotification } from "../../utils/toaster"; import { ManifestRenderer } from "../render-manifest"; import { controlButtons } from "./control-buttons"; import { renderPluginSelector } from "./plugin-select"; +import { renderTemplateSelector } from "./template-selector"; import { updateGuiTitle } from "./utils"; export function renderRepoPicker(renderer: ManifestRenderer, repos: Record): void { - renderer.currentStep = "repoPicker"; + renderer.currentStep = "repoSelector"; controlButtons({ hide: true }); renderer.backButton.style.display = "block"; renderer.manifestGui?.classList.add("rendering"); @@ -20,8 +21,6 @@ export function renderRepoPicker(renderer: ManifestRenderer, repos: Record { - renderPluginSelector(renderer); + renderTemplateSelector(renderer); }) .catch((error) => { console.error(error); @@ -126,5 +125,7 @@ function handleRepoSelection(event: Event, renderer: ManifestRenderer): void { console.error(error); toastNotification("Error fetching org config", { type: "error" }); }); + } else { + throw new Error("No selected repo found"); } } diff --git a/static/scripts/rendering/template-selector.ts b/static/scripts/rendering/template-selector.ts new file mode 100644 index 0000000..39d8061 --- /dev/null +++ b/static/scripts/rendering/template-selector.ts @@ -0,0 +1,66 @@ +import { createElement } from "../../utils/element-helpers"; +import { STRINGS } from "../../utils/strings"; +import { toastNotification } from "../../utils/toaster"; +import { configTemplateHandler } from "../predefined-configs/template-handler"; +import { ManifestRenderer } from "../render-manifest"; +import { controlButtons } from "./control-buttons"; +import { renderPluginSelector } from "./plugin-select"; +import { updateGuiTitle } from "./utils"; + +export function renderTemplateSelector(renderer: ManifestRenderer): void { + renderer.currentStep = "templateSelector"; + controlButtons({ hide: true }); + renderer.manifestGui?.classList.add("rendering"); + renderer.manifestGuiBody.innerHTML = null; + + const templateRow = document.createElement("tr"); + const templateCell = document.createElement("td"); + templateCell.colSpan = 4; + templateCell.className = STRINGS.TDV_CENTERED; + + const templateButtons = createElement("div", { class: "template-buttons" }); + + const minimalButton = createElement("button", { textContent: "Minimal" }); + minimalButton.addEventListener("click", () => { + configTemplateHandler("minimal", renderer).catch(console.error); + }); + + const fullDefaultButton = createElement("button", { textContent: "Full Default" }); + fullDefaultButton.addEventListener("click", () => { + configTemplateHandler("full-defaults", renderer).catch(console.error); + }); + + const customButton = createElement("button", { textContent: "Custom" }); + customButton.addEventListener("click", () => { + const selectedOrg = localStorage.getItem("selectedOrg"); + if (!selectedOrg) { + throw new Error("No org selected"); + } + fetchOrgConfig(renderer, selectedOrg).catch(console.error); + }); + + templateButtons.appendChild(minimalButton); + templateButtons.appendChild(fullDefaultButton); + templateButtons.appendChild(customButton); + + templateCell.appendChild(templateButtons); + templateRow.appendChild(templateCell); + + renderer.manifestGuiBody.appendChild(templateRow); + + updateGuiTitle("Select a Template"); + + renderer.manifestGui?.classList.remove("rendering"); + renderer.manifestGui?.classList.add("rendered"); +} + +async function fetchOrgConfig(renderer: ManifestRenderer, org: string): Promise { + const removeToast = toastNotification("Fetching organization config...", { type: "info", shouldAutoDismiss: true }); + const octokit = renderer.auth.octokit; + if (!octokit) { + throw new Error("No org or octokit found"); + } + await renderer.configParser.fetchUserInstalledConfig(org, octokit); + renderPluginSelector(renderer); + removeToast(); +} diff --git a/static/utils/strings.ts b/static/utils/strings.ts index 7a69041..672c1ab 100644 --- a/static/utils/strings.ts +++ b/static/utils/strings.ts @@ -4,6 +4,7 @@ export const STRINGS = { SELECT_SELECTED: ".select-selected", SELECT_HIDE: "select-hide", SELECT_ARROW_ACTIVE: "select-arrow-active", + FAILED_TO_LOAD_TEMPLATE: "Failed to load template", DATA_SELECTED: "data-selected", PICKER_SELECT: "picker-select", };