diff --git a/static/manifest-gui.css b/static/manifest-gui.css index 1f88ee5..b6cc57c 100644 --- a/static/manifest-gui.css +++ b/static/manifest-gui.css @@ -90,6 +90,8 @@ header #uos-logo path { max-height: calc(100vh - 192px); scrollbar-width: thin; scrollbar-color: #303030 #101010; + opacity: 1; + transition: opacity 0.5s ease; } .readme-container p { @@ -231,6 +233,9 @@ button { border-radius: 4px; opacity: 0.75; font-family: "Proxima Nova", sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } button:hover { background-color: #404040; diff --git a/static/scripts/authentication.ts b/static/scripts/authentication.ts index 74e8660..ce8f068 100644 --- a/static/scripts/authentication.ts +++ b/static/scripts/authentication.ts @@ -1,6 +1,7 @@ import { createClient, SupabaseClient, Session } from "@supabase/supabase-js"; import { Octokit } from "@octokit/rest"; import { GitHubUser } from "../types/github"; +import { CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; declare const SUPABASE_URL: string; declare const SUPABASE_ANON_KEY: string; @@ -131,9 +132,33 @@ export class AuthService { } public async getGitHubUserOrgs(): Promise { - const octokit = await this.getOctokit(); - const response = await octokit.rest.orgs.listForAuthenticatedUser(); - return response.data.map((org: { login: string }) => org.login); + const user = await this.octokit?.rest.users.getAuthenticated(); + const listForAuthUser = await this.octokit?.rest.orgs.listForAuthenticatedUser(); + if (!user || !listForAuthUser) return []; + const listForUserPublic = await this.octokit?.rest.orgs.listForUser({ username: user?.data.login }); + const allOrgs = [...(listForAuthUser?.data || []), ...(listForUserPublic?.data || [])].map((org) => org.login); + + const orgConfigPermissions: Record = {}; + + const getOrgConfigRepoUserPermissions = async (org: string) => { + try { + const p = await this.octokit?.rest.repos.getCollaboratorPermissionLevel({ owner: org, repo: CONFIG_ORG_REPO, username: user?.data.login }); + orgConfigPermissions[org] = p?.data.permission || "none"; + } catch (er) { + console.error(`[getOrgConfigPermissions] - ${org}::`, er); + } + }; + + for (const org of allOrgs) { + await getOrgConfigRepoUserPermissions(org); + } + + return Array.from( + new Set([ + ...(listForAuthUser?.data.map((org) => org.login) || []), + ...Object.keys(orgConfigPermissions).filter((org) => orgConfigPermissions[org] !== "none"), + ]) + ); } public async getGitHubUserOrgRepos(orgs: string[]): Promise> { diff --git a/static/scripts/rendering/config-editor.ts b/static/scripts/rendering/config-editor.ts index 3c30f43..259c25d 100644 --- a/static/scripts/rendering/config-editor.ts +++ b/static/scripts/rendering/config-editor.ts @@ -52,22 +52,27 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M let value: string; if (typeof currentObj === "object" || Array.isArray(currentObj)) { - value = JSON.stringify(currentObj, null, 2); + value = currentObj[key] ? JSON.stringify(currentObj[key]) : ""; + if (value === "") { + // no-op + } else if (!value) { + value = currentObj ? JSON.stringify(currentObj) : ""; + } } else if (typeof currentObj === "boolean") { value = currentObj ? "true" : "false"; + } else if (!currentObj) { + value = ""; } else { value = currentObj as string; } if (input.tagName === "TEXTAREA") { (input as HTMLTextAreaElement).value = value; + } else if (input.tagName === "INPUT" && (input as HTMLInputElement).type === "checkbox") { + (input as HTMLInputElement).checked = value === "true"; } else { (input as HTMLInputElement).value = value; } - - if (input.tagName === "INPUT" && (input as HTMLInputElement).type === "checkbox") { - (input as HTMLInputElement).checked = value === "true"; - } }); } diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts index a8bd6c0..a029bcd 100644 --- a/static/scripts/rendering/input-parsing.ts +++ b/static/scripts/rendering/input-parsing.ts @@ -22,7 +22,6 @@ export function processProperties( if (!prop) { return; } - if (prop.type === "object" && prop.properties) { processProperties(renderer, manifest, prop.properties, fullKey); } else if ("anyOf" in prop && Array.isArray(prop.anyOf)) { @@ -108,6 +107,19 @@ export function parseConfigInputs( missing.push(key); } } + + /** + * We've ID'd the required fields that are missing, now we check if there are any fields + * that have null | undefined values and remove them from the configuration object, + * since the defaults will be used the config prop does not need to be present. + */ + + Object.keys(config).forEach((key) => { + if (config[key] === null || config[key] === undefined || config[key] === "") { + delete config[key]; + } + }); + return { config, missing }; } else { throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2)); diff --git a/static/scripts/rendering/navigation.ts b/static/scripts/rendering/navigation.ts index a350b3d..c53be20 100644 --- a/static/scripts/rendering/navigation.ts +++ b/static/scripts/rendering/navigation.ts @@ -16,7 +16,7 @@ export function createBackButton(renderer: ManifestRenderer): HTMLButtonElement return backButton; } -function handleBackButtonClick(renderer: ManifestRenderer): void { +export function handleBackButtonClick(renderer: ManifestRenderer): void { renderer.manifestGui?.classList.remove("plugin-editor"); const readmeContainer = document.querySelector(".readme-container"); if (readmeContainer) { diff --git a/static/scripts/rendering/write-add-remove.ts b/static/scripts/rendering/write-add-remove.ts index 7c41607..1e63fc8 100644 --- a/static/scripts/rendering/write-add-remove.ts +++ b/static/scripts/rendering/write-add-remove.ts @@ -5,6 +5,7 @@ import { parseConfigInputs } from "./input-parsing"; import { getOfficialPluginConfig } from "../../utils/storage"; import { renderConfigEditor } from "./config-editor"; import { normalizePluginName } from "./utils"; +import { handleBackButtonClick } from "./navigation"; /** * Writes the new configuration to the config file. This does not push the config to GitHub @@ -68,6 +69,8 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo ], }; + removePushNotificationIfPresent(); + if (option === "add") { handleAddPlugin(renderer, plugin, pluginManifest); } else if (option === "remove") { @@ -75,6 +78,13 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo } } +function removePushNotificationIfPresent() { + const notification = document.querySelector(".toast.toast-success.show"); + if (notification) { + notification.remove(); + } +} + function handleAddPlugin(renderer: ManifestRenderer, plugin: Plugin, pluginManifest: Manifest): void { renderer.configParser.addPlugin(plugin); toastNotification(`Configuration for ${pluginManifest.name} saved successfully. Do you want to push to GitHub?`, { @@ -127,6 +137,21 @@ async function notificationConfigPush(renderer: ManifestRenderer) { type: "success", shouldAutoDismiss: true, }); + + const container = document.querySelector("#manifest-gui") as HTMLElement | null; + const readmeContainer = document.querySelector(".readme-container") as HTMLElement | null; + if (container && readmeContainer) { + container.style.transition = "opacity 0.5s ease"; + container.style.opacity = "0"; + readmeContainer.style.transition = "opacity 0.5s ease"; + readmeContainer.style.opacity = "0"; + setTimeout(() => { + handleBackButtonClick(renderer); + container.style.opacity = "1"; + }, 500); + } else { + handleBackButtonClick(renderer); + } } export function handleResetToDefault(renderer: ManifestRenderer, pluginManifest: Manifest | null) { diff --git a/static/utils/element-helpers.ts b/static/utils/element-helpers.ts index b7865ff..26682f6 100644 --- a/static/utils/element-helpers.ts +++ b/static/utils/element-helpers.ts @@ -1,4 +1,5 @@ import { Manifest } from "../types/plugins"; +import { toastNotification } from "./toaster"; const CONFIG_INPUT_STR = "config-input"; @@ -37,7 +38,7 @@ export function createInputRow( valueCell.className = "table-data-value"; valueCell.ariaRequired = `${required}`; - const input = createInput(key, prop.default, prop); + const input = createInput(key, prop.default, prop.type); valueCell.appendChild(input); row.appendChild(valueCell); @@ -49,21 +50,24 @@ export function createInputRow( items: prop.items ? { type: prop.items.type } : null, }; } -export function createInput(key: string, defaultValue: unknown, prop: Manifest["configuration"]): HTMLElement { +export function createInput(key: string, defaultValue: unknown, prop: string): HTMLElement { if (!key) { throw new Error("Input name is required"); } let ele: HTMLElement; - const dataType = prop.type; - - if (dataType === "object" || dataType === "array") { - ele = createTextareaInput(key, defaultValue, dataType); - } else if (dataType === "boolean") { + if (prop === "object" || prop === "array") { + ele = createTextareaInput(key, defaultValue, prop); + } else if (prop === "boolean") { ele = createBooleanInput(key, defaultValue); } else { - ele = createStringInput(key, defaultValue, dataType); + ele = createStringInput(key, defaultValue, prop); + } + + if (!ele) { + toastNotification("An error occurred while creating an input element", { type: "error" }); + throw new Error("Input type is required"); } return ele; @@ -76,7 +80,8 @@ export function createStringInput(key: string, defaultValue: string | unknown, d "data-config-key": key, "data-type": dataType, class: CONFIG_INPUT_STR, - value: `${defaultValue}`, + value: defaultValue ? `${defaultValue}` : "", + placeholder: defaultValue ? "" : `Enter ${dataType}`, }); } export function createBooleanInput(key: string, defaultValue: boolean | unknown): HTMLElement { diff --git a/static/utils/toaster.ts b/static/utils/toaster.ts index d726515..483a7dd 100644 --- a/static/utils/toaster.ts +++ b/static/utils/toaster.ts @@ -28,8 +28,7 @@ export function toastNotification( }); closeButton.addEventListener("click", () => { - toastElement.classList.remove("show"); - setTimeout(() => toastElement.remove(), 250); + kill(); }); toastElement.appendChild(messageElement); @@ -68,21 +67,35 @@ export function toastNotification( toastElement.classList.add("show"); }); - function kill(withTimeout = false) { - if (withTimeout) { - setTimeout(() => { - toastElement.classList.remove("show"); - setTimeout(() => toastElement.remove(), 250); - }, duration); - } else { - toastElement.classList.remove("show"); - setTimeout(() => toastElement.remove(), 250); + let autoDismissTimeout: number | undefined; + + function kill() { + toastElement.classList.remove("show"); + setTimeout(() => toastElement.remove(), 250); + if (autoDismissTimeout) { + clearTimeout(autoDismissTimeout); } } - if (shouldAutoDismiss) { - kill(shouldAutoDismiss); + function startAutoDismiss() { + if (shouldAutoDismiss) { + autoDismissTimeout = window.setTimeout(() => { + kill(); + }, duration); + } } + toastElement.addEventListener("mouseenter", () => { + if (autoDismissTimeout) { + clearTimeout(autoDismissTimeout); + } + }); + + toastElement.addEventListener("mouseleave", () => { + startAutoDismiss(); + }); + + startAutoDismiss(); + return kill; }