diff --git a/.cspell.json b/.cspell.json
index c9a8062..7ab02ee 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -8,5 +8,5 @@
"dictionaries": ["typescript", "node", "software-terms"],
"import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"],
"ignoreRegExpList": ["[0-9a-fA-F]{6}"],
- "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck"]
+ "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck", "Typebox"]
}
diff --git a/.github/knip.ts b/.github/knip.ts
index 0ea0901..1db99c4 100644
--- a/.github/knip.ts
+++ b/.github/knip.ts
@@ -6,7 +6,19 @@ const config: KnipConfig = {
ignore: ["src/types/config.ts", "**/__mocks__/**", "**/__fixtures__/**"],
ignoreExportsUsedInFile: true,
// eslint can also be safely ignored as per the docs: https://knip.dev/guides/handling-issues#eslint--jest
- ignoreDependencies: ["@mswjs/data", "@supabase/supabase-js", "@ubiquity-os/ubiquity-os-kernel", "ajv", "yaml", "simple-git", "@actions/core", "esbuild"],
+ ignoreDependencies: [
+ "@mswjs/data",
+ "@supabase/supabase-js",
+ "@ubiquity-os/ubiquity-os-kernel",
+ "ajv",
+ "yaml",
+ "simple-git",
+ "@actions/core",
+ "esbuild",
+ "@ubiquity-os/plugin-sdk",
+ "markdown-it",
+ "@types/markdown-it",
+ ],
eslint: true,
};
diff --git a/README.md b/README.md
index fea8d1d..0602cd9 100644
--- a/README.md
+++ b/README.md
@@ -48,3 +48,15 @@ The browser automatically URI encodes it:
```
###### Example from `command-start-stop/manifest.json`
+
+## How to run
+
+1. Clone the repository
+2. Run `yarn` to install dependencies
+3. OAuth: Obtain your _GitHub OAuth App_ client ID and secret from your OAuth app settings, and set the callback URL to match the one given by Supabase when enabling GitHub provider OAuth.
+4. Replace the hardcoded `SUPABASE_URL` and `SUPABASE_KEY` in `build/esbuild-build.ts` with your Supabase URL and key (Optionally use `.env` and use `process.env` instead.)
+5. Run `yarn start` and visit `localhost:8080` in your browser.
+6. Once logged in you should see the orgs that you own.
+7. Select an org > select a config (dev | prod) > select a plugin > edit/add/remove > push to GitHub.
+
+TODO: Update readme with a better overview of the project.
diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts
index 3d772f1..6927822 100644
--- a/build/esbuild-build.ts
+++ b/build/esbuild-build.ts
@@ -19,8 +19,9 @@ export const esbuildOptions: BuildOptions = {
define: createEnvDefines([], {
SUPABASE_STORAGE_KEY: generateSupabaseStorageKey(),
NODE_ENV: process.env.NODE_ENV || "development",
- SUPABASE_URL: "https://wfzpewmlyiozupulbuur.supabase.co",
+ SUPABASE_URL: process.env.SUPABASE_URL || "https://wfzpewmlyiozupulbuur.supabase.co",
SUPABASE_ANON_KEY:
+ process.env.SUPABASE_ANON_KEY ||
/* cspell:disable-next-line */
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndmenBld21seWlvenVwdWxidXVyIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTU2NzQzMzksImV4cCI6MjAxMTI1MDMzOX0.SKIL3Q0NOBaMehH0ekFspwgcu3afp3Dl9EDzPqs1nKs",
}),
@@ -57,7 +58,7 @@ function createEnvDefines(environmentVariables: string[], generatedAtBuild: Reco
}
export function generateSupabaseStorageKey(): string | null {
- const SUPABASE_URL = "https://wfzpewmlyiozupulbuur.supabase.co";
+ const SUPABASE_URL = process.env.SUPABASE_URL || "https://wfzpewmlyiozupulbuur.supabase.co";
if (!SUPABASE_URL) {
console.error("SUPABASE_URL environment variable is not set");
return null;
diff --git a/package.json b/package.json
index ab3f4ea..c57f2d1 100644
--- a/package.json
+++ b/package.json
@@ -35,9 +35,11 @@
"dependencies": {
"@octokit/rest": "^21.0.2",
"@supabase/supabase-js": "^2.46.1",
+ "@ubiquity-os/plugin-sdk": "^1.0.11",
"@ubiquity-os/ubiquity-os-kernel": "^2.5.3",
"ajv": "^8.17.1",
"dotenv": "^16.4.4",
+ "markdown-it": "^14.1.0",
"yaml": "^2.6.0"
},
"devDependencies": {
@@ -50,6 +52,7 @@
"@jest/globals": "29.7.0",
"@mswjs/data": "0.16.1",
"@types/jest": "^29.5.12",
+ "@types/markdown-it": "^14.1.2",
"@types/node": "20.14.5",
"cspell": "8.14.4",
"cypress": "13.6.6",
diff --git a/static/index.html b/static/index.html
index 79f76d5..fa71ad7 100644
--- a/static/index.html
+++ b/static/index.html
@@ -26,7 +26,7 @@
| |
| |
diff --git a/static/main.ts b/static/main.ts
index fcd9aa7..9b7e82a 100644
--- a/static/main.ts
+++ b/static/main.ts
@@ -1,71 +1,39 @@
import { AuthService } from "./scripts/authentication";
-import { ManifestDecoder } from "./scripts/decode-manifest";
import { ManifestFetcher } from "./scripts/fetch-manifest";
import { ManifestRenderer } from "./scripts/render-manifest";
-import { OrgWithInstall } from "./types/github";
+import { renderOrgPicker } from "./scripts/rendering/org-select";
import { toastNotification } from "./utils/toaster";
async function handleAuth() {
const auth = new AuthService();
await auth.renderGithubLoginButton();
- const token = await auth.getGitHubAccessToken();
- if (!token) {
- // await auth.signInWithGithub(); force a login?
- }
-
return auth;
}
export async function mainModule() {
const auth = await handleAuth();
- const decoder = new ManifestDecoder();
const renderer = new ManifestRenderer(auth);
- const search = window.location.search.substring(1);
-
- if (search) {
- const decodedManifest = await decoder.decodeManifestFromSearch(search);
- return renderer.renderManifest(decodedManifest);
- }
+ renderer.manifestGuiBody.dataset.loading = "false";
try {
- /**
- * "ubiquity-os", "ubiquity-os-marketplace" === dev config
- * "ubiquity" === prod config
- */
const ubiquityOrgsToFetchOfficialConfigFrom = ["ubiquity-os"];
- const fetcher = new ManifestFetcher(ubiquityOrgsToFetchOfficialConfigFrom, auth.octokit, decoder);
- const cache = fetcher.checkManifestCache();
- if (auth.isActiveSession()) {
- const userOrgs = await auth.getGitHubUserOrgs();
- localStorage.setItem("userOrgs", JSON.stringify(userOrgs));
+ const fetcher = new ManifestFetcher(ubiquityOrgsToFetchOfficialConfigFrom, auth.octokit);
- const appInstallations = await auth.octokit?.apps.listInstallationsForAuthenticatedUser();
- const installs = appInstallations?.data.installations;
+ if (auth.isActiveSession()) {
+ renderer.manifestGuiBody.dataset.loading = "true";
+ const killNotification = toastNotification("Fetching manifest data...", { type: "info", shouldAutoDismiss: true });
- const orgsWithInstalls = userOrgs.map((org) => {
- const orgInstall = installs?.find((install) => {
- if (install.account && "login" in install.account) {
- return install.account.login.toLowerCase() === org.toLowerCase();
- }
- return false;
- });
- return {
- org,
- install: orgInstall,
- };
- }) as OrgWithInstall[];
- renderer.renderOrgPicker(orgsWithInstalls.map((org) => org.org));
+ const userOrgs = await auth.getGitHubUserOrgs();
const userOrgRepos = await auth.getGitHubUserOrgRepos(userOrgs);
localStorage.setItem("orgRepos", JSON.stringify(userOrgRepos));
+ renderOrgPicker(renderer, userOrgs);
- if (Object.keys(cache).length === 0) {
- const manifestCache = await fetcher.fetchMarketplaceManifests();
- localStorage.setItem("manifestCache", JSON.stringify(manifestCache));
- // this is going to extract URLs from our official config which we'll inject into `- plugin: ...`
- await fetcher.fetchOfficialPluginConfig();
- }
+ await fetcher.fetchMarketplaceManifests();
+ await fetcher.fetchOfficialPluginConfig();
+ renderer.manifestGuiBody.dataset.loading = "false";
+ killNotification();
} else {
- renderer.renderOrgPicker([]);
+ renderOrgPicker(renderer, []);
}
} catch (error) {
if (error instanceof Error) {
@@ -76,10 +44,4 @@ export async function mainModule() {
}
}
-mainModule()
- .then(() => {
- console.log("mainModule loaded");
- })
- .catch((error) => {
- console.error(error);
- });
+mainModule().catch(console.error);
diff --git a/static/manifest-gui.css b/static/manifest-gui.css
index 01ae511..1f88ee5 100644
--- a/static/manifest-gui.css
+++ b/static/manifest-gui.css
@@ -1,7 +1,8 @@
html {
background-image: radial-gradient(#000000, #101010);
- background-color: #101010; /* height: 100vh; */
+ background-color: #101010;
}
+
body {
margin: 0;
color: #fff;
@@ -9,9 +10,6 @@ body {
background-image: url(./grid-25.png);
height: calc(100vh - 24px);
}
-controls button {
- background: #101010;
-}
header {
margin: 24px;
@@ -20,73 +18,204 @@ header {
white-space: nowrap;
mask-image: linear-gradient(90deg, #000, transparent);
}
+
header:hover {
opacity: 1;
}
+
header #uos-logo-container {
padding-right: 12px;
}
+
header > * {
display: inline-block;
vertical-align: middle;
}
+
header h1 {
margin: 0;
font-weight: 400;
font-size: 24px;
}
+
header #uos-logo {
height: 36px;
}
+
header #uos-logo path {
fill: #fff;
}
+
+#controls {
+ position: fixed;
+ right: 24px;
+ top: 24px;
+}
+
+#controls button {
+ background: #101010;
+}
+
+#viewport {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: calc(100vh - 96px);
+}
+
+#viewport-cell {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ width: 100%;
+ padding: clamp(16px, 2vw, 32px);
+ gap: 24px;
+}
+
+.readme-container {
+ width: auto;
+ max-width: 700px;
+ user-select: text;
+ background-image: linear-gradient(0deg, #101010, #202020);
+ border-radius: 4px;
+ margin: 0 auto;
+ box-shadow: 0 24px 48px #000000;
+ overflow: auto;
+ padding: 16px 24px;
+ color: #fff;
+ font-family: "Proxima Nova", sans-serif;
+ line-height: 1.6;
+ opacity: 0.85;
+ max-height: calc(100vh - 192px);
+ scrollbar-width: thin;
+ scrollbar-color: #303030 #101010;
+}
+
+.readme-container p {
+ color: #ccc;
+ margin-bottom: 16px;
+}
+
+.readme-container a {
+ color: #1e90ff;
+ text-decoration: none;
+}
+
+.readme-container a:hover {
+ text-decoration: underline;
+}
+
+.readme-container code {
+ background-color: #202020;
+ padding: 2px 4px;
+ border-radius: 4px;
+ color: #1e90ff;
+}
+
+.readme-container pre {
+ background-color: #202020;
+ padding: 16px;
+ border-radius: 4px;
+ overflow: auto;
+ color: #ccc;
+ margin-bottom: 16px;
+}
+
#manifest-gui {
+ width: auto;
+ min-width: 400px;
+ max-width: 1300px;
user-select: none;
background-image: linear-gradient(0deg, #101010, #202020);
- margin-top: 16px;
- border-radius: 4px;
margin: 0 auto;
+ border-radius: 4px;
box-shadow: 0 24px 48px #000000;
overflow: hidden;
- opacity: 0.75;
- transition: 0.25s opacity cubic-bezier(0, 1, 1, 1);
opacity: 0;
- width: 400px;
+ transition: opacity 0.25s cubic-bezier(0, 1, 1, 1);
}
+
#manifest-gui.plugin-editor {
- width: 800px;
+ width: 100%;
}
+
#manifest-gui.rendered {
opacity: 0.75;
}
+
#manifest-gui:hover {
opacity: 1;
}
-.table-data-header {
+
+#manifest-gui > tr > td > * {
+ vertical-align: middle;
+ margin: 8px;
+}
+
+#manifest-gui thead td {
+ border-bottom: 1px solid #303030;
+}
+
+#manifest-gui thead td:last-of-type {
text-align: right;
+ padding: 8px 16px;
+ color: #808080;
+}
+
+#manifest-gui tfoot tr td button {
+ width: calc(33% - 4px);
+ display: inline-block;
+ font-weight: 900;
+ margin: 0 2px;
+}
+
+#manifest-gui pre {
+ margin: 0 0 16px;
+}
+
+.config-input {
+ width: calc(100% - 32px);
+ padding: 8px 16px;
+ background-color: #101010;
+ color: #fff;
+ border-radius: 4px;
+ font-size: 16px;
+ font-family: "Proxima Nova", sans-serif;
+ border-bottom: 1px solid #303030;
+}
+
+.config-input:hover {
+ border-color: #606060;
+}
+
+.config-input:focus {
+ outline: none;
+ border-color: #808080;
+}
+
+.table-data-header {
color: grey;
text-transform: capitalize;
- text-rendering: geometricPrecision; /* padding-left:8px; */
+ text-rendering: geometricPrecision;
+ display: flex;
+ justify-content: right;
+ border-top: 1px solid #303030;
}
-/* #manifest-gui .table-data-header>div{opacity:0;width:0;transition:1s width ease;overflow: hidden;} */
-/* #manifest-gui:hover .table-data-header>div{opacity:1;width:100px} */
-.table-data-header > div {
+
+.table-data-header {
padding: 8px 16px;
}
+
.table-data-value {
user-select: text;
}
-.table-data-value > div {
+
+.table-data-value > input,
+textarea {
padding: 8px 16px;
- border-bottom-left-radius: 4px;
- border-left: 1px solid #303030;
- border-bottom: 1px solid #303030; /* font-weight: bold; */
-}
-#manifest-gui > tr > td > * {
- vertical-align: middle;
- margin: 8px;
}
+
#controls {
position: fixed;
right: 24px;
@@ -97,7 +226,7 @@ button {
background: 0 0;
color: #fff;
font-size: 16px;
- border: none; /* background-color: transparent; */
+ border: none;
padding: 8px 16px;
border-radius: 4px;
opacity: 0.75;
@@ -111,30 +240,33 @@ button:hover {
button:active {
background-color: #606060;
}
-#manifest-gui thead td {
- border-bottom: 1px solid #303030; /* text-align: end; */
-}
-#manifest-gui thead td:last-of-type {
- text-align: right;
- padding: 8px 16px;
- color: #808080;
+button#reset-to-default {
+ background-color: #303030;
+ color: #fff;
}
-#viewport {
- width: 100%;
- display: table;
- height: calc(100vh - ((36px + 8px) * 2) * 2); /* border-collapse: collapse; */
+button#reset-to-default:hover {
+ background-color: #404040;
}
-#viewport-cell {
- height: 100%;
- display: table-cell;
- vertical-align: middle; /* width: 100%; */
+button#reset-to-default:active {
+ background-color: #505050;
}
-#manifest-gui tfoot tr td button {
- width: calc(50% - 4px);
- display: inline-block;
- font-weight: 900;
- margin: 0 2px;
+button#reset-to-default::before {
+ content: "♻️";
+}
+button#reset-to-default:hover::before {
+ content: "Use Defaults";
+}
+button#reset-to-default:active::before {
+ content: "♻️♻️♻️♻️♻️";
+}
+
+button#remove.disabled {
+ background-color: #303030;
+ color: #808080;
+ cursor: not-allowed;
+ opacity: 0.5;
+ pointer-events: none;
}
button#remove::before {
@@ -174,10 +306,6 @@ button#add:active::before {
content: "+++";
}
-#manifest-gui pre {
- margin: 0 0 16px;
-}
-
.picker-select {
width: 100%;
padding: 8px 16px;
@@ -198,25 +326,74 @@ button#add:active::before {
outline: none;
border-color: #808080;
}
+select option {
+ background-color: #101010;
+ color: #fff;
+ font-family: "Proxima Nova", sans-serif;
+ display: flex;
+ justify-content: space-between;
+}
-.config-input {
- width: calc(100% - 32px);
- padding: 8px 16px;
+.custom-select {
+ position: relative;
+ font-family: "Proxima Nova", sans-serif;
+ width: inherit;
+}
+
+.select-selected {
background-color: #101010;
color: #fff;
+ padding: 8px 16px;
border: 1px solid #303030;
border-radius: 4px;
- font-size: 16px;
- font-family: "Proxima Nova", sans-serif;
+ cursor: pointer;
+ user-select: none;
+ position: relative;
}
-.config-input:hover {
- border-color: #606060;
+.select-selected::after {
+ content: "";
+ position: absolute;
+ top: 18px;
+ right: 16px;
+ width: 0;
+ height: 0;
+ border: 6px solid transparent;
+ border-color: #fff transparent transparent transparent;
}
-.config-input:focus {
- outline: none;
- border-color: #808080;
+.select-selected.select-arrow-active::after {
+ border-color: transparent transparent #fff transparent;
+ top: 12px;
+}
+
+.select-items {
+ background-color: #101010;
+ border: 1px solid #303030;
+ border-radius: 8px;
+ z-index: 99;
+ width: inherit;
+ max-height: 400px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: #303030 #101010;
+}
+
+.select-items .select-option {
+ color: #fff;
+ padding: 8px 16px;
+ display: flex;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+}
+
+.select-items .select-option:hover {
+ background-color: #303030;
+}
+
+.select-hide {
+ display: none;
}
.picker-container,
@@ -259,23 +436,6 @@ button#add:active::before {
margin-bottom: 16px;
}
-/* Select Elements */
-.picker-select {
- width: 100%;
- padding: 8px 16px;
- background-color: #101010;
- color: #fff;
- border: 1px solid #303030;
- border-radius: 4px;
- font-size: 16px;
- font-family: "Proxima Nova", sans-serif;
-}
-
-.picker-select:focus {
- outline: none;
- border-color: #606060;
-}
-
.save-button {
appearance: none;
background: #101010;
diff --git a/static/scripts/authentication.ts b/static/scripts/authentication.ts
index fd708e5..74e8660 100644
--- a/static/scripts/authentication.ts
+++ b/static/scripts/authentication.ts
@@ -65,7 +65,13 @@ export class AuthService {
public async signInWithGithub(): Promise {
const search = window.location.search;
localStorage.setItem("manifest", search);
- const { data } = await this.supabase.auth.signInWithOAuth({ provider: "github" });
+ const { data } = await this.supabase.auth.signInWithOAuth({
+ provider: "github",
+ options: {
+ scopes: "read:org read:user user:email repo",
+ redirectTo: `${window.location.href}`,
+ },
+ });
if (!data) throw new Error("Failed to sign in with GitHub");
}
diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts
index 9dfbe80..a02b21c 100644
--- a/static/scripts/config-parser.ts
+++ b/static/scripts/config-parser.ts
@@ -3,161 +3,182 @@ import { Plugin, PluginConfig } from "../types/plugins";
import { Octokit } from "@octokit/rest";
import { RequestError } from "@octokit/request-error";
import { toastNotification } from "../utils/toaster";
-
-const CONFIG_PATH = ".github/.ubiquity-os.config.yml";
-const UBIQUITY_OS = ".ubiquity-os";
-
+import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants";
+
+/**
+ * Responsible for fetching, parsing, and updating the user's installed plugin configurations.
+ *
+ * - `configRepoExistenceCheck` checks if the user has a config repo and creates one if not
+ * - `repoFileExistenceCheck` checks if the user has a config file and creates one if not
+ * - `fetchUserInstalledConfig` fetches the user's installed config from the config repo
+ */
export class ConfigParser {
repoConfig: string | null = null;
repoConfigSha: string | null = null;
newConfigYml: string | null = null;
- async fetchUserInstalledConfig(org: string | null, repo: string | null, env: "development" | "production", octokit: Octokit | null) {
- if (!org || !octokit) {
- throw new Error("Missing required parameters");
+ async configRepoExistenceCheck(org: string, repo: string, octokit: Octokit) {
+ if (!org || !repo) {
+ throw new Error("Organization or repo name not provided");
}
- const content = this.loadConfig();
- if (!content) {
- throw new Error("No content to push");
- }
+ let exists;
try {
- const existingConfig = await octokit.repos.getContent({
+ await octokit.repos.get({
owner: org,
- repo: repo ? repo : UBIQUITY_OS,
- path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"),
- ref: repo ? "development" : "main",
+ repo,
});
+ exists = true;
+ } catch (error) {
+ console.log(error);
+ exists = false;
+ }
- if (existingConfig && "content" in existingConfig.data) {
- this.repoConfigSha = existingConfig.data.sha;
- this.repoConfig = atob(existingConfig.data.content);
- }
- } catch (er) {
- console.log(er);
- if (er instanceof RequestError && er.status === 404) {
- const msgParts = ["Could not find the", env, "config file in", repo ? repo : `your org: ${org}`, ", would you like to create one?"];
-
- toastNotification(msgParts.join(" "), {
- type: "success",
- actionText: "Create",
- action: async () => {
- await this.handleMissingStorageBranchOrFile(octokit, org, repo);
- },
+ if (!exists) {
+ try {
+ await octokit.repos.createInOrg({
+ name: repo,
+ description: "UbiquityOS Configuration Repo",
+ org,
});
+
+ toastNotification("We noticed you don't have a '.ubiquity-os' config repo, so we created one for you.", { type: "success" });
+ } catch (er) {
+ console.log(er);
+ throw new Error("Config repo creation failed");
}
}
+
+ return exists;
}
- parseConfig(config?: string | null): PluginConfig {
- if (config && typeof config === "string") {
- return YAML.parse(config);
- } else {
- return YAML.parse(this.loadConfig());
+ async repoFileExistenceCheck(org: string, octokit: Octokit, repo: string, path: string) {
+ try {
+ const { data } = await octokit.repos.getContent({
+ owner: org,
+ repo,
+ path,
+ });
+
+ return data;
+ } catch (error) {
+ console.error(error);
}
+
+ return null;
}
- async updateConfig(org: string, repo: string | null, env: "development" | "production", octokit: Octokit, option: "add" | "remove") {
- let repoPlugins = this.parseConfig(this.repoConfig).plugins;
- const newPlugins = this.parseConfig().plugins;
+ async fetchUserInstalledConfig(org: string, octokit: Octokit, repo = CONFIG_ORG_REPO, path = CONFIG_FULL_PATH) {
+ if (repo === CONFIG_ORG_REPO) {
+ await this.configRepoExistenceCheck(org, repo, octokit);
+ }
- if (!newPlugins) {
- throw new Error("No plugins found in the config");
+ let existingConfig = await this.repoFileExistenceCheck(org, octokit, repo, path);
+
+ if (!existingConfig) {
+ try {
+ this.newConfigYml = YAML.stringify({ plugins: [] });
+ await octokit.repos.createOrUpdateFileContents({
+ owner: org,
+ repo,
+ path,
+ message: `chore: creating config`,
+ content: btoa(this.newConfigYml),
+ });
+
+ toastNotification(`We couldn't locate your config file, so we created an empty one for you.`, { type: "success" });
+
+ existingConfig = await this.repoFileExistenceCheck(org, octokit, repo, path);
+ } catch (er) {
+ console.log(er);
+ throw new Error("Config file creation failed");
+ }
}
- const newPluginNames = newPlugins.map((p) => p.uses[0].plugin);
- if (newPluginNames.length === 0) {
- throw new Error("No plugins found in the config");
+ if (existingConfig && "content" in existingConfig) {
+ this.repoConfigSha = existingConfig.sha;
+ this.repoConfig = atob(existingConfig.content);
+ } else {
+ throw new Error("No existing config found");
}
- if (option === "add") {
- // update if it exists, add if it doesn't
- newPlugins.forEach((newPlugin) => {
- const existingPlugin = repoPlugins.find((p) => p.uses[0].plugin === newPlugin.uses[0].plugin);
- if (existingPlugin) {
- existingPlugin.uses[0].with = newPlugin.uses[0].with;
- } else {
- repoPlugins.push(newPlugin);
- }
- });
+ return this.repoConfig;
+ }
- this.newConfigYml = YAML.stringify({ plugins: repoPlugins });
- } else if (option === "remove") {
- // remove only this plugin, keep all others
- newPlugins.forEach((newPlugin) => {
- const existingPlugin = repoPlugins.find((p) => p.uses[0].plugin === newPlugin.uses[0].plugin);
- if (existingPlugin) {
- repoPlugins = repoPlugins.filter((p) => p.uses[0].plugin !== newPlugin.uses[0].plugin);
- }
- });
- this.newConfigYml = YAML.stringify({ plugins: newPlugins });
+ parseConfig(config?: string | null): PluginConfig {
+ if (config && typeof config === "string") {
+ return YAML.parse(config);
+ } else {
+ return YAML.parse(this.loadConfig());
}
+ }
- this.saveConfig();
- return this.createOrUpdateFileContents(org, repo, env, octokit);
+ async updateConfig(org: string, octokit: Octokit, repo = CONFIG_ORG_REPO, path = CONFIG_FULL_PATH) {
+ return this.createOrUpdateFileContents(org, repo, path, octokit);
}
- async createOrUpdateFileContents(org: string, repo: string | null, env: "development" | "production", octokit: Octokit) {
+ async createOrUpdateFileContents(org: string, repo: string, path: string, octokit: Octokit) {
const recentSha = await octokit.repos.getContent({
owner: org,
- repo: repo ? repo : UBIQUITY_OS,
- path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"),
- ref: repo ? "development" : "main",
+ repo: repo,
+ path,
});
const sha = "sha" in recentSha.data ? recentSha.data.sha : null;
- try {
- return octokit.repos.createOrUpdateFileContents({
- owner: org,
- repo: repo ? repo : UBIQUITY_OS,
- path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"),
- message: `chore: updating ${env} config`,
- content: btoa(`${this.newConfigYml}`),
- sha: `${sha}`,
- branch: repo ? "development" : "main",
- });
- } catch (err) {
- throw new Error(`Failed to create or update file contents:\n ${String(err)}`);
+ if (!sha) {
+ throw new Error("No sha found");
}
+
+ if (!this.newConfigYml) {
+ throw new Error("No content to push");
+ }
+
+ this.repoConfig = this.newConfigYml;
+
+ return octokit.repos.createOrUpdateFileContents({
+ owner: org,
+ repo: repo,
+ path,
+ message: `chore: Plugin Installer UI - update`,
+ content: btoa(this.newConfigYml),
+ sha,
+ });
}
addPlugin(plugin: Plugin) {
- const config = this.loadConfig();
- const parsedConfig = YAML.parse(config);
- if (!parsedConfig.plugins) {
- parsedConfig.plugins = [];
+ const parsedConfig = this.parseConfig(this.repoConfig);
+ parsedConfig.plugins ??= [];
+
+ const existingPlugin = parsedConfig.plugins.find((p) => p.uses[0].plugin === plugin.uses[0].plugin);
+ if (existingPlugin) {
+ existingPlugin.uses[0].with = plugin.uses[0].with;
+ } else {
+ parsedConfig.plugins.push(plugin);
}
- parsedConfig.plugins.push(plugin);
+
this.newConfigYml = YAML.stringify(parsedConfig);
+ this.repoConfig = this.newConfigYml;
this.saveConfig();
}
removePlugin(plugin: Plugin) {
- const config = this.loadConfig();
- const parsedConfig = YAML.parse(config);
+ const parsedConfig = this.parseConfig(this.repoConfig);
if (!parsedConfig.plugins) {
- console.log("No plugins to remove");
+ toastNotification("No plugins found in config", { type: "error" });
return;
}
+
parsedConfig.plugins = parsedConfig.plugins.filter((p: Plugin) => p.uses[0].plugin !== plugin.uses[0].plugin);
- console.log(parsedConfig);
this.newConfigYml = YAML.stringify(parsedConfig);
+ this.repoConfig = this.newConfigYml;
this.saveConfig();
}
- /**
- * Loads the current config from local storage or
- * creates a new one if it doesn't exist.
- *
- * If a new config is created, it is also saved to local storage.
- * When a new config is created, it is a blank JS object representing
- * the ubiquity-os.config.yml file.
- */
- loadConfig() {
+ loadConfig(): string {
if (!this.newConfigYml) {
- this.newConfigYml = localStorage.getItem("config");
+ this.newConfigYml = localStorage.getItem("config") as string;
}
if (!this.newConfigYml) {
@@ -168,7 +189,7 @@ export class ConfigParser {
this.repoConfig = YAML.parse(this.newConfigYml);
}
- return this.newConfigYml as string;
+ return this.newConfigYml;
}
saveConfig() {
diff --git a/static/scripts/decode-manifest.ts b/static/scripts/decode-manifest.ts
deleted file mode 100644
index 623319e..0000000
--- a/static/scripts/decode-manifest.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Manifest, ManifestPreDecode } from "../types/plugins";
-
-export class ManifestDecoder {
- constructor() {}
-
- decodeManifestFromFetch(manifest: ManifestPreDecode) {
- if (manifest.error) {
- return null;
- }
-
- const decodedManifest: Manifest = {
- name: manifest.name,
- description: manifest.description,
- "ubiquity:listeners": manifest["ubiquity:listeners"],
- configuration: manifest.configuration,
- };
-
- return decodedManifest;
- }
-
- decodeManifestFromSearch(search: string) {
- const parsed = this.stringUriParser(search);
-
- const encodedManifestEnvelope = parsed.find((pair) => pair["manifest"]);
- if (!encodedManifestEnvelope) {
- throw new Error("No encoded manifest found!");
- }
- const encodedManifest = encodedManifestEnvelope["manifest"];
- const decodedManifest = decodeURI(encodedManifest);
-
- this.renderManifest(decodedManifest);
- return JSON.parse(decodedManifest);
- }
-
- stringUriParser(input: string): Array<{ [key: string]: string }> {
- const buffer: Array<{ [key: string]: string }> = [];
- const sections = input.split("&");
- for (const section of sections) {
- const keyValues = section.split("=");
- buffer.push({ [keyValues[0]]: keyValues[1] });
- }
- return buffer;
- }
-
- renderManifest(manifest: string) {
- const dfg = document.createDocumentFragment();
- dfg.textContent = manifest;
- }
-}
diff --git a/static/scripts/fetch-manifest.ts b/static/scripts/fetch-manifest.ts
index f212b38..9a02094 100644
--- a/static/scripts/fetch-manifest.ts
+++ b/static/scripts/fetch-manifest.ts
@@ -1,28 +1,29 @@
import { Octokit } from "@octokit/rest";
-import { ManifestDecoder } from "./decode-manifest";
-import { ManifestPreDecode } from "../types/plugins";
+import { Manifest, ManifestPreDecode } from "../types/plugins";
+import { DEV_CONFIG_FULL_PATH, CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants";
+import { getOfficialPluginConfig } from "../utils/storage";
/**
- * Given a list of repositories, fetch the manifest for each repository.
+ * Responsible for:
+ * - Mainly UbiquityOS Marketplace data fetching (config-parser fetches user configs)
+ * - Fetching the manifest.json files from the marketplace
+ * - Fetching the README.md files from the marketplace
+ * - Fetching the official plugin config from the orgs
+ * - Capturing the worker and action urls from the official plugin config (will be taken from the manifest directly soon)
+ * - Storing the fetched data in localStorage
*/
export class ManifestFetcher {
private _orgs: string[];
private _octokit: Octokit | null;
- private _decoder: ManifestDecoder;
workerUrlRegex = /https:\/\/([a-z0-9-]+)\.ubiquity\.workers\.dev/g;
actionUrlRegex = /[a-z0-9-]+\/[a-z0-9-]+(?:\/[^@]+)?@[a-z0-9-]+/g;
workerUrls = new Set();
actionUrls = new Set();
- devYmlConfigPath = ".github/.ubiquity-os.config.dev.yml";
- prodYmlConfigPath = ".github/.ubiquity-os.config.yml";
- configRepo = ".ubiquity-os";
-
- constructor(orgs: string[], octokit: Octokit | null, decoder: ManifestDecoder) {
+ constructor(orgs: string[], octokit: Octokit | null) {
this._orgs = orgs;
this._octokit = octokit;
- this._decoder = decoder;
}
async fetchMarketplaceManifests() {
@@ -34,12 +35,13 @@ export class ManifestFetcher {
const manifestCache = this.checkManifestCache();
for (const repo of repos.data) {
- const manifestUrl = `https://raw.githubusercontent.com/${org}/${repo.name}/development/manifest.json`;
- const manifest = await this.fetchActionManifest(manifestUrl);
- const decoded = this._decoder.decodeManifestFromFetch(manifest);
+ const manifestUrl = this.createGithubRawEndpoint(org, repo.name, "development", "manifest.json");
+ const manifest = await this.fetchPluginManifest(manifestUrl);
+ const decoded = this.decodeManifestFromFetch(manifest);
+ const readme = await this.fetchPluginReadme(this.createGithubRawEndpoint(org, repo.name, "development", "README.md"));
if (decoded) {
- manifestCache[manifestUrl] = decoded;
+ manifestCache[manifestUrl] = { ...decoded, readme };
}
}
@@ -48,7 +50,6 @@ export class ManifestFetcher {
}
checkManifestCache(): Record {
- // check if the manifest is already in the cache
const manifestCache = localStorage.getItem("manifestCache");
if (manifestCache) {
return JSON.parse(manifestCache);
@@ -65,9 +66,9 @@ export class ManifestFetcher {
}
}
- createActionEndpoint(owner: string, repo: string, branch: string) {
+ createGithubRawEndpoint(owner: string, repo: string, branch: string, path: string) {
// no endpoint so we fetch the raw content from the owner/repo/branch
- return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/manifest.json`;
+ return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/${path}`;
}
captureActionUrls(config: string) {
@@ -79,7 +80,7 @@ export class ManifestFetcher {
async fetchOfficialPluginConfig() {
await this.fetchOrgsUbiquityOsConfigs();
- const officialPluginConfig = JSON.parse(localStorage.getItem("officialPluginConfig") || "{}") || {};
+ const officialPluginConfig = getOfficialPluginConfig();
this.workerUrls.forEach((url) => {
officialPluginConfig[url] = { workerUrl: url };
@@ -96,42 +97,53 @@ export class ManifestFetcher {
return officialPluginConfig;
}
- async fetchWorkerManifest(workerUrl: string) {
- const url = workerUrl + "/manifest.json";
+ async fetchPluginManifest(actionUrl: string) {
try {
- const response = await fetch(url, {
- headers: {
- "Content-Type": "application/json",
- },
- method: "GET",
- });
+ const response = await fetch(actionUrl);
return await response.json();
} catch (e) {
- let error = e;
- try {
- const res = await fetch(url.replace(/development/g, "main"));
- return await res.json();
- } catch (e) {
- error = e;
- }
- console.error(error);
- if (error instanceof Error) {
- return { workerUrl, error: error.message };
+ if (e instanceof Error) {
+ return { actionUrl, error: e.message };
}
- return { workerUrl, error: String(error) };
+ console.error(e);
+ return { actionUrl, error: String(e) };
}
}
- async fetchActionManifest(actionUrl: string) {
+ async fetchPluginReadme(pluginUrl: string) {
+ async function handle404(result: string, octokit?: Octokit | null) {
+ if (result.includes("404: Not Found")) {
+ const [owner, repo] = pluginUrl.split("/").slice(3, 5);
+ const readme = await octokit?.repos.getContent({
+ owner,
+ repo,
+ path: "README.md",
+ });
+
+ if (readme && "content" in readme.data) {
+ return atob(readme.data.content);
+ } else {
+ return "No README.md found";
+ }
+ }
+
+ return result;
+ }
try {
- const response = await fetch(actionUrl);
- return await response.json();
+ const response = await fetch(pluginUrl, { signal: new AbortController().signal });
+ return await handle404(await response.text(), this._octokit);
} catch (e) {
- if (e instanceof Error) {
- return { actionUrl, error: e.message };
+ let error = e;
+ try {
+ const res = await fetch(pluginUrl.replace(/development/g, "main"), { signal: new AbortController().signal });
+ return await handle404(await res.text(), this._octokit);
+ } catch (e) {
+ error = e;
}
- console.error(e);
- return { actionUrl, error: String(e) };
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
}
}
@@ -146,8 +158,8 @@ export class ManifestFetcher {
try {
const { data: devConfig } = await this._octokit.repos.getContent({
owner: org,
- repo: this.configRepo,
- path: this.devYmlConfigPath,
+ repo: CONFIG_ORG_REPO,
+ path: DEV_CONFIG_FULL_PATH,
});
if ("content" in devConfig) {
@@ -160,8 +172,8 @@ export class ManifestFetcher {
try {
const { data: prodConfig } = await this._octokit.repos.getContent({
owner: org,
- repo: this.configRepo,
- path: this.prodYmlConfigPath,
+ repo: CONFIG_ORG_REPO,
+ path: CONFIG_FULL_PATH,
});
if ("content" in prodConfig) {
@@ -177,4 +189,19 @@ export class ManifestFetcher {
this.captureActionUrls(config);
}
}
+
+ decodeManifestFromFetch(manifest: ManifestPreDecode) {
+ if (manifest.error) {
+ return null;
+ }
+
+ const decodedManifest: Manifest = {
+ name: manifest.name,
+ description: manifest.description,
+ "ubiquity:listeners": manifest["ubiquity:listeners"],
+ configuration: manifest.configuration,
+ };
+
+ return decodedManifest;
+ }
}
diff --git a/static/scripts/render-manifest.ts b/static/scripts/render-manifest.ts
index a12d48d..09219f3 100644
--- a/static/scripts/render-manifest.ts
+++ b/static/scripts/render-manifest.ts
@@ -1,21 +1,13 @@
-import { Manifest } from "@ubiquity-os/ubiquity-os-kernel";
-import { ManifestCache, ManifestPreDecode, ManifestProps, Plugin } from "../types/plugins";
import { ConfigParser } from "./config-parser";
import { AuthService } from "./authentication";
-import AJV, { AnySchemaObject } from "ajv";
-import { createElement, createInputRow } from "../utils/ele-helpers";
-import { toastNotification } from "../utils/toaster";
-
-const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true });
-
-const TDV_CENTERED = "table-data-value centered";
-const PICKER_SELECT_STR = "picker-select";
-const NO_ORG_ERROR = "No selected org found";
-
-type ExtendedHtmlElement = {
- [key in keyof T]: T[key] extends HTMLElement["innerHTML"] ? string | null : T[key];
-};
-
+import { ExtendedHtmlElement } from "../types/github";
+import { controlButtons } from "./rendering/control-buttons";
+import { createBackButton } from "./rendering/navigation";
+
+/**
+ * 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.
+ */
export class ManifestRenderer {
private _manifestGui: HTMLElement;
private _manifestGuiBody: ExtendedHtmlElement;
@@ -23,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" | "configSelector" | "pluginSelector" | "configEditor" = "orgPicker";
+ private _currentStep: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" = "orgPicker";
private _orgs: string[] = [];
constructor(auth: AuthService) {
@@ -37,622 +29,67 @@ export class ManifestRenderer {
this._manifestGui = manifestGui as HTMLElement;
this._manifestGuiBody = manifestGuiBody as HTMLElement;
- this._controlButtons(true);
-
- this._backButton = createElement("button", {
- id: "back-button",
- class: "button",
- textContent: "Back",
- }) as HTMLButtonElement;
+ controlButtons({ hide: true });
+ this.currentStep = "orgPicker";
const title = manifestGui.querySelector("#manifest-gui-title");
+ this._backButton = createBackButton(this);
title?.previousSibling?.appendChild(this._backButton);
- this._backButton.style.display = "none";
- this._backButton.addEventListener("click", this._handleBackButtonClick.bind(this));
- }
-
- private _handleBackButtonClick(): void {
- switch (this._currentStep) {
- case "configSelector": {
- this.renderOrgPicker(this._orgs);
- break;
- }
- case "pluginSelector": {
- const selectedConfig = localStorage.getItem("selectedConfig") as "development" | "production";
- this._renderConfigSelector(selectedConfig);
- break;
- }
- case "configEditor": {
- const selectedConfig = localStorage.getItem("selectedConfig") as "development" | "production";
- this._renderPluginSelector(selectedConfig);
- break;
- }
- case "repoPicker": {
- const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}");
- this.renderRepoPicker(orgRepos);
- break;
- }
- case "orgPicker": {
- const orgs = JSON.parse(localStorage.getItem("userOrgs") || "[]");
- this.renderOrgPicker(orgs);
- break;
- }
- default:
- break;
- }
}
- // Event Handlers
-
- private _handleOrgSelection(event: Event): void {
- const selectElement = event.target as HTMLSelectElement;
- const selectedOrg = selectElement.value;
- if (selectedOrg) {
- localStorage.setItem("selectedOrg", selectedOrg);
- const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}");
- this.renderRepoPicker(orgRepos);
- }
+ get orgs(): string[] {
+ return this._orgs;
}
- private _handleRepoSelection(event: Event): void {
- const selectElement = event.target as HTMLSelectElement;
- const selectedRepo = selectElement.value;
- if (selectedRepo) {
- localStorage.setItem("selectedRepo", selectedRepo);
- const selectedOrg = localStorage.getItem("selectedOrg");
- if (!selectedOrg) {
- throw new Error(NO_ORG_ERROR);
- }
- this._renderConfigSelector(selectedOrg);
- }
- }
-
- private _handlePluginSelection(event: Event): void {
- try {
- const selectElement = event.target as HTMLSelectElement;
- const selectedPluginManifest = selectElement.value;
- if (selectedPluginManifest) {
- localStorage.setItem("selectedPluginManifest", selectedPluginManifest);
- this._renderConfigEditor(selectedPluginManifest);
- }
- } catch (error) {
- console.error("Error handling plugin selection:", error);
- alert("An error occurred while selecting the plugin.");
- }
- }
-
- private _handleConfigSelection(event: Event): void {
- try {
- const selectElement = event.target as HTMLSelectElement;
- const selectedConfig = selectElement.value as "development" | "production";
- if (selectedConfig) {
- const fetchOrgConfig = async () => {
- const org = localStorage.getItem("selectedOrg");
- const octokit = this._auth.octokit;
- if (!org || !octokit) {
- throw new Error("No org or octokit found");
- }
- const selectedRepo = localStorage.getItem("selectedRepo");
- await this._configParser.fetchUserInstalledConfig(org, selectedRepo, selectedConfig, octokit);
- };
- localStorage.setItem("selectedConfig", selectedConfig);
- this._renderPluginSelector(selectedConfig);
- fetchOrgConfig().catch(console.error);
- }
- } catch (error) {
- console.error("Error handling configuration selection:", error);
- alert("An error occurred while selecting the configuration.");
- }
- }
-
- // UI Rendering
-
- private _controlButtons(hide: boolean): void {
- const addButton = document.getElementById("add");
- const removeButton = document.getElementById("remove");
- if (addButton) {
- addButton.style.display = hide ? "none" : "inline-block";
- }
- if (removeButton) {
- removeButton.style.display = hide ? "none" : "inline-block";
- }
-
- this._manifestGui?.classList.add("rendered");
- }
-
- public renderOrgPicker(orgs: string[]): void {
+ set orgs(orgs: string[]) {
this._orgs = orgs;
- this._currentStep = "orgPicker";
- this._controlButtons(true);
- this._backButton.style.display = "none";
- this._manifestGui?.classList.add("rendering");
- this._manifestGuiBody.innerHTML = null;
-
- const pickerRow = document.createElement("tr");
- const pickerCell = document.createElement("td");
- pickerCell.colSpan = 4;
- pickerCell.className = TDV_CENTERED;
-
- if (!orgs.length) {
- const hasSession = this._auth.isActiveSession();
- if (hasSession) {
- this._updateGuiTitle("No installations found");
- this._manifestGuiBody.appendChild(pickerRow);
- this._manifestGui?.classList.add("rendered");
- } else {
- this._updateGuiTitle("Please sign in to GitHub");
- }
- return;
- }
-
- this._updateGuiTitle("Select an Organization");
-
- const orgSelect = createElement("select", {
- id: "org-picker-select",
- class: PICKER_SELECT_STR,
- style: "width: 100%",
- });
-
- const defaultOption = createElement("option", {
- value: null,
- textContent: "Found installations...",
- });
- orgSelect.appendChild(defaultOption);
-
- orgs.forEach((org) => {
- const option = createElement("option", {
- value: org,
- textContent: org,
- });
- orgSelect.appendChild(option);
- });
-
- orgSelect.addEventListener("change", this._handleOrgSelection.bind(this));
- pickerCell.appendChild(orgSelect);
- pickerRow.appendChild(pickerCell);
- this._manifestGuiBody.appendChild(pickerRow);
- this._manifestGui?.classList.add("rendered");
}
- public renderRepoPicker(repos: Record): void {
- this._currentStep = "repoPicker";
- this._controlButtons(true);
- this._backButton.style.display = "block";
- this._manifestGui?.classList.add("rendering");
- this._manifestGuiBody.innerHTML = null;
-
- if (!Reflect.ownKeys(repos).length) {
- this._updateGuiTitle("No repositories found");
- this._manifestGuiBody.appendChild(document.createElement("tr"));
- this._manifestGui?.classList.add("rendered");
- return;
- }
-
- localStorage.setItem("orgRepos", JSON.stringify(repos));
-
- const pickerRow = document.createElement("tr");
- const pickerCell = document.createElement("td");
- pickerCell.colSpan = 4;
- pickerCell.className = TDV_CENTERED;
-
- this._updateGuiTitle("Select a Repository");
- const selectedOrg = localStorage.getItem("selectedOrg");
-
- if (!selectedOrg) {
- throw new Error(NO_ORG_ERROR);
- }
-
- const orgConfigButton = createElement("button", {
- id: "org-config-button",
- class: "button",
- textContent: "Select Organization Configuration",
- });
-
- orgConfigButton.addEventListener("click", () => {
- this._renderConfigSelector(selectedOrg);
- });
-
- const orgConfigRow = document.createElement("tr");
- const orgConfigCell = document.createElement("td");
- orgConfigCell.colSpan = 4;
- orgConfigCell.className = TDV_CENTERED;
- orgConfigCell.appendChild(orgConfigButton);
- orgConfigRow.appendChild(orgConfigCell);
- this._manifestGuiBody.appendChild(orgConfigRow);
-
- const repoSelect = createElement("select", {
- id: "repo-picker-select",
- class: PICKER_SELECT_STR,
- style: "width: 100%",
- });
-
- const defaultOption = createElement("option", {
- value: null,
- textContent: "Or select a repository...",
- });
- repoSelect.appendChild(defaultOption);
-
- const orgRepos = repos[selectedOrg];
-
- if (!orgRepos) {
- throw new Error("No org repos found");
- }
-
- orgRepos.forEach((repo) => {
- const option = createElement("option", {
- value: repo,
- textContent: repo,
- });
- repoSelect.appendChild(option);
- });
-
- repoSelect.addEventListener("change", this._handleRepoSelection.bind(this));
- pickerCell.appendChild(repoSelect);
- pickerRow.appendChild(pickerCell);
- this._manifestGuiBody.appendChild(pickerRow);
- this._manifestGui?.classList.add("rendered");
+ get currentStep(): "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" {
+ return this._currentStep;
}
- private _renderConfigSelector(selectedOrg: string): void {
- this._currentStep = "configSelector";
- this._backButton.style.display = "block";
- this._manifestGuiBody.innerHTML = null;
- this._controlButtons(true);
-
- const pickerRow = document.createElement("tr");
- const pickerCell = document.createElement("td");
- pickerCell.colSpan = 2;
- pickerCell.className = TDV_CENTERED;
-
- const configSelect = createElement("select", {
- id: "config-selector-select",
- class: PICKER_SELECT_STR,
- });
-
- const defaultOption = createElement("option", {
- value: null,
- textContent: "Select a configuration",
- });
- configSelect.appendChild(defaultOption);
-
- const configs = ["development", "production"];
- configs.forEach((config) => {
- const option = createElement("option", {
- value: config,
- textContent: config.charAt(0).toUpperCase() + config.slice(1),
- });
- configSelect.appendChild(option);
- });
-
- configSelect.removeEventListener("change", this._handleConfigSelection.bind(this));
- configSelect.addEventListener("change", this._handleConfigSelection.bind(this));
- pickerCell.appendChild(configSelect);
- pickerRow.appendChild(pickerCell);
- this._updateGuiTitle(`Select a Configuration for ${selectedOrg}`);
- this._manifestGuiBody.appendChild(pickerRow);
+ set currentStep(step: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor") {
+ this._currentStep = step;
}
- private _renderPluginSelector(selectedConfig: "development" | "production"): void {
- this._currentStep = "pluginSelector";
- this._backButton.style.display = "block";
- this._manifestGuiBody.innerHTML = null;
- this._controlButtons(true);
-
- const manifestCache = JSON.parse(localStorage.getItem("manifestCache") || "{}") as ManifestCache;
- const pluginUrls = Object.keys(manifestCache);
-
- const pickerRow = document.createElement("tr");
- const pickerCell = document.createElement("td");
- pickerCell.colSpan = 2;
- pickerCell.className = TDV_CENTERED;
-
- const pluginSelect = createElement("select", {
- id: "plugin-selector-select",
- class: PICKER_SELECT_STR,
- });
-
- const defaultOption = createElement("option", {
- value: null,
- textContent: "Select a plugin",
- });
- pluginSelect.appendChild(defaultOption);
-
- const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => {
- if (manifestCache[key]?.name) {
- acc[key] = manifestCache[key];
- }
- return acc;
- }, {} as ManifestCache);
-
- pluginUrls.forEach((url) => {
- if (!cleanManifestCache[url]?.name) {
- return;
- }
- const option = createElement("option", {
- value: JSON.stringify(cleanManifestCache[url]),
- textContent: cleanManifestCache[url]?.name,
- });
- pluginSelect.appendChild(option);
- });
-
- pluginSelect.addEventListener("change", this._handlePluginSelection.bind(this));
- pickerCell.appendChild(pluginSelect);
- pickerRow.appendChild(pickerCell);
-
- this._updateGuiTitle(`Select a Plugin for ${selectedConfig}`);
- this._manifestGuiBody.appendChild(pickerRow);
+ get backButton(): HTMLButtonElement {
+ return this._backButton;
}
- renderManifest(decodedManifest: ManifestPreDecode) {
- if (!decodedManifest) {
- throw new Error("No decoded manifest found!");
- }
- this._manifestGui?.classList.add("rendering");
- this._manifestGuiBody.innerHTML = null;
-
- const table = document.createElement("table");
- Object.entries(decodedManifest).forEach(([key, value]) => {
- const row = document.createElement("tr");
-
- const headerCell = document.createElement("td");
- headerCell.className = "table-data-header";
- headerCell.textContent = key.replace("ubiquity:", "");
- row.appendChild(headerCell);
-
- const valueCell = document.createElement("td");
- valueCell.className = "table-data-value";
-
- if (typeof value === "string") {
- valueCell.textContent = value;
- } else {
- const pre = document.createElement("pre");
- pre.textContent = JSON.stringify(value, null, 2);
- valueCell.appendChild(pre);
- }
-
- row.appendChild(valueCell);
- table.appendChild(row);
- });
-
- this._manifestGuiBody.appendChild(table);
- this._manifestGui?.classList.add("rendered");
+ set backButton(button: HTMLButtonElement) {
+ this._backButton = button;
}
- private _boundConfigAdd = this._writeNewConfig.bind(this, "add");
- private _boundConfigRemove = this._writeNewConfig.bind(this, "remove");
- private _renderConfigEditor(manifestStr: string): void {
- this._currentStep = "configEditor";
- this._backButton.style.display = "block";
- this._manifestGuiBody.innerHTML = null;
- this._controlButtons(false);
-
- const pluginManifest = JSON.parse(manifestStr) as Manifest;
- const configProps = pluginManifest.configuration?.properties || {};
- this._processProperties(configProps);
-
- const add = document.getElementById("add");
- const remove = document.getElementById("remove");
- if (!add || !remove) {
- throw new Error("Add or remove button not found");
- }
- add.addEventListener("click", this._boundConfigAdd);
- remove.addEventListener("click", this._boundConfigRemove);
-
- this._updateGuiTitle(`Editing Configuration for ${pluginManifest.name}`);
- this._manifestGui?.classList.add("plugin-editor");
- this._manifestGui?.classList.add("rendered");
+ get manifestGui(): HTMLElement {
+ return this._manifestGui;
}
- private _updateGuiTitle(title: string): void {
- const guiTitle = document.querySelector("#manifest-gui-title");
- if (!guiTitle) {
- throw new Error("GUI Title not found");
- }
- guiTitle.textContent = title;
+ set manifestGui(gui: HTMLElement) {
+ this._manifestGui = gui;
}
- // Configuration Parsing
-
- private _processProperties(props: Record, prefix: string | null = null) {
- Object.keys(props).forEach((key) => {
- const fullKey = prefix ? `${prefix}.${key}` : key;
- const prop = props[key];
-
- if (prop.type === "object" && prop.properties) {
- this._processProperties(prop.properties, fullKey);
- } else {
- createInputRow(fullKey, prop, this._configDefaults);
- }
- });
+ get manifestGuiBody(): ExtendedHtmlElement {
+ return this._manifestGuiBody;
}
- private _parseConfigInputs(configInputs: NodeListOf, manifest: Manifest): { [key: string]: unknown } {
- const config: Record = {};
- const schema = manifest.configuration;
- if (!schema) {
- throw new Error("No schema found in manifest");
- }
- const validate = ajv.compile(schema as AnySchemaObject);
-
- configInputs.forEach((input) => {
- const key = input.getAttribute("data-config-key");
- if (!key) {
- throw new Error("Input key is required");
- }
-
- const keys = key.split(".");
-
- let currentObj = config;
- for (let i = 0; i < keys.length - 1; i++) {
- const part = keys[i];
- if (!currentObj[part] || typeof currentObj[part] !== "object") {
- currentObj[part] = {};
- }
- currentObj = currentObj[part] as Record;
- }
-
- 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;
- }
-
- currentObj[keys[keys.length - 1]] = value;
- });
-
- if (validate(config)) {
- return config;
- } else {
- throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2));
- }
+ set manifestGuiBody(body: ExtendedHtmlElement) {
+ this._manifestGuiBody = body;
}
- private _writeNewConfig(option: "add" | "remove"): void {
- const selectedManifest = localStorage.getItem("selectedPluginManifest");
- if (!selectedManifest) {
- toastNotification("No selected plugin manifest found.", {
- type: "error",
- shouldAutoDismiss: true,
- });
- throw new Error("No selected plugin manifest found");
- }
- const pluginManifest = JSON.parse(selectedManifest) as Manifest;
- const configInputs = document.querySelectorAll(".config-input");
-
- const newConfig = this._parseConfigInputs(configInputs, pluginManifest);
-
- this._configParser.loadConfig();
-
- const officialPluginConfig: Record = JSON.parse(localStorage.getItem("officialPluginConfig") || "{}");
-
- const pluginName = pluginManifest.name;
-
- // this relies on the manifest matching the repo name
- const normalizedPluginName = pluginName
- .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 ${pluginName}.`, {
- type: "error",
- shouldAutoDismiss: true,
- });
- throw new Error("No plugin URL found");
- }
-
- const plugin: Plugin = {
- uses: [
- {
- plugin: pluginUrl,
- with: newConfig,
- },
- ],
- };
-
- if (option === "add") {
- this._handleAddPlugin(plugin, pluginManifest);
- } else {
- this._handleRemovePlugin(plugin, pluginManifest);
- }
+ get auth(): AuthService {
+ return this._auth;
}
- private _handleAddPlugin(plugin: Plugin, pluginManifest: Manifest): void {
- this._configParser.addPlugin(plugin);
- toastNotification(`Configuration for ${pluginManifest.name} saved successfully. Do you want to push to GitHub?`, {
- type: "success",
- actionText: "Push to GitHub",
- action: async () => {
- const octokit = this._auth.octokit;
- if (!octokit) {
- throw new Error("Octokit not found");
- }
-
- const org = localStorage.getItem("selectedOrg");
- const repo = localStorage.getItem("selectedRepo");
- const config = localStorage.getItem("selectedConfig") as "development" | "production";
-
- if (!org) {
- throw new Error(NO_ORG_ERROR);
- }
-
- if (!config) {
- throw new Error("No selected config found");
- }
-
- try {
- await this._configParser.updateConfig(org, repo, config, octokit, "add");
- } 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,
- });
- },
- });
+ get configParser(): ConfigParser {
+ return this._configParser;
}
- private _handleRemovePlugin(plugin: Plugin, pluginManifest: Manifest): void {
- this._configParser.removePlugin(plugin);
- toastNotification(`Configuration for ${pluginManifest.name} removed successfully. Do you want to push to GitHub?`, {
- type: "success",
- actionText: "Push to GitHub",
- action: async () => {
- const octokit = this._auth.octokit;
- if (!octokit) {
- throw new Error("Octokit not found");
- }
-
- const org = localStorage.getItem("selectedOrg");
- const repo = localStorage.getItem("selectedRepo");
- const config = localStorage.getItem("selectedConfig") as "development" | "production";
-
- if (!org) {
- throw new Error(NO_ORG_ERROR);
- }
-
- if (!config) {
- throw new Error("No selected config found");
- }
-
- try {
- await this._configParser.updateConfig(org, repo, config, octokit, "remove");
- } 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;
- }
+ get configDefaults(): { [key: string]: { type: string; value: string; items: { type: string } | null } } {
+ return this._configDefaults;
+ }
- toastNotification("Configuration pushed to GitHub successfully.", {
- type: "success",
- shouldAutoDismiss: true,
- });
- },
- });
+ set configDefaults(defaults: { [key: string]: { type: string; value: string; items: { type: string } | null } }) {
+ this._configDefaults = defaults;
}
}
diff --git a/static/scripts/rendering/config-editor.ts b/static/scripts/rendering/config-editor.ts
new file mode 100644
index 0000000..3c30f43
--- /dev/null
+++ b/static/scripts/rendering/config-editor.ts
@@ -0,0 +1,175 @@
+import { Manifest, Plugin } from "../../types/plugins";
+import { controlButtons } from "./control-buttons";
+import { ManifestRenderer } from "../render-manifest";
+import { processProperties } from "./input-parsing";
+import { addTrackedEventListener, getTrackedEventListeners, normalizePluginName, removeTrackedEventListener, updateGuiTitle } from "./utils";
+import { handleResetToDefault, writeNewConfig } from "./write-add-remove";
+import MarkdownIt from "markdown-it";
+import { getManifestCache } from "../../utils/storage";
+const md = new MarkdownIt();
+
+/**
+ * Displays the plugin configuration editor.
+ *
+ * - `pluginManifest` should never be null or there was a problem fetching from the marketplace
+ * - `plugin` should only be passed in if you intend on replacing the default configuration with their installed configuration
+ *
+ * Allows for:
+ * - Adding a single plugin configuration
+ * - Removing a single plugin configuration
+ * - Resetting the plugin configuration to the schema default
+ * - Building multiple plugins like a "shopping cart" and they all get pushed at once in the background
+ *
+ * Compromises:
+ * - Typebox Unions get JSON.stringify'd and displayed as one string meaning `text-conversation-rewards` has a monster config for HTML tags
+ * - Plugin config objects are split like `plugin.config.key` and `plugin.config.key2` and `plugin.config.key3` and so on
+ */
+export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: Manifest | null, plugin?: Plugin["uses"][0]["with"]): void {
+ renderer.currentStep = "configEditor";
+ renderer.backButton.style.display = "block";
+ renderer.manifestGuiBody.innerHTML = null;
+ controlButtons({ hide: false });
+ processProperties(renderer, pluginManifest, pluginManifest?.configuration.properties || {}, null);
+ const configInputs = document.querySelectorAll(".config-input");
+
+ // If plugin is passed in, we want to inject those values into the inputs
+ if (plugin) {
+ configInputs.forEach((input) => {
+ const key = input.getAttribute("data-config-key");
+ if (!key) {
+ throw new Error("Input key is required");
+ }
+
+ const keys = key.split(".");
+ let currentObj = plugin;
+ for (let i = 0; i < keys.length; i++) {
+ if (!currentObj[keys[i]]) {
+ break;
+ }
+ currentObj = currentObj[keys[i]] as Record;
+ }
+
+ let value: string;
+
+ if (typeof currentObj === "object" || Array.isArray(currentObj)) {
+ value = JSON.stringify(currentObj, null, 2);
+ } else if (typeof currentObj === "boolean") {
+ value = currentObj ? "true" : "false";
+ } else {
+ value = currentObj as string;
+ }
+
+ if (input.tagName === "TEXTAREA") {
+ (input as HTMLTextAreaElement).value = value;
+ } else {
+ (input as HTMLInputElement).value = value;
+ }
+
+ if (input.tagName === "INPUT" && (input as HTMLInputElement).type === "checkbox") {
+ (input as HTMLInputElement).checked = value === "true";
+ }
+ });
+ }
+
+ const add = document.getElementById("add") as HTMLButtonElement;
+ const remove = document.getElementById("remove") as HTMLButtonElement;
+ const resetToDefaultButton = document.getElementById("reset-to-default") as HTMLButtonElement;
+ if (!add || !remove || !resetToDefaultButton) {
+ throw new Error("Buttons not found");
+ }
+
+ const parsedConfig = renderer.configParser.parseConfig(renderer.configParser.repoConfig || localStorage.getItem("config"));
+ // for when `resetToDefault` is called and no plugin gets passed in, we still want to show the remove button
+ const isInstalled = parsedConfig.plugins?.find((p) => p.uses[0].plugin.includes(normalizePluginName(pluginManifest?.name || "")));
+
+ loadListeners({
+ renderer,
+ pluginManifest,
+ withPluginOrInstalled: !!(plugin || isInstalled),
+ add,
+ remove,
+ resetToDefaultButton,
+ }).catch(console.error);
+
+ if (plugin || isInstalled) {
+ remove.disabled = false;
+ remove.classList.remove("disabled");
+ } else {
+ remove.disabled = true;
+ remove.classList.add("disabled");
+ }
+
+ resetToDefaultButton.hidden = !!(plugin || isInstalled);
+ const manifestCache = getManifestCache();
+ const pluginUrls = Object.keys(manifestCache);
+ const pluginUrl = pluginUrls.find((url) => {
+ return manifestCache[url].name === pluginManifest?.name;
+ });
+
+ if (!pluginUrl) {
+ throw new Error("Plugin URL not found");
+ }
+ const readme = manifestCache[pluginUrl].readme;
+
+ if (readme) {
+ const viewportCell = document.getElementById("viewport-cell");
+ if (!viewportCell) {
+ throw new Error("Viewport cell not found");
+ }
+ const readmeContainer = document.createElement("div");
+ readmeContainer.className = "readme-container";
+ readmeContainer.innerHTML = md.render(readme);
+ viewportCell.appendChild(readmeContainer);
+ }
+
+ const org = localStorage.getItem("selectedOrg");
+
+ updateGuiTitle(`Editing Configuration for ${pluginManifest?.name} in ${org}`);
+ renderer.manifestGui?.classList.add("plugin-editor");
+ renderer.manifestGui?.classList.add("rendered");
+}
+
+async function loadListeners({
+ renderer,
+ pluginManifest,
+ withPluginOrInstalled,
+ add,
+ remove,
+ resetToDefaultButton,
+}: {
+ renderer: ManifestRenderer;
+ pluginManifest: Manifest | null;
+ withPluginOrInstalled: boolean;
+ add: HTMLButtonElement;
+ remove: HTMLButtonElement;
+ resetToDefaultButton: HTMLButtonElement;
+}) {
+ function addHandler() {
+ writeNewConfig(renderer, "add");
+ }
+ function removeHandler() {
+ writeNewConfig(renderer, "remove");
+ }
+ function resetToDefaultHandler() {
+ handleResetToDefault(renderer, pluginManifest);
+ }
+
+ // ensure the listeners are removed before adding new ones
+ await (async () => {
+ getTrackedEventListeners(remove, "click")?.forEach((listener) => {
+ removeTrackedEventListener(remove, "click", listener);
+ });
+ getTrackedEventListeners(add, "click")?.forEach((listener) => {
+ removeTrackedEventListener(add, "click", listener);
+ });
+ getTrackedEventListeners(resetToDefaultButton, "click")?.forEach((listener) => {
+ removeTrackedEventListener(resetToDefaultButton, "click", listener);
+ });
+ })();
+
+ addTrackedEventListener(resetToDefaultButton, "click", resetToDefaultHandler);
+ addTrackedEventListener(add, "click", addHandler);
+ if (withPluginOrInstalled) {
+ addTrackedEventListener(remove, "click", removeHandler);
+ }
+}
diff --git a/static/scripts/rendering/control-buttons.ts b/static/scripts/rendering/control-buttons.ts
new file mode 100644
index 0000000..e95e630
--- /dev/null
+++ b/static/scripts/rendering/control-buttons.ts
@@ -0,0 +1,24 @@
+import { manifestGuiBody } from "../../utils/element-helpers";
+
+export function controlButtons({ hide }: { hide: boolean }): void {
+ const addButton = document.getElementById("add");
+ const removeButton = document.getElementById("remove");
+ const resetToDefaultButton = document.getElementById("reset-to-default");
+ const hideOrDisplay = hide ? "none" : "inline-block";
+ if (addButton) {
+ addButton.style.display = hideOrDisplay;
+ }
+ if (removeButton) {
+ removeButton.style.display = hideOrDisplay;
+ }
+
+ if (resetToDefaultButton) {
+ resetToDefaultButton.style.display = hideOrDisplay;
+ }
+
+ if (!manifestGuiBody) {
+ return;
+ }
+
+ manifestGuiBody.classList.add("rendered");
+}
diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts
new file mode 100644
index 0000000..a8bd6c0
--- /dev/null
+++ b/static/scripts/rendering/input-parsing.ts
@@ -0,0 +1,115 @@
+import AJV, { AnySchemaObject } from "ajv";
+import { createInputRow } from "../../utils/element-helpers";
+import { ManifestRenderer } from "../render-manifest";
+import { Manifest } from "../../types/plugins";
+
+// Without the raw Typebox Schema it was difficult to use Typebox which is why I've used AJV to validate the configuration.
+const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true });
+
+/**
+ * This creates the input rows for the configuration editor for any given plugin.
+ */
+export function processProperties(
+ renderer: ManifestRenderer,
+ manifest: Manifest | null | undefined,
+ props: Record,
+ prefix: string | null = null
+) {
+ const required = manifest?.configuration?.required || [];
+ Object.keys(props).forEach((key) => {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ const prop = props[key];
+ if (!prop) {
+ return;
+ }
+
+ if (prop.type === "object" && prop.properties) {
+ processProperties(renderer, manifest, prop.properties, fullKey);
+ } else if ("anyOf" in prop && Array.isArray(prop.anyOf)) {
+ if (prop.default) {
+ createInputRow(fullKey, prop, renderer.configDefaults, required.includes(fullKey));
+ } else {
+ prop.anyOf?.forEach((subProp) => {
+ processProperties(renderer, manifest, subProp.properties || {}, fullKey);
+ });
+ }
+ } else {
+ createInputRow(fullKey, prop, renderer.configDefaults);
+ }
+ });
+}
+
+/**
+ * This parse the inputs from the configuration editor and returns the configuration object.
+ * It also returns an array of missing required fields if any.
+ *
+ * It should become a priority to establish API like usage of `null` and `undefined` in our schemas so it's
+ * easier and less buggy when using the installer.
+ */
+export function parseConfigInputs(
+ configInputs: NodeListOf,
+ manifest: Manifest
+): { config: Record; missing: string[] } {
+ const config: Record = {};
+ const schema = manifest.configuration;
+ if (!schema) {
+ throw new Error("No schema found in manifest");
+ }
+ const required = schema.required || [];
+ const validate = ajv.compile(schema as AnySchemaObject);
+
+ configInputs.forEach((input) => {
+ const key = input.getAttribute("data-config-key");
+ if (!key) {
+ throw new Error("Input key is required");
+ }
+
+ const keys = key.split(".");
+
+ let currentObj = config;
+ for (let i = 0; i < keys.length - 1; i++) {
+ const part = keys[i];
+ if (!currentObj[part] || typeof currentObj[part] !== "object") {
+ currentObj[part] = {};
+ }
+ currentObj = currentObj[part] as Record;
+ }
+
+ let value: unknown;
+ const expectedType = input.getAttribute("data-type");
+
+ if (expectedType === "boolean") {
+ value = (input as HTMLInputElement).checked;
+ } else if (expectedType === "object" || expectedType === "array") {
+ if (!input.value) {
+ value = expectedType === "object" ? {} : [];
+ } else
+ 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;
+ }
+ currentObj[keys[keys.length - 1]] = value;
+ });
+
+ if (validate(config)) {
+ const missing = [];
+ for (const key of required) {
+ const isBoolean = schema.properties && schema.properties[key] && schema.properties[key].type === "boolean";
+ if ((isBoolean && config[key] === false) || config[key] === true) {
+ continue;
+ }
+
+ if (!config[key] || config[key] === "undefined" || config[key] === "null") {
+ missing.push(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
new file mode 100644
index 0000000..a350b3d
--- /dev/null
+++ b/static/scripts/rendering/navigation.ts
@@ -0,0 +1,34 @@
+import { createElement } from "../../utils/element-helpers";
+import { ManifestRenderer } from "../render-manifest";
+import { renderOrgPicker } from "./org-select";
+import { renderPluginSelector } from "./plugin-select";
+import { renderRepoPicker } from "./repo-select";
+
+export function createBackButton(renderer: ManifestRenderer): HTMLButtonElement {
+ const backButton = createElement("button", {
+ id: "back-button",
+ class: "button",
+ textContent: "Back",
+ }) as HTMLButtonElement;
+
+ backButton.style.display = "none";
+ backButton.addEventListener("click", () => handleBackButtonClick(renderer));
+ return backButton;
+}
+
+function handleBackButtonClick(renderer: ManifestRenderer): void {
+ renderer.manifestGui?.classList.remove("plugin-editor");
+ const readmeContainer = document.querySelector(".readme-container");
+ if (readmeContainer) {
+ readmeContainer.remove();
+ }
+ // "pluginSelector" | "configEditor"
+ const step = renderer.currentStep;
+ if (step === "repoPicker" || step === "orgPicker") {
+ renderOrgPicker(renderer, renderer.orgs);
+ } else if (step === "pluginSelector") {
+ renderRepoPicker(renderer, JSON.parse(localStorage.getItem("orgRepos") || "{}"));
+ } else if (step === "configEditor") {
+ renderPluginSelector(renderer);
+ }
+}
diff --git a/static/scripts/rendering/org-select.ts b/static/scripts/rendering/org-select.ts
new file mode 100644
index 0000000..3062032
--- /dev/null
+++ b/static/scripts/rendering/org-select.ts
@@ -0,0 +1,89 @@
+import { createElement } from "../../utils/element-helpers";
+import { STRINGS } from "../../utils/strings";
+import { ManifestRenderer } from "../render-manifest";
+import { controlButtons } from "./control-buttons";
+import { renderRepoPicker } from "./repo-select";
+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";
+ controlButtons({ hide: true });
+ renderer.backButton.style.display = "none";
+ renderer.manifestGui?.classList.add("rendering");
+ renderer.manifestGuiBody.innerHTML = null;
+ renderer.orgs = orgs;
+
+ const pickerRow = document.createElement("tr");
+ const pickerCell = document.createElement("td");
+ pickerCell.colSpan = 4;
+ pickerCell.className = STRINGS.TDV_CENTERED;
+
+ const customSelect = createElement("div", { class: "custom-select", style: `display: ${orgs.length ? "block" : "none"}` });
+
+ const selectSelected = createElement("div", {
+ class: "select-selected",
+ textContent: "Select an organization",
+ });
+
+ const selectItems = createElement("div", {
+ class: "select-items select-hide",
+ });
+
+ customSelect.appendChild(selectSelected);
+ customSelect.appendChild(selectItems);
+
+ pickerCell.appendChild(customSelect);
+ pickerRow.appendChild(pickerCell);
+
+ renderer.manifestGuiBody.appendChild(pickerRow);
+ renderer.manifestGui?.classList.add("rendered");
+
+ if (!orgs.length) {
+ const hasSession = renderer.auth.isActiveSession();
+ const isLoading = renderer.manifestGuiBody.dataset.loading === "true";
+
+ if (hasSession && !isLoading) {
+ updateGuiTitle("No organizations found");
+ } else if (hasSession && isLoading) {
+ updateGuiTitle("Fetching organization data...");
+ } else {
+ updateGuiTitle("Please sign in to GitHub");
+ }
+ return;
+ }
+
+ updateGuiTitle("Select an Organization");
+
+ orgs.forEach((org) => {
+ const optionDiv = createElement("div", { class: "select-option" });
+ const textSpan = createElement("span", { textContent: org });
+
+ optionDiv.appendChild(textSpan);
+ optionDiv.addEventListener("click", () => {
+ selectSelected.textContent = org;
+ handleOrgSelection(renderer, org);
+ });
+
+ selectItems.appendChild(optionDiv);
+ });
+
+ selectSelected.addEventListener("click", (e) => {
+ e.stopPropagation();
+ selectItems.classList.toggle(STRINGS.SELECT_HIDE);
+ selectSelected.classList.toggle(STRINGS.SELECT_ARROW_ACTIVE);
+ });
+
+ document.addEventListener("click", closeAllSelect);
+}
+
+function handleOrgSelection(renderer: ManifestRenderer, org: string): void {
+ if (!org) {
+ throw new Error("No org selected");
+ }
+ localStorage.setItem("selectedOrg", org);
+ const repos = JSON.parse(localStorage.getItem("orgRepos") || "{}");
+ renderRepoPicker(renderer, repos);
+}
diff --git a/static/scripts/rendering/plugin-select.ts b/static/scripts/rendering/plugin-select.ts
new file mode 100644
index 0000000..a7ee070
--- /dev/null
+++ b/static/scripts/rendering/plugin-select.ts
@@ -0,0 +1,97 @@
+import { ManifestCache, ManifestPreDecode, Plugin } from "../../types/plugins";
+import { createElement } from "../../utils/element-helpers";
+import { getManifestCache } from "../../utils/storage";
+import { STRINGS } from "../../utils/strings";
+import { ManifestRenderer } from "../render-manifest";
+import { renderConfigEditor } from "./config-editor";
+import { controlButtons } from "./control-buttons";
+import { closeAllSelect, normalizePluginName, updateGuiTitle } from "./utils";
+
+/**
+ * Renders a dropdown of plugins taken from the marketplace with an installed indicator.
+ * The user can select a plugin and it will render the configuration editor for that plugin.
+ */
+export function renderPluginSelector(renderer: ManifestRenderer): void {
+ renderer.currentStep = "pluginSelector";
+ renderer.backButton.style.display = "block";
+ renderer.manifestGuiBody.innerHTML = null;
+ controlButtons({ hide: true });
+
+ const manifestCache = getManifestCache();
+ const pluginUrls = Object.keys(manifestCache);
+
+ const pickerRow = document.createElement("tr");
+ const pickerCell = document.createElement("td");
+ pickerCell.colSpan = 2;
+ pickerCell.className = STRINGS.TDV_CENTERED;
+
+ const userConfig = renderer.configParser.repoConfig;
+ let installedPlugins: Plugin[] = [];
+
+ if (userConfig) {
+ installedPlugins = renderer.configParser.parseConfig(userConfig).plugins;
+ }
+
+ const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => {
+ if (manifestCache[key]?.name) {
+ acc[key] = manifestCache[key];
+ }
+ return acc;
+ }, {} as ManifestCache);
+
+ const customSelect = createElement("div", { class: "custom-select" });
+
+ const selectSelected = createElement("div", {
+ class: "select-selected",
+ textContent: "Select a plugin",
+ });
+
+ const selectItems = createElement("div", {
+ class: "select-items select-hide",
+ });
+
+ customSelect.appendChild(selectSelected);
+ customSelect.appendChild(selectItems);
+
+ pickerCell.appendChild(customSelect);
+ pickerRow.appendChild(pickerCell);
+
+ renderer.manifestGuiBody.appendChild(pickerRow);
+
+ pluginUrls.forEach((url) => {
+ if (!cleanManifestCache[url]?.name) {
+ return;
+ }
+ const normalizedName = normalizePluginName(cleanManifestCache[url].name);
+ const reg = new RegExp(normalizedName, "i");
+ const installedPlugin: Plugin | undefined = installedPlugins.find((plugin) => plugin.uses[0].plugin.match(reg));
+ const defaultForInstalled: ManifestPreDecode | null = cleanManifestCache[url];
+ const optionText = defaultForInstalled.name;
+ const indicator = installedPlugin ? "🟢" : "🔴";
+
+ const optionDiv = createElement("div", { class: "select-option" });
+ const textSpan = createElement("span", { textContent: optionText });
+ const indicatorSpan = createElement("span", { textContent: indicator });
+
+ optionDiv.appendChild(textSpan);
+ optionDiv.appendChild(indicatorSpan);
+
+ optionDiv.addEventListener("click", () => {
+ selectSelected.textContent = optionText;
+ closeAllSelect();
+ localStorage.setItem("selectedPluginManifest", JSON.stringify(defaultForInstalled));
+ renderConfigEditor(renderer, defaultForInstalled, installedPlugin?.uses[0].with);
+ });
+
+ selectItems.appendChild(optionDiv);
+ });
+
+ selectSelected.addEventListener("click", (e) => {
+ e.stopPropagation();
+ closeAllSelect();
+ selectItems.classList.toggle(STRINGS.SELECT_HIDE);
+ selectSelected.classList.toggle(STRINGS.SELECT_ARROW_ACTIVE);
+ });
+
+ updateGuiTitle(`Select a Plugin`);
+}
diff --git a/static/scripts/rendering/repo-select.ts b/static/scripts/rendering/repo-select.ts
new file mode 100644
index 0000000..14da202
--- /dev/null
+++ b/static/scripts/rendering/repo-select.ts
@@ -0,0 +1,99 @@
+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 { updateGuiTitle } from "./utils";
+
+export function renderRepoPicker(renderer: ManifestRenderer, repos: Record): void {
+ renderer.currentStep = "repoPicker";
+ controlButtons({ hide: true });
+ renderer.backButton.style.display = "block";
+ renderer.manifestGui?.classList.add("rendering");
+ renderer.manifestGuiBody.innerHTML = null;
+
+ if (!Reflect.ownKeys(repos).length) {
+ updateGuiTitle("No repositories found");
+ renderer.manifestGuiBody.appendChild(document.createElement("tr"));
+ renderer.manifestGui?.classList.add("rendered");
+ return;
+ }
+
+ localStorage.setItem("orgRepos", JSON.stringify(repos));
+
+ const pickerRow = document.createElement("tr");
+ const pickerCell = document.createElement("td");
+ pickerCell.colSpan = 4;
+ pickerCell.className = STRINGS.TDV_CENTERED;
+
+ updateGuiTitle("Select a Repository");
+ const selectedOrg = localStorage.getItem("selectedOrg");
+
+ if (!selectedOrg) {
+ throw new Error(`No selected org found in local storage`);
+ }
+
+ const repoSelect = createElement("select", {
+ id: "repo-picker-select",
+ class: STRINGS.PICKER_SELECT,
+ style: "width: 100%",
+ });
+
+ const defaultOption = createElement("option", {
+ value: null,
+ textContent: "Select a repository...",
+ });
+ repoSelect.appendChild(defaultOption);
+
+ const orgRepos = repos[selectedOrg];
+
+ if (!orgRepos) {
+ throw new Error("No org repos found");
+ }
+
+ orgRepos.forEach((repo) => {
+ const option = createElement("option", {
+ value: repo,
+ textContent: repo,
+ });
+ repoSelect.appendChild(option);
+ });
+
+ repoSelect.addEventListener("change", (event) => handleRepoSelection(event, renderer));
+ pickerCell.appendChild(repoSelect);
+ pickerRow.appendChild(pickerCell);
+ renderer.manifestGuiBody.appendChild(pickerRow);
+ renderer.manifestGui?.classList.add("rendered");
+}
+
+async function fetchOrgConfig(renderer: ManifestRenderer, org: string, repo: string): Promise {
+ const kill = toastNotification(`Fetching ${org} 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, repo);
+ kill();
+}
+
+function handleRepoSelection(event: Event, renderer: ManifestRenderer): void {
+ const selectElement = event.target as HTMLSelectElement;
+ const selectedRepo = selectElement.value;
+ if (selectedRepo) {
+ localStorage.setItem("selectedRepo", selectedRepo);
+ const selectedOrg = localStorage.getItem("selectedOrg");
+ if (!selectedOrg) {
+ throw new Error("No selected org found");
+ }
+
+ fetchOrgConfig(renderer, selectedOrg, selectedRepo)
+ .then(() => {
+ renderPluginSelector(renderer);
+ })
+ .catch((error) => {
+ console.error(error);
+ toastNotification("Error fetching org config", { type: "error" });
+ });
+ }
+}
diff --git a/static/scripts/rendering/utils.ts b/static/scripts/rendering/utils.ts
new file mode 100644
index 0000000..39604a7
--- /dev/null
+++ b/static/scripts/rendering/utils.ts
@@ -0,0 +1,56 @@
+import { STRINGS } from "../../utils/strings";
+
+// this relies on the manifest matching the repo name
+export function normalizePluginName(pluginName: string): string {
+ return pluginName
+ .toLowerCase()
+ .replace(/ /g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/-+/g, "-");
+}
+
+export function updateGuiTitle(title: string): void {
+ const guiTitle = document.querySelector("#manifest-gui-title");
+ if (!guiTitle) {
+ throw new Error("GUI Title not found");
+ }
+ guiTitle.textContent = title;
+}
+
+export function closeAllSelect() {
+ const selectItemsList = document.querySelectorAll(STRINGS.SELECT_ITEMS);
+ const selectSelectedList = document.querySelectorAll(STRINGS.SELECT_SELECTED);
+ selectItemsList.forEach((item) => {
+ item.classList.add(STRINGS.SELECT_HIDE);
+ });
+ selectSelectedList.forEach((item) => {
+ item.classList.remove(STRINGS.SELECT_ARROW_ACTIVE);
+ });
+}
+
+const eventListenersMap = new WeakMap>();
+export function addTrackedEventListener(target: EventTarget, type: string, listener: EventListener) {
+ if (!eventListenersMap.has(target)) {
+ eventListenersMap.set(target, new Map());
+ }
+ const listeners = eventListenersMap.get(target)?.get(type) || [];
+ if (!listeners.map((l) => l.name).includes(listener.name)) {
+ listeners.push(listener);
+ eventListenersMap.get(target)?.set(type, listeners);
+ target.addEventListener(type, listener);
+ }
+}
+
+export function removeTrackedEventListener(target: EventTarget, type: string, listener: EventListener) {
+ const listeners = eventListenersMap.get(target)?.get(type) || [];
+ const index = listeners.findIndex((l) => l.name === listener.name);
+ if (index !== -1) {
+ listeners.splice(index, 1);
+ eventListenersMap?.get(target)?.set(type, listeners);
+ target.removeEventListener(type, listener);
+ }
+}
+
+export function getTrackedEventListeners(target: EventTarget, type: string): EventListener[] {
+ return eventListenersMap.get(target)?.get(type) || [];
+}
diff --git a/static/scripts/rendering/write-add-remove.ts b/static/scripts/rendering/write-add-remove.ts
new file mode 100644
index 0000000..7c41607
--- /dev/null
+++ b/static/scripts/rendering/write-add-remove.ts
@@ -0,0 +1,141 @@
+import { toastNotification } from "../../utils/toaster";
+import { ManifestRenderer } from "../render-manifest";
+import { Manifest, Plugin } from "../../types/plugins";
+import { parseConfigInputs } from "./input-parsing";
+import { getOfficialPluginConfig } from "../../utils/storage";
+import { renderConfigEditor } from "./config-editor";
+import { normalizePluginName } from "./utils";
+
+/**
+ * Writes the new configuration to the config file. This does not push the config to GitHub
+ * only updates the local config. The actual push event is handled via a toast notification.
+ *
+ * - Acts as a "save" button for the configuration editor
+ * - Adds or removes a plugin configuration from the config file
+ */
+export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remove") {
+ const selectedManifest = localStorage.getItem("selectedPluginManifest");
+ if (!selectedManifest) {
+ toastNotification("No selected plugin manifest found.", {
+ type: "error",
+ shouldAutoDismiss: true,
+ });
+ throw new Error("No selected plugin manifest found");
+ }
+ const pluginManifest = JSON.parse(selectedManifest) as Manifest;
+ const configInputs = document.querySelectorAll(".config-input");
+
+ const { config: newConfig, missing } = parseConfigInputs(configInputs, pluginManifest);
+
+ if (missing.length) {
+ toastNotification("Please fill out all required fields.", {
+ type: "error",
+ shouldAutoDismiss: true,
+ });
+ missing.forEach((key) => {
+ const ele = document.querySelector(`[data-config-key="${key}"]`) as HTMLInputElement | HTMLTextAreaElement | null;
+ if (ele) {
+ ele.style.border = "1px solid red";
+ ele.focus();
+ } else {
+ console.log(`Input element with key ${key} not found`);
+ }
+ });
+ return;
+ }
+
+ renderer.configParser.loadConfig();
+ const normalizedPluginName = normalizePluginName(pluginManifest.name);
+ const officialPluginConfig: Record = getOfficialPluginConfig();
+ const pluginUrl = Object.keys(officialPluginConfig).find((url) => {
+ return url.includes(normalizedPluginName);
+ });
+
+ if (!pluginUrl) {
+ toastNotification(`No plugin URL found for ${normalizedPluginName}.`, {
+ type: "error",
+ shouldAutoDismiss: true,
+ });
+ throw new Error("No plugin URL found");
+ }
+
+ const plugin: Plugin = {
+ uses: [
+ {
+ plugin: pluginUrl,
+ with: newConfig,
+ },
+ ],
+ };
+
+ if (option === "add") {
+ handleAddPlugin(renderer, plugin, pluginManifest);
+ } else if (option === "remove") {
+ handleRemovePlugin(renderer, plugin, pluginManifest);
+ }
+}
+
+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?`, {
+ type: "success",
+ actionText: "Push to GitHub",
+ shouldAutoDismiss: true,
+ action: () => notificationConfigPush(renderer),
+ });
+}
+
+function handleRemovePlugin(renderer: ManifestRenderer, plugin: Plugin, pluginManifest: Manifest): void {
+ renderer.configParser.removePlugin(plugin);
+ toastNotification(`Configuration for ${pluginManifest.name} removed successfully. Do you want to push to GitHub?`, {
+ type: "success",
+ actionText: "Push to GitHub",
+ shouldAutoDismiss: true,
+ action: () => notificationConfigPush(renderer),
+ });
+}
+
+async function notificationConfigPush(renderer: ManifestRenderer) {
+ const octokit = renderer.auth.octokit;
+ if (!octokit) {
+ throw new Error("Octokit not found");
+ }
+
+ const org = localStorage.getItem("selectedOrg");
+
+ if (!org) {
+ throw new Error("No selected org found");
+ }
+
+ try {
+ const selectedRepo = localStorage.getItem("selectedRepo");
+ if (!selectedRepo) {
+ throw new Error("No selected repo found");
+ }
+
+ await renderer.configParser.updateConfig(org, octokit, selectedRepo);
+ } 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,
+ });
+}
+
+export function handleResetToDefault(renderer: ManifestRenderer, pluginManifest: Manifest | null) {
+ if (!pluginManifest) {
+ throw new Error("No plugin manifest found");
+ }
+ renderConfigEditor(renderer, pluginManifest);
+ const readmeContainer = document.querySelector(".readme-container");
+ if (readmeContainer) {
+ readmeContainer.remove();
+ }
+}
diff --git a/static/types/github.ts b/static/types/github.ts
index b9ef981..96a9b94 100644
--- a/static/types/github.ts
+++ b/static/types/github.ts
@@ -3,13 +3,6 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
export type GitHubUserResponse = RestEndpointMethodTypes["users"]["getByUsername"]["response"];
export type GitHubUser = GitHubUserResponse["data"];
-export type OrgWithInstall = {
- org: string;
- install: {
- account: {
- login: string;
- id: number;
- type: "Organization" | "User";
- };
- };
+export type ExtendedHtmlElement = {
+ [key in keyof T]: T[key] extends HTMLElement["innerHTML"] ? string | null : T[key];
};
diff --git a/static/types/plugins.ts b/static/types/plugins.ts
index 3fe14b8..7f3404a 100644
--- a/static/types/plugins.ts
+++ b/static/types/plugins.ts
@@ -31,13 +31,12 @@ export type Manifest = {
};
configuration: {
type: string;
- properties: {
- [key: string]: {
- default: unknown;
- type?: string;
- };
+ default: object;
+ items?: {
+ type: string;
};
+ properties?: Record;
+ required?: string[];
};
+ readme?: string;
};
-
-export type ManifestProps = { type: string; default: string; items?: { type: string }; properties?: Record };
diff --git a/static/utils/ele-helpers.ts b/static/utils/element-helpers.ts
similarity index 83%
rename from static/utils/ele-helpers.ts
rename to static/utils/element-helpers.ts
index dc9116a..b7865ff 100644
--- a/static/utils/ele-helpers.ts
+++ b/static/utils/element-helpers.ts
@@ -1,16 +1,19 @@
-import { ManifestProps } from "../types/plugins";
+import { Manifest } from "../types/plugins";
const CONFIG_INPUT_STR = "config-input";
export const manifestGuiBody = document.getElementById("manifest-gui-body");
-export function createElement(tagName: TK, attributes: { [key: string]: string | null }): HTMLElementTagNameMap[TK] {
+export function createElement(
+ tagName: TK,
+ attributes: { [key: string]: string | boolean | null }
+): HTMLElementTagNameMap[TK] {
const element = document.createElement(tagName);
Object.keys(attributes).forEach((key) => {
if (key === "textContent") {
- element.textContent = attributes[key];
+ element.textContent = attributes[key] as string;
} else if (key in element) {
- (element as Record)[key] = attributes[key];
+ (element as Record)[key] = attributes[key];
} else {
element.setAttribute(key, `${attributes[key]}`);
}
@@ -19,18 +22,20 @@ export function createElement(tagName: T
}
export function createInputRow(
key: string,
- prop: ManifestProps,
- configDefaults: Record
+ prop: Manifest["configuration"],
+ configDefaults: Record,
+ required = false
): void {
const row = document.createElement("tr");
const headerCell = document.createElement("td");
headerCell.className = "table-data-header";
- headerCell.textContent = key;
+ headerCell.textContent = key.replace(/([A-Z])/g, " $1");
row.appendChild(headerCell);
const valueCell = document.createElement("td");
valueCell.className = "table-data-value";
+ valueCell.ariaRequired = `${required}`;
const input = createInput(key, prop.default, prop);
valueCell.appendChild(input);
@@ -44,7 +49,7 @@ export function createInputRow(
items: prop.items ? { type: prop.items.type } : null,
};
}
-export function createInput(key: string, defaultValue: unknown, prop: ManifestProps): HTMLElement {
+export function createInput(key: string, defaultValue: unknown, prop: Manifest["configuration"]): HTMLElement {
if (!key) {
throw new Error("Input name is required");
}
diff --git a/static/utils/storage.ts b/static/utils/storage.ts
new file mode 100644
index 0000000..71d4cdc
--- /dev/null
+++ b/static/utils/storage.ts
@@ -0,0 +1,9 @@
+import { ManifestCache } from "../types/plugins";
+
+export function getManifestCache(): ManifestCache {
+ return JSON.parse(localStorage.getItem("manifestCache") || "{}");
+}
+
+export function getOfficialPluginConfig() {
+ return JSON.parse(localStorage.getItem("officialPluginConfig") || "{}");
+}
diff --git a/static/utils/strings.ts b/static/utils/strings.ts
new file mode 100644
index 0000000..bb65ebe
--- /dev/null
+++ b/static/utils/strings.ts
@@ -0,0 +1,8 @@
+export const STRINGS = {
+ TDV_CENTERED: "table-data-value centered",
+ SELECT_ITEMS: ".select-items",
+ SELECT_SELECTED: ".select-selected",
+ SELECT_HIDE: "select-hide",
+ SELECT_ARROW_ACTIVE: "select-arrow-active",
+ PICKER_SELECT: "picker-select",
+};
diff --git a/static/utils/toaster.ts b/static/utils/toaster.ts
index 5170afd..d726515 100644
--- a/static/utils/toaster.ts
+++ b/static/utils/toaster.ts
@@ -1,4 +1,4 @@
-import { createElement } from "./ele-helpers";
+import { createElement } from "./element-helpers";
export function toastNotification(
message: string,
@@ -10,7 +10,7 @@ export function toastNotification(
duration?: number;
killAll?: boolean;
} = {}
-): void {
+): () => void {
const { type = "info", actionText, action, shouldAutoDismiss = false, duration = 5000 } = options;
const toastElement = createElement("div", {
@@ -68,10 +68,21 @@ export function toastNotification(
toastElement.classList.add("show");
});
- if (shouldAutoDismiss) {
- setTimeout(() => {
+ 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);
- }, duration);
+ }
+ }
+
+ if (shouldAutoDismiss) {
+ kill(shouldAutoDismiss);
}
+
+ return kill;
}
diff --git a/yarn.lock b/yarn.lock
index 8e0604d..34babbc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15,7 +15,22 @@
"@actions/http-client" "^2.0.1"
uuid "^8.3.2"
-"@actions/github@6.0.0":
+"@actions/core@^1.11.1":
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.11.1.tgz#ae683aac5112438021588030efb53b1adb86f172"
+ integrity sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==
+ dependencies:
+ "@actions/exec" "^1.1.1"
+ "@actions/http-client" "^2.0.1"
+
+"@actions/exec@^1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-1.1.1.tgz#2e43f28c54022537172819a7cf886c844221a611"
+ integrity sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==
+ dependencies:
+ "@actions/io" "^1.0.1"
+
+"@actions/github@6.0.0", "@actions/github@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@actions/github/-/github-6.0.0.tgz#65883433f9d81521b782a64cc1fd45eef2191ea7"
integrity sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==
@@ -33,6 +48,11 @@
tunnel "^0.0.6"
undici "^5.25.4"
+"@actions/io@^1.0.1":
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71"
+ integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==
+
"@ampproject/remapping@^2.2.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4"
@@ -1659,6 +1679,11 @@
resolved "https://registry.yarnpkg.com/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-8.3.0.tgz#a7a4da00c0f27f7f5708eb3fcebefa08f8d51125"
integrity sha512-vKLsoR4xQxg4Z+6rU/F65ItTUz/EXbD+j/d4mlq2GW8TsA4Tc8Kdma2JTAAJ5hrKWUQzkR/Esn2fjsqiVRYaQg==
+"@octokit/openapi-webhooks-types@8.5.1":
+ version "8.5.1"
+ resolved "https://registry.yarnpkg.com/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-8.5.1.tgz#de421dbd3efb586e908a152eed3f0ae50698a2f2"
+ integrity sha512-i3h1b5zpGSB39ffBbYdSGuAd0NhBAwPyA3QV3LYi/lx4lsbZiu7u2UHgXVUR6EpvOI8REOuVh1DZTRfHoJDvuQ==
+
"@octokit/plugin-paginate-graphql@^5.2.4":
version "5.2.4"
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.4.tgz#b6afda7b3f24cb93d2ab822ec8eac664a5d325d0"
@@ -1671,7 +1696,7 @@
dependencies:
"@octokit/types" "^13.5.0"
-"@octokit/plugin-paginate-rest@^11.0.0":
+"@octokit/plugin-paginate-rest@^11.0.0", "@octokit/plugin-paginate-rest@^11.3.5":
version "11.3.5"
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.5.tgz#a1929b3ba3dc7b63bc73bb6d3c7a3faf2a9c7649"
integrity sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==
@@ -1704,7 +1729,7 @@
dependencies:
"@octokit/types" "^12.6.0"
-"@octokit/plugin-rest-endpoint-methods@^13.0.0":
+"@octokit/plugin-rest-endpoint-methods@^13.0.0", "@octokit/plugin-rest-endpoint-methods@^13.2.6":
version "13.2.6"
resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.6.tgz#b9d343dbe88a6cb70cc7fa16faa98f0a29ffe654"
integrity sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw==
@@ -1720,6 +1745,15 @@
"@octokit/types" "^13.0.0"
bottleneck "^2.15.3"
+"@octokit/plugin-retry@^7.1.2":
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-7.1.2.tgz#242e2d19a72a50b5113bb25d7d2c622ce0373fa0"
+ integrity sha512-XOWnPpH2kJ5VTwozsxGurw+svB2e61aWlmk5EVIYZPwFK5F9h4cyPyj9CIKRyMXMHSwpIsI3mPOdpMmrRhe7UQ==
+ dependencies:
+ "@octokit/request-error" "^6.0.0"
+ "@octokit/types" "^13.0.0"
+ bottleneck "^2.15.3"
+
"@octokit/plugin-throttling@9.3.1":
version "9.3.1"
resolved "https://registry.yarnpkg.com/@octokit/plugin-throttling/-/plugin-throttling-9.3.1.tgz#5648165e1e70e861625f3a16af6c55cafe861061"
@@ -1728,6 +1762,14 @@
"@octokit/types" "^13.0.0"
bottleneck "^2.15.3"
+"@octokit/plugin-throttling@^9.3.2":
+ version "9.3.2"
+ resolved "https://registry.yarnpkg.com/@octokit/plugin-throttling/-/plugin-throttling-9.3.2.tgz#cc05180e45e769d6726c5faed157e9ad3b6ab8c0"
+ integrity sha512-FqpvcTpIWFpMMwIeSoypoJXysSAQ3R+ALJhXXSG1HTP3YZOIeLmcNcimKaXxTcws+Sh6yoRl13SJ5r8sXc1Fhw==
+ dependencies:
+ "@octokit/types" "^13.0.0"
+ bottleneck "^2.15.3"
+
"@octokit/request-error@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.1.0.tgz#ee4138538d08c81a60be3f320cd71063064a3b30"
@@ -1807,6 +1849,15 @@
"@octokit/request-error" "^6.0.1"
"@octokit/webhooks-methods" "^5.0.0"
+"@octokit/webhooks@^13.3.0":
+ version "13.4.1"
+ resolved "https://registry.yarnpkg.com/@octokit/webhooks/-/webhooks-13.4.1.tgz#608929916b0e0e5755fa5ca1de7484d2113bc6a9"
+ integrity sha512-I5YPUtfWidh+OzyrlDahJsUpkpGK0kCTmDRbuqGmlCUzOtxdEkX3R4d6Cd08ijQYwkVXQJanPdbKuZBeV2NMaA==
+ dependencies:
+ "@octokit/openapi-webhooks-types" "8.5.1"
+ "@octokit/request-error" "^6.0.1"
+ "@octokit/webhooks-methods" "^5.0.0"
+
"@open-draft/deferred-promise@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd"
@@ -1840,6 +1891,11 @@
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.21.tgz#651191b5cc13c27ae0cb2150f3af5255597a9961"
integrity sha512-1wU0VNSZQt13BmJvxYhHRVwDBnG8y5qrcyi3DnmEQzvfeRycUNneQSd6quyxrNbspM1pV/m4r4udO6o1tCuXjg==
+"@sinclair/typebox@^0.33.21":
+ version "0.33.22"
+ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.22.tgz#3339d85172509095a8384cb4b44834a7c9309d86"
+ integrity sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==
+
"@sinonjs/commons@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
@@ -2029,16 +2085,34 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
+"@types/linkify-it@^5":
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
+ integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
+
"@types/lodash@^4.14.172":
version "4.14.202"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8"
integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==
+"@types/markdown-it@^14.1.2":
+ version "14.1.2"
+ resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
+ integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
+ dependencies:
+ "@types/linkify-it" "^5"
+ "@types/mdurl" "^2"
+
"@types/md5@^2.3.0":
version "2.3.5"
resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.3.5.tgz#481cef0a896e3a5dcbfc5a8a8b02c05958af48a5"
integrity sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==
+"@types/mdurl@^2":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
+ integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
+
"@types/mute-stream@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478"
@@ -2207,6 +2281,27 @@
"@typescript-eslint/types" "8.8.1"
eslint-visitor-keys "^3.4.3"
+"@ubiquity-os/plugin-sdk@^1.0.11":
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/@ubiquity-os/plugin-sdk/-/plugin-sdk-1.0.11.tgz#b45029a0bd7469b19e71d4685d9ee8e7163afe38"
+ integrity sha512-BlZbqOfuBYMFyDEJfPc9HCrr5l8m3uNOXmPXr/M8/UFwZT+nHfZfB+AULoY0Goyx2BX1JaHd5bgDjJG1PwozPA==
+ dependencies:
+ "@actions/core" "^1.11.1"
+ "@actions/github" "^6.0.0"
+ "@octokit/core" "^6.1.2"
+ "@octokit/plugin-paginate-graphql" "^5.2.4"
+ "@octokit/plugin-paginate-rest" "^11.3.5"
+ "@octokit/plugin-rest-endpoint-methods" "^13.2.6"
+ "@octokit/plugin-retry" "^7.1.2"
+ "@octokit/plugin-throttling" "^9.3.2"
+ "@octokit/rest" "^21.0.2"
+ "@octokit/types" "^13.6.1"
+ "@octokit/webhooks" "^13.3.0"
+ "@sinclair/typebox" "^0.33.21"
+ "@ubiquity-os/ubiquity-os-logger" "^1.3.2"
+ dotenv "^16.4.5"
+ hono "^4.6.9"
+
"@ubiquity-os/ubiquity-os-kernel@^2.5.3":
version "2.5.3"
resolved "https://registry.yarnpkg.com/@ubiquity-os/ubiquity-os-kernel/-/ubiquity-os-kernel-2.5.3.tgz#622aacdbccc1bed7d9115a08c30eb35a2eb2f308"
@@ -3317,7 +3412,7 @@ dot-prop@^5.1.0:
dependencies:
is-obj "^2.0.0"
-dotenv@16.4.5:
+dotenv@16.4.5, dotenv@^16.4.5:
version "16.4.5"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
@@ -3397,6 +3492,11 @@ enquirer@^2.3.6:
ansi-colors "^4.1.1"
strip-ansi "^6.0.1"
+entities@^4.4.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@@ -4230,6 +4330,11 @@ hono@4.4.13:
resolved "https://registry.yarnpkg.com/hono/-/hono-4.4.13.tgz#954e8f6e4bab14f3f9d7bac4eef4c56d23e7f900"
integrity sha512-c6qqenclmQ6wpXzqiElMa2jt423PVCmgBreDfC5s2lPPpGk7d0lOymd8QTzFZyYC5mSSs6imiTMPip+gLwuW/g==
+hono@^4.6.9:
+ version "4.6.11"
+ resolved "https://registry.yarnpkg.com/hono/-/hono-4.6.11.tgz#8aa9bea754cfd6e295800652a107dd850bcf3c54"
+ integrity sha512-f0LwJQFKdUUrCUAVowxSvNCjyzI7ZLt8XWYU/EApyeq5FfOvHFarBaE5rjU9HTNFk4RI0FkdB2edb3p/7xZjzQ==
+
hosted-git-info@^2.1.4:
version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@@ -5195,6 +5300,13 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
lint-staged@15.2.7:
version "15.2.7"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-15.2.7.tgz#97867e29ed632820c0fb90be06cd9ed384025649"
@@ -5400,6 +5512,18 @@ makeerror@1.0.12:
dependencies:
tmpl "1.0.5"
+markdown-it@^14.1.0:
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
md5@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
@@ -5409,6 +5533,11 @@ md5@^2.3.0:
crypt "0.0.2"
is-buffer "~1.1.6"
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
memorystream@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
@@ -5967,6 +6096,11 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -6838,6 +6972,11 @@ typescript@5.6.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"