diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts index 6927822..7bd1ffd 100644 --- a/build/esbuild-build.ts +++ b/build/esbuild-build.ts @@ -1,6 +1,7 @@ import esbuild, { BuildOptions } from "esbuild"; import * as dotenv from "dotenv"; dotenv.config(); +import MINIMAL_PREDEFINED_CONFIG from "../static/minimal-predefined.json"; const ENTRY_POINTS = { typescript: ["static/main.ts"], @@ -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 eb5cc83..b5d6c76 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() { @@ -26,7 +26,7 @@ export async function mainModule() { if (Object.keys(cache).length === 0) { renderer.manifestGuiBody.dataset.loading = "true"; const killNotification = toastNotification("Fetching manifest data...", { type: "info", shouldAutoDismiss: true }); - renderOrgPicker(renderer, []); + renderOrgSelector(renderer, []); await fetcher.fetchMarketplaceManifests(); await fetcher.fetchOfficialPluginConfig(); @@ -34,9 +34,9 @@ export async function mainModule() { renderer.manifestGuiBody.dataset.loading = "false"; } - renderOrgPicker(renderer, userOrgs); + renderOrgSelector(renderer, userOrgs); } else { - renderOrgPicker(renderer, []); + renderOrgSelector(renderer, []); } } catch (error) { if (error instanceof Error) { diff --git a/static/manifest-gui.css b/static/manifest-gui.css index 1f88ee5..46adfad 100644 --- a/static/manifest-gui.css +++ b/static/manifest-gui.css @@ -136,6 +136,13 @@ header #uos-logo path { transition: opacity 0.25s cubic-bezier(0, 1, 1, 1); } +.template-buttons { + display: flex; + justify-content: center; + gap: 8px; + margin: 8px 0; +} + #manifest-gui.plugin-editor { width: 100%; } @@ -255,7 +262,7 @@ button#reset-to-default::before { content: "♻️"; } button#reset-to-default:hover::before { - content: "Use Defaults"; + content: "Reset"; } button#reset-to-default:active::before { content: "♻️♻️♻️♻️♻️"; 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 25ea64e..eb2cb18 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -3,6 +3,7 @@ import { Plugin, PluginConfig } from "../types/plugins"; import { Octokit } from "@octokit/rest"; import { toastNotification } from "../utils/toaster"; import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; +import { AuthService } from "./authentication"; /** * Responsible for fetching, parsing, and updating the user's installed plugin configurations. @@ -119,7 +120,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: AuthService["octokit"]) { + if (!octokit) { + throw new Error("Octokit not found"); + } + const recentSha = await octokit.repos.getContent({ owner: org, repo: repo, @@ -177,7 +182,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; } @@ -193,7 +202,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..50d976c --- /dev/null +++ b/static/scripts/predefined-configs/template-handler.ts @@ -0,0 +1,300 @@ +import { toastNotification } from "../../utils/toaster"; +import { ManifestRenderer } from "../render-manifest"; +import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; +import { STRINGS } from "../../utils/strings"; +import { Manifest, ManifestCache, ManifestPreDecode, Plugin, PluginConfig } from "../../types/plugins"; +import { AnySchemaObject } from "ajv"; +import { createInputRow } from "../../utils/element-helpers"; +import { controlButtons } from "../rendering/control-buttons"; +import { updateGuiTitle } from "../rendering/utils"; +import { parseConfigInputs } from "../rendering/input-parsing"; +import YAML from "yaml"; +import { AuthService } from "../authentication"; + +type TemplateTypes = "minimal" | "full-defaults" | "custom"; +type MinimalPredefinedConfig = { + "text-conversation-rewards": { + yamlConfig: string; + }; + "command-start-stop": { + yamlConfig: string; + }; + "daemon-pricing": { + yamlConfig: string; + }; +}; + +declare const MINIMAL_PREDEFINED_CONFIG: string; + +export async function configTemplateHandler(type: TemplateTypes, renderer: ManifestRenderer) { + let config: string | undefined; + if (type === "minimal") { + config = await handleMinimalTemplate(); + } else if (type === "full-defaults") { + await handleFullDefaultsTemplate(renderer); + return; + } else { + throw new Error("Invalid template type"); + } + + if (!config) { + throw new Error(STRINGS.FAILED_TO_LOAD_TEMPLATE); + } + + 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); + + try { + // YAML.stringify({ plugins: [] }).length === 12 + if (userInstalledConfig.length > 12) { + toastNotification("Configuration File Detected: This will be overwritten, are you sure you want to continue?", { + type: "warning", + actionText: "Continue", + action: async () => { + await writeTemplate(renderer, config, type, octokit, org); + }, + }); + return; + } + await writeTemplate(renderer, config, type, octokit, org); + } catch (error) { + toastNotification(STRINGS.FAILED_TO_LOAD_TEMPLATE, { type: "error" }); + throw error; + } +} + +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) as MinimalPredefinedConfig; + + const parts = Array.from(Object.entries(obj)).map(([, value]) => { + return `\n ${value.yamlConfig}`; + }); + + return `plugins:${parts.join("")}`; + } catch (error) { + toastNotification("Failed to fetch minimal predefined config", { type: "error" }); + throw error; + } +} + +async function handleFullDefaultsTemplate(renderer: ManifestRenderer) { + renderer.configParser.writeBlankConfig(); + + const manifestCache = JSON.parse(localStorage.getItem("manifestCache") || "{}") as ManifestCache; + const pluginUrls = Object.keys(manifestCache); + const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => { + if (manifestCache[key]?.name) { + acc[key] = manifestCache[key]; + } + return acc; + }, {} as ManifestCache); + + const plugins: { name: string; defaults: ManifestPreDecode["configuration"] }[] = []; + + pluginUrls.forEach((url) => { + if (!cleanManifestCache[url]?.name) { + return; + } + + const defaultForInstalled: ManifestPreDecode | null = cleanManifestCache[url]; + const manifestCache = JSON.parse(localStorage.getItem("manifestCache") || "{}") as ManifestCache; + const pluginUrls = Object.keys(manifestCache); + const pluginUrl = pluginUrls.find((url) => { + return manifestCache[url].name === defaultForInstalled.name; + }); + + const plugin = manifestCache[pluginUrl || ""]; + const config = plugin?.configuration; + + if (!config) { + return; + } + + const defaults = buildDefaultValues(config); + + plugins.push({ + name: plugin.name, + defaults, + }); + }); + + 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, + }); + }); +} + +/** + * 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: ManifestPreDecode["configuration"] }[]) { + const configDefaults: Record = {}; + renderer.manifestGuiBody.innerHTML = null; + + plugins.forEach((plugin) => { + const { defaults } = plugin; + const keys = Object.keys(defaults); + keys.forEach((key) => { + if (defaults[key as keyof typeof defaults] === null) { + createInputRow(key, defaults, configDefaults); + } + }); + }); + + 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.remove(); + + add.addEventListener("click", () => { + const configInputs = document.querySelectorAll(".config-input"); + const newConfig = parseConfigInputs(configInputs, {} as Manifest, plugins); + + const officialPluginConfig: Record = JSON.parse(localStorage.getItem("officialPluginConfig") || "{}"); + + const pluginArr: Plugin[] = []; + for (const [name, config] of Object.entries(newConfig)) { + // this relies on the manifest matching the repo name + const normalizedPluginName = name + .toLowerCase() + .replace(/ /g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-"); + + const pluginUrl = Object.keys(officialPluginConfig).find((url) => { + return url.includes(normalizedPluginName); + }); + + if (!pluginUrl) { + toastNotification(`No plugin URL found for ${normalizedPluginName}.`, { + 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 aabb867..60da347 100644 --- a/static/scripts/render-manifest.ts +++ b/static/scripts/render-manifest.ts @@ -4,6 +4,8 @@ import { ExtendedHtmlElement } from "../types/github"; import { controlButtons } from "./rendering/control-buttons"; import { createBackButton } from "./rendering/navigation"; +type NavSteps = "orgSelector" | "pluginSelector" | "templateSelector" | "configEditor"; + /** * More of a controller than a renderer, this is responsible for rendering the manifest GUI * and managing the state of the GUI with the help of the rendering functions. @@ -15,7 +17,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" | "pluginSelector" | "configEditor" = "orgPicker"; + private _currentStep: NavSteps = "orgSelector"; private _orgs: string[] = []; constructor(auth: AuthService) { @@ -31,7 +33,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 +47,11 @@ export class ManifestRenderer { this._orgs = orgs; } - get currentStep(): "orgPicker" | "pluginSelector" | "configEditor" { + get currentStep(): NavSteps { return this._currentStep; } - set currentStep(step: "orgPicker" | "pluginSelector" | "configEditor") { + set currentStep(step: NavSteps) { this._currentStep = step; } diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts index a8bd6c0..5007997 100644 --- a/static/scripts/rendering/input-parsing.ts +++ b/static/scripts/rendering/input-parsing.ts @@ -48,18 +48,28 @@ export function processProperties( */ export function parseConfigInputs( configInputs: NodeListOf, - manifest: Manifest + manifest: Manifest, + fullDefaultTemplate?: { + name: string; + defaults: Record; + }[] ): { config: Record; missing: string[] } { const config: Record = {}; - const schema = manifest.configuration; - if (!schema) { + const schema = manifest?.configuration; + if (!schema && !fullDefaultTemplate) { throw new Error("No schema found in manifest"); } + + if (fullDefaultTemplate) { + return { config: handleFullDefault(configInputs, fullDefaultTemplate), missing: [] }; + } + const required = schema.required || []; const validate = ajv.compile(schema as AnySchemaObject); configInputs.forEach((input) => { const key = input.getAttribute("data-config-key"); + console.log("key", key); if (!key) { throw new Error("Input key is required"); } @@ -113,3 +123,55 @@ export function parseConfigInputs( throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2)); } } + +function handleFullDefault( + configInputs: NodeListOf, + fullDefaultTemplate: { + name: string; + defaults: Record; + }[] +) { + 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}": ${input.value}`); + } + } else { + value = (input as HTMLInputElement).value; + } + + 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 + ); +} diff --git a/static/scripts/rendering/navigation.ts b/static/scripts/rendering/navigation.ts index d57b96a..50a5e43 100644 --- a/static/scripts/rendering/navigation.ts +++ b/static/scripts/rendering/navigation.ts @@ -1,6 +1,6 @@ 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"; export function createBackButton(renderer: ManifestRenderer): HTMLButtonElement { @@ -24,8 +24,8 @@ function handleBackButtonClick(renderer: ManifestRenderer): void { const step = renderer.currentStep; - if (step === "pluginSelector" || step === "orgPicker") { - renderOrgPicker(renderer, renderer.orgs); + if (step === "pluginSelector" || step === "orgSelector") { + renderOrgSelector(renderer, renderer.orgs); } else if (step === "configEditor") { renderPluginSelector(renderer); } diff --git a/static/scripts/rendering/org-select.ts b/static/scripts/rendering/org-select.ts index 065d49a..8dc9262 100644 --- a/static/scripts/rendering/org-select.ts +++ b/static/scripts/rendering/org-select.ts @@ -1,16 +1,15 @@ import { createElement } from "../../utils/element-helpers"; import { STRINGS } from "../../utils/strings"; -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 { closeAllSelect, updateGuiTitle } from "./utils"; /** * Renders the orgs for the authenticated user to select from. */ export function renderOrgPicker(renderer: ManifestRenderer, orgs: string[]) { - renderer.currentStep = "orgPicker"; + renderer.currentStep = "orgSelector"; controlButtons({ hide: true }); renderer.backButton.style.display = "none"; renderer.manifestGui?.classList.add("rendering"); @@ -85,16 +84,5 @@ function handleOrgSelection(renderer: ManifestRenderer, org: string): void { throw new Error("No org selected"); } localStorage.setItem("selectedOrg", org); - fetchOrgConfig(renderer, org).catch(console.error); -} - -async function fetchOrgConfig(renderer: ManifestRenderer, org: string): Promise { - const kill = 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); - kill(); + renderTemplateSelector(renderer); } diff --git a/static/scripts/rendering/template-selector.ts b/static/scripts/rendering/template-selector.ts new file mode 100644 index 0000000..1c0acc3 --- /dev/null +++ b/static/scripts/rendering/template-selector.ts @@ -0,0 +1,72 @@ +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 { renderOrgSelector } from "./org-select"; +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.backButton.style.display = "block"; + renderer.backButton.addEventListener("click", () => { + renderOrgSelector(renderer, renderer.orgs); + }); + + renderer.manifestGui?.classList.remove("rendering"); + renderer.manifestGui?.classList.add("rendered"); +} + +async function fetchOrgConfig(renderer: ManifestRenderer, org: string): Promise { + const kill = 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); + kill(); +} diff --git a/static/scripts/rendering/write-add-remove.ts b/static/scripts/rendering/write-add-remove.ts index f069d30..a4c7b6e 100644 --- a/static/scripts/rendering/write-add-remove.ts +++ b/static/scripts/rendering/write-add-remove.ts @@ -22,9 +22,9 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo }); throw new Error("No selected plugin manifest found"); } - const pluginManifest = JSON.parse(selectedManifest) as Manifest; - const configInputs = document.querySelectorAll(".config-input"); + const pluginManifest = JSON.parse(selectedManifest || "{}") as Manifest; + const configInputs = document.querySelectorAll(".config-input"); const { config: newConfig, missing } = parseConfigInputs(configInputs, pluginManifest); if (missing.length) { diff --git a/static/utils/element-helpers.ts b/static/utils/element-helpers.ts index b7865ff..a385194 100644 --- a/static/utils/element-helpers.ts +++ b/static/utils/element-helpers.ts @@ -27,7 +27,6 @@ export function createInputRow( required = false ): void { const row = document.createElement("tr"); - const headerCell = document.createElement("td"); headerCell.className = "table-data-header"; headerCell.textContent = key.replace(/([A-Z])/g, " $1"); diff --git a/static/utils/strings.ts b/static/utils/strings.ts index 23dffe2..da3b423 100644 --- a/static/utils/strings.ts +++ b/static/utils/strings.ts @@ -4,4 +4,5 @@ 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", };