diff --git a/package-lock.json b/package-lock.json index fb5758a..d605856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-embedder-for-retool", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "app-embedder-for-retool", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@extend-chrome/messages": "^1.2.2", @@ -14,13 +14,15 @@ "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", "i18n": "^0.15.1", + "jotai": "^2.10.0", "react": "^18.3.1", "react-bootstrap": "^2.10.4", "react-dom": "^18.3.1", "react-hot-toast": "^2.4.1", "simple-git": "^3.25.0", "swr": "^2.2.5", - "tiny-typed-emitter": "^2.1.0" + "tiny-typed-emitter": "^2.1.0", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@babel/core": "^7.24.9", @@ -10907,6 +10909,27 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jotai": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.10.0.tgz", + "integrity": "sha512-8W4u0aRlOIwGlLQ0sqfl/c6+eExl5D8lZgAUolirZLktyaj4WnxO/8a0HEPmtriQAB6X5LMhXzZVmw02X0P0qQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -16567,6 +16590,35 @@ "webpack": "^4.0.0 || ^5.0.0", "webpack-sources": "*" } + }, + "node_modules/zustand": { + "version": "5.0.0-rc.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0-rc.2.tgz", + "integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } }, "dependencies": { @@ -24135,6 +24187,12 @@ } } }, + "jotai": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.10.0.tgz", + "integrity": "sha512-8W4u0aRlOIwGlLQ0sqfl/c6+eExl5D8lZgAUolirZLktyaj4WnxO/8a0HEPmtriQAB6X5LMhXzZVmw02X0P0qQ==", + "requires": {} + }, "js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -28287,6 +28345,12 @@ "requires": { "yazl": "^2.5.1" } + }, + "zustand": { + "version": "5.0.0-rc.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0-rc.2.tgz", + "integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==", + "requires": {} } } } diff --git a/package.json b/package.json index c7e8397..6312b42 100755 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ }, "license": "MIT", "scripts": { - "build": "node utils/build.js", + "build": "DEBUG=FALSE node utils/build.js", "fix": "eslint src --fix", "prettier": "prettier --write '**/*.{js,jsx,ts,tsx,json,css,scss,md}'", - "start": "node utils/webserver.js" + "start": "DEBUG=true node utils/webserver.js" }, "dependencies": { "@extend-chrome/messages": "^1.2.2", @@ -25,7 +25,8 @@ "react-hot-toast": "^2.4.1", "simple-git": "^3.25.0", "swr": "^2.2.5", - "tiny-typed-emitter": "^2.1.0" + "tiny-typed-emitter": "^2.1.0", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "@babel/core": "^7.24.9", diff --git a/src/hooks/useComposedUrl.ts b/src/hooks/useComposedUrl.ts new file mode 100644 index 0000000..6209140 --- /dev/null +++ b/src/hooks/useComposedUrl.ts @@ -0,0 +1,22 @@ +import { useExtensionState } from "./useExtensionState"; + +export function useComposedUrl() { + return useExtensionState((state) => { + const app = state.getActiveApp(); + + if (app) { + const url = new URL( + `${app.public ? "p" : "app"}/${app.name}}`, + `https://${state.domain}.retool.com/` + ); + app.query.forEach((q) => url.searchParams.append(q.param, q.value)); + + if (app.hash.length === 0) { + return `${url.toString()}`; + } + + const hashParams = new URLSearchParams(app.hash.map((h) => [h.param, h.value])); + return `${url.toString()}#${hashParams.toString()}`; + } + }); +} diff --git a/src/hooks/useExtensionState.ts b/src/hooks/useExtensionState.ts new file mode 100644 index 0000000..32b792e --- /dev/null +++ b/src/hooks/useExtensionState.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import ChromeStateStorage from "../lib/chrome/ChromeStateStorage"; +import { DEMO_APPS, INSPECTOR_APP } from "../pages/Options/EmbeddableApps"; + +import type { RetoolApp } from "../types"; + +type State = { + domain: string; + apps: RetoolApp[]; + activeAppName: RetoolApp["name"] | undefined; + // activeApp: RetoolApp | undefined; + workflowUrl: string; + workflowApiKey: string; +}; + +interface Actions { + reset: () => void; + getActiveApp: () => RetoolApp | undefined; + setDomain: (domain: State["domain"]) => void; + setActiveApp: (name: State["domain"]) => void; + addApp: (app: RetoolApp) => void; + updateApp: (name: string, app: Partial) => void; +} + +export const STORAGE_KEY = "app-embedder-for-retool"; + +const initialState: State = { + domain: "", + activeAppName: INSPECTOR_APP["name"], + workflowUrl: "", + workflowApiKey: "", + apps: [INSPECTOR_APP, ...DEMO_APPS], +}; + +export const useExtensionState = create()( + persist( + (set, get) => ({ + ...initialState, + reset: () => set(initialState), + setDomain: (domain) => set(() => ({ domain })), + setActiveApp: (name) => set(() => ({ activeAppName: name })), + addApp: (app) => set((state) => ({ apps: [...state.apps, app] })), + updateApp: (name, props) => { + set((state) => ({ + apps: state.apps.map((app) => (app.name === name ? { ...app, ...props } : app)), + })); + }, + getActiveApp: () => get().apps.find((app) => app.name === get().activeAppName), + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => ChromeStateStorage), + } + ) +); diff --git a/src/hooks/useRetoolAppStore.ts b/src/hooks/useRetoolAppStore.ts new file mode 100644 index 0000000..b874f7f --- /dev/null +++ b/src/hooks/useRetoolAppStore.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; + +import type { RetoolApp, UrlParamSpec } from "../types"; + +type Actions = { + setAppId: (appName: string) => void; + setEnvironment: (env: RetoolApp["env"]) => void; + setVersion: (version: RetoolApp["version"]) => void; + setHashParams: (spec: UrlParamSpec[]) => void; + setQueryParams: (spec: UrlParamSpec[]) => void; + updateParam: (which: "query" | "hash", index: number, spec: UrlParamSpec[]) => void; + resetApp: () => void; +}; + +const initialState: RetoolApp = { + id: "", + env: "development", + version: "latest", + hash: [], + query: [], +}; + +export const useRetoolAppStore = create()((set) => ({ + ...initialState, + setAppId: (id) => set(() => ({ id })), + setEnvironment: (env) => set(() => ({ env })), + setVersion: (version) => set(() => ({ version })), + setHashParams: (hash) => set(() => ({ hash })), + setQueryParams: (query) => set(() => ({ query })), + updateParam: (which, index, param) => + set((state) => ({ + hash: state[which].map((item, idx) => (idx === index ? { ...item, ...param } : item)), + })), + resetApp: () => set(() => initialState), +})); diff --git a/src/hooks/useWorkflow.ts b/src/hooks/useWorkflow.ts index 9cdd527..ba6754d 100644 --- a/src/hooks/useWorkflow.ts +++ b/src/hooks/useWorkflow.ts @@ -1,17 +1,7 @@ import { useState } from "react"; import useSWR from "swr"; -const callWorkflow = async (url: string, workflowApiKey: string): Promise => { - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Workflow-Api-Key": workflowApiKey, - }, - }); - const data = (await res.json()) as { apps: string[] }; - return data.apps ?? []; -}; +import { callWorkflow } from "../lib/workflows"; export function useWorkflow(url: string, apiKey: string) { const [workflowUrl, setWorkflowUrl] = useState(url); @@ -22,6 +12,6 @@ export function useWorkflow(url: string, apiKey: string) { workflowApiKey, setWorkflowUrl, setWorkflowApiKey, - ...useSWR(url, (keyIsUrl: string) => callWorkflow(keyIsUrl, workflowApiKey)), + ...useSWR(url, () => callWorkflow(url, workflowApiKey)), }; } diff --git a/src/lib/RetoolURL.ts b/src/lib/RetoolURL.ts index 0d38b43..fe15062 100644 --- a/src/lib/RetoolURL.ts +++ b/src/lib/RetoolURL.ts @@ -1,3 +1,7 @@ +export function retoolAppToUrl() { + // +} + export function retoolUrl(config: RetoolUrlConfig) { const url = new RetoolURL(config.domain); if (config?.app) url.app(config.app); diff --git a/src/lib/chrome/ChromeStateStorage.ts b/src/lib/chrome/ChromeStateStorage.ts new file mode 100644 index 0000000..778b72f --- /dev/null +++ b/src/lib/chrome/ChromeStateStorage.ts @@ -0,0 +1,51 @@ +import type { StateStorage } from "zustand/middleware"; + +const ChromeStateStorage: StateStorage = { + getItem, + setItem, + removeItem, +}; + +export default ChromeStateStorage; + +function getItem(name: string): string | null | Promise { + return runtimePromise((resolve, rejectIfRuntimeError) => { + chrome.storage.sync.get(name, (items) => { + rejectIfRuntimeError(); + resolve(items[name]); + }); + }); +} + +function setItem(name: string, value: string): unknown | Promise { + return runtimePromise((resolve, rejectIfRuntimeError) => { + chrome.storage.sync.set({ [name]: value }, () => { + rejectIfRuntimeError(); + resolve(void 0); + }); + }); +} + +function removeItem(name: string): unknown | Promise { + return runtimePromise((resolve, rejectIfRuntimeError) => { + chrome.storage.sync.remove(name, () => { + rejectIfRuntimeError(); + resolve(void 0); + }); + }); +} + +function runtimePromise(handler: PromiseHandler): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const rejectIfRuntimeError = () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError.message); + } + }; + handler(resolve, rejectIfRuntimeError); + return promise; +} + +type Resolver = (value: T | PromiseLike) => void; + +type PromiseHandler = (resolve: Resolver, rejectIfRuntimeError: () => void) => void; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 9aa5240..9b704b1 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -5,3 +5,9 @@ export const log = (...args: unknown[]) => { ...args ); }; + +export const debug = (...args: unknown[]) => { + if (process.env.DEBUG) { + log(...args); + } +}; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 6df8c00..b5f7ba8 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,10 +1,11 @@ import { ChromeStorage } from "./chrome/ChromeStorage"; -import type { ExtensionSettings, ParamEntry } from "../types"; +import type { UrlParamSpec } from "../hooks/useExtensionState"; +import type { ExtensionSettings } from "../types"; export type SerializedSettings = { - urlParams: ParamEntry[]; - hashParams: ParamEntry[]; + urlParams: UrlParamSpec[]; + hashParams: UrlParamSpec[]; } & Required; export const storage = new ChromeStorage(); diff --git a/src/lib/workflows.ts b/src/lib/workflows.ts new file mode 100644 index 0000000..546a173 --- /dev/null +++ b/src/lib/workflows.ts @@ -0,0 +1,11 @@ +export const callWorkflow = async (url: string, workflowApiKey: string): Promise => { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Workflow-Api-Key": workflowApiKey, + }, + }); + const data = (await res.json()) as { apps: string[] }; + return data.apps ?? []; +}; diff --git a/src/pages/Options/EmbeddableApps.ts b/src/pages/Options/EmbeddableApps.ts new file mode 100644 index 0000000..8272503 --- /dev/null +++ b/src/pages/Options/EmbeddableApps.ts @@ -0,0 +1,40 @@ +import type { RetoolApp } from "../../types"; + +export const INSPECTOR_APP: RetoolApp = { + name: "app-embedder-for-retool-inspector", + public: true, + version: "latest", + env: "production", + hash: [], + query: [], +}; + +export const DEMO_APPS: RetoolApp[] = [ + { + name: "demo-app-1", + public: false, + version: "3.61.2", + env: "development", + hash: [], + query: [], + }, + { + name: "fancy-thing-2", + public: false, + version: "latest", + env: "production", + hash: [ + { index: 1, param: "taco", value: "bell" }, + { index: 2, param: "bean", value: "burrito" }, + ], + query: [], + }, + { + name: "another-app-3", + public: false, + version: "1.2.3", + env: "staging", + hash: [{ index: 1, param: "super", value: "duper" }], + query: [{ index: 1, param: "foo", value: "bar" }], + }, +]; diff --git a/src/pages/Options/Options.tsx b/src/pages/Options/Options.tsx index 41939e8..01b9c9a 100644 --- a/src/pages/Options/Options.tsx +++ b/src/pages/Options/Options.tsx @@ -6,10 +6,14 @@ import Col from "react-bootstrap/Col"; import Container from "react-bootstrap/Container"; import Navbar from "react-bootstrap/Navbar"; import Row from "react-bootstrap/Row"; +import Tab from "react-bootstrap/Tab"; +import Tabs from "react-bootstrap/Tabs"; import extLogo from "../../assets/img/logo_32.png"; import retoolLogo from "../../assets/img/retool.svg"; -import OptionsForm from "./OptionsForm"; +import OptionsForm from "./Tabs/ConfigTab/OptionsForm"; +import StorageTab from "./Tabs/StorageTab/StorageTab"; +import WorkflowTab from "./Tabs/WorkflowTab"; import type { SerializedSettings } from "../../lib/storage"; @@ -24,8 +28,8 @@ const Options: React.FC = ({ settings }) => {
App Embedder For
@@ -37,22 +41,46 @@ const Options: React.FC = ({ settings }) => {
- - - - - WelcomeTo enable this exension, please fill in the - required fields. - - - - - -
+ + + + + + + {/* + WelcomeTo enable this exension, please fill in the + required fields. + */} + + + + + + + + + + + + +
+ ); }; diff --git a/src/pages/Options/OptionsForm.tsx b/src/pages/Options/OptionsForm.tsx deleted file mode 100644 index a76df01..0000000 --- a/src/pages/Options/OptionsForm.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import React, { useMemo, useState } from "react"; -import Accordion from "react-bootstrap/Accordion"; -import Button from "react-bootstrap/Button"; -import Col from "react-bootstrap/Col"; -import Container from "react-bootstrap/Container"; -import Form from "react-bootstrap/Form"; -import InputGroup from "react-bootstrap/InputGroup"; -import Row from "react-bootstrap/Row"; -// eslint-disable-next-line import/no-named-as-default -import toast, { Toaster } from "react-hot-toast"; - -import { useRetoolUrl } from "../../hooks/useRetoolUrl"; -import { useWorkflow } from "../../hooks/useWorkflow"; -import { type SerializedSettings, storage } from "../../lib/storage"; - -import type { Environment, RetoolUrlConfig, RetoolVersion } from "../../lib/RetoolURL"; -import type { ParamEntry } from "../../types"; - -type Props = { - settings: SerializedSettings; -}; - -const OptionsForm: React.FC = ({ settings }) => { - const [urlParams, setUrlParams] = useState(settings.urlParams); - const [hashParams, setHashParams] = useState(settings.hashParams); - - const { - data: appList, - error: appListError, - isLoading, - workflowUrl, - workflowApiKey, - setWorkflowUrl, - setWorkflowApiKey, - } = useWorkflow(`${settings?.workflowUrl}`, `${settings.workflowApiKey}`); - - const [useWorkflowList, setUseWorkflowList] = useState(false); - const { url, domain, app, version, env, setApp, setDomain, setVersion, setEnv } = useRetoolUrl( - settings as RetoolUrlConfig - ); - - const composedUrl = useMemo(() => { - const _url = new URL(url); - const _hashParams = new URLSearchParams(); - urlParams.forEach(({ param, value }) => _url.searchParams.append(param, value)); - hashParams.forEach(({ param, value }) => _hashParams.append(param, value)); - if (hashParams.length === 0) { - return `${_url.toString()}`; - } - return `${_url.toString()}#${_hashParams.toString()}`; - }, [url, urlParams, hashParams]); - - const handleSaveSettings = async () => { - if (domain === "") { - toast.error("Your Retool instance name cannot be blank, please fill in this field."); - return; - } - if (app === "") { - toast.error("You must provide a Retool app Name or ID, please fill in this field."); - return; - } - try { - await storage.save({ - domain, - app, - version, - env, - workflowUrl, - workflowApiKey, - urlParams, - hashParams, - }); - toast.success("Settings saved."); - storage.load(); - } catch (e) { - const error = e as Error; - toast.error(error.message); - } - }; - - return ( - <> - - -
- - - Instance Name{" "} - (required) - - - - https:// - setDomain(e.target.value.replace(/\s/, ""))} - /> - .retool.com - - - This is your registered domain / instance name. - - - - - - App Name{" "} - (required) - - {useWorkflowList ? ( - setApp(e.target.value as Environment)} - > - {isLoading ? ( - - ) : ( - appList?.map((appName) => ( - - )) - )} - - ) : ( - setApp(e.target.value)} - /> - )} - - Use the "Share" button in the editor and copy the name / id from the URL after - "app/" - - - - - - - Version - { - const { value } = e.target; - if (value === "") { - setVersion("latest"); - } else if (/(?:[0-9]+\.){2}[0-9]+/.test(value)) { - setVersion(e.target.value as RetoolVersion); - } - }} - /> - Input version: "1.2.3" or "latest" - - - - - Environment - setEnv(e.target.value as Environment)} - > - - - - - Select preferred environment - - - - - - - - Extra URL Params - {urlParams.length > 0 && - urlParams.map((entry) => { - return ( - { - setUrlParams((old) => { - return old.map((entry) => { - if (entry.index === index) { - entry[target] = data; - } - return entry; - }); - }); - }} - onValueChange={(paramKey) => { - setUrlParams((old) => { - return old; - }); - }} - onRemove={(indexToRemove) => { - setUrlParams((old) => { - return old.filter((entry) => { - return entry.index !== indexToRemove; - }); - }); - }} - /> - ); - })} - - - - - - Hash Params - {hashParams.length > 0 && - hashParams.map((entry) => { - return ( - { - setHashParams((old) => { - return old.map((entry) => { - if (entry.index === index) { - entry[target] = data; - } - return entry; - }); - }); - }} - onValueChange={(paramKey) => { - setHashParams((old) => { - return old; - }); - }} - onRemove={(indexToRemove) => { - setHashParams((old) => { - return old.filter((entry) => { - return entry.index !== indexToRemove; - }); - }); - }} - /> - ); - })} - - - - - - - Composed URL - - - - - Does this look correct? - - -
- -
- - - - -
- - Workflow App Name Provider -
-
- - -

- Enable this feature to swap the App Name from an input field, - into a dynamic list fetched from a Retool Workflow. -

- Workflow URL - setWorkflowUrl(e.target.value)} - /> - - Supply a Retool workflow URL that returns a 200 with a JSON body - formatted {"{ apps: string[] }"} - -
- - Workflow API Key - setWorkflowApiKey(e.target.value)} - /> - Copy this value from Retool - - - - - - {!useWorkflowList ? ( -

❌ Disabled

- ) : isLoading ? ( -

🚀 Fetching...

- ) : appListError ? ( -

💣 Error! {appListError}

- ) : appList ? ( -

✅ Success. Loaded {appList.length} app names.

- ) : ( -

🔦 No results returned.

- )} -
-
-
-
- -
- - - ); -}; - -export default OptionsForm; - -type ParamUpdate = { - index: number; - target: "param" | "value"; - data: string; -}; - -const ParamInputGroup: React.FC<{ - index: number; - param: string; - value: string; - onRemove: (index: number) => void; - onKeyChange: (data: ParamUpdate) => void; - onValueChange: (data: ParamUpdate) => void; -}> = ({ index, param, value, onKeyChange, onValueChange, onRemove }) => { - return ( - - { - onKeyChange({ - index, - target: "param", - data: e.target.value, - }); - }} - /> - { - onValueChange({ - index, - target: "value", - data: e.target.value, - }); - }} - /> - - - ); -}; diff --git a/src/pages/Options/Tabs/ConfigTab/AppNameInput.tsx b/src/pages/Options/Tabs/ConfigTab/AppNameInput.tsx new file mode 100644 index 0000000..e382a8b --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/AppNameInput.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; + +type Props = { + name: string; + onChange: (name: string) => void; +}; + +export default function AppNameInput({ name, onChange }: Props) { + return ( + + + App Name{" "} + (required) + + onChange(e.target.value)} + /> + + Use the "Share" button in the editor and copy the name / id from the URL after "app/" + + + ); +} diff --git a/src/pages/Options/Tabs/ConfigTab/AppSelect.tsx b/src/pages/Options/Tabs/ConfigTab/AppSelect.tsx new file mode 100644 index 0000000..391ddc4 --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/AppSelect.tsx @@ -0,0 +1,18 @@ + setApp(e.target.value as Environment)} +> + {isLoading ? ( + + ) : ( + appList?.map((appName) => ( + + )) + )} +; diff --git a/src/pages/Options/Tabs/ConfigTab/ComposedURL.tsx b/src/pages/Options/Tabs/ConfigTab/ComposedURL.tsx new file mode 100644 index 0000000..de34b44 --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/ComposedURL.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import InputGroup from "react-bootstrap/InputGroup"; + +type Props = { + title: string; + url: string; +}; + +const ComposedURL: React.FC> = ({ url, title }) => { + const properTitle = !title + ? "Embedded App" + : title + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + return ( + + Composed URL + + + + + Does this look correct? + + ); +}; + +export default ComposedURL; diff --git a/src/pages/Options/Tabs/ConfigTab/DomainInput.tsx b/src/pages/Options/Tabs/ConfigTab/DomainInput.tsx new file mode 100644 index 0000000..b1a194a --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/DomainInput.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; +import InputGroup from "react-bootstrap/InputGroup"; + +import { useExtensionState } from "../../../../hooks/useExtensionState"; + +export default function DomainInputGroup() { + const domain = useExtensionState((state) => state.domain); + const setDomain = useExtensionState((state) => state.setDomain); + + return ( + + + Instance Name{" "} + (required) + + + + https:// + setDomain(e.target.value.replace(/\s/, ""))} + /> + .retool.com + + This is your registered domain / instance name. + + ); +} diff --git a/src/pages/Options/Tabs/ConfigTab/EnvironmentRadio.tsx b/src/pages/Options/Tabs/ConfigTab/EnvironmentRadio.tsx new file mode 100644 index 0000000..2900eb2 --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/EnvironmentRadio.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; + +import type { AppEnvironment } from "../../../../types"; + +type Props = { + environment: AppEnvironment; + onChange: (env: Props["environment"]) => void; +}; + +const EnvironmentRadio: React.FC = ({ environment, onChange }) => { + return ["production", "staging", "development"].map((env) => ( + onChange(e.target.value as Props["environment"])} + /> + )); +}; + +export default EnvironmentRadio; diff --git a/src/pages/Options/Tabs/ConfigTab/EnvironmentSelect.tsx b/src/pages/Options/Tabs/ConfigTab/EnvironmentSelect.tsx new file mode 100644 index 0000000..24cdade --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/EnvironmentSelect.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; + +import type { AppEnvironment } from "../../../../types"; + +type Props = { + environment: AppEnvironment; + onChange: (env: Props["environment"]) => void; +}; + +export default function EnvironmentSelect({ environment, onChange }: Props) { + return ( + + Environment + onChange(e.target.value as Props["environment"])} + > + + + + + + Select preferred environment + + ); +} diff --git a/src/pages/Options/Tabs/ConfigTab/OptionsForm.tsx b/src/pages/Options/Tabs/ConfigTab/OptionsForm.tsx new file mode 100644 index 0000000..2ada66f --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/OptionsForm.tsx @@ -0,0 +1,337 @@ +import React, { useMemo, useState } from "react"; +import Accordion from "react-bootstrap/Accordion"; +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Container from "react-bootstrap/Container"; +import Form from "react-bootstrap/Form"; +import InputGroup from "react-bootstrap/InputGroup"; +import Row from "react-bootstrap/Row"; +// eslint-disable-next-line import/no-named-as-default +import toast, { Toaster } from "react-hot-toast"; + +import { useComposedUrl } from "../../../../hooks/useComposedUrl"; +import { useExtensionState } from "../../../../hooks/useExtensionState"; +import { useRetoolUrl } from "../../../../hooks/useRetoolUrl"; +import { useWorkflow } from "../../../../hooks/useWorkflow"; +import { debug, log } from "../../../../lib/logger"; +import { storage } from "../../../../lib/storage"; +import AppNameInput from "./AppNameInput"; +import ComposedURL from "./ComposedURL"; +import DomainInput from "./DomainInput"; +import EnvironmentSelect from "./EnvironmentSelect"; +import VersionInput from "./VersionInput"; + +import type { RetoolUrlConfig } from "../../../../lib/RetoolURL"; +import type { SerializedSettings } from "../../../../lib/storage"; +import type { UrlParamSpec } from "../../../../types"; + +type Props = { + settings: SerializedSettings; +}; + +const OptionsForm: React.FC = ({ settings }) => { + const extensionState = useExtensionState(); + const activeAppName = useExtensionState((s) => s.activeAppName); + const cURL = useComposedUrl(); + const [urlParams, setUrlParams] = useState(settings.urlParams); + const [hashParams, setHashParams] = useState(settings.hashParams); + + const { url, domain, app, version, env, setApp, setDomain, setVersion, setEnv } = useRetoolUrl( + settings as RetoolUrlConfig + ); + + const composedUrl = useMemo(() => { + const _url = new URL(url); + const _hashParams = new URLSearchParams(); + urlParams.forEach(({ param, value }) => _url.searchParams.append(param, value)); + hashParams.forEach(({ param, value }) => _hashParams.append(param, value)); + if (hashParams.length === 0) { + return `${_url.toString()}`; + } + return `${_url.toString()}#${_hashParams.toString()}`; + }, [url, urlParams, hashParams]); + + const handleSaveSettings = async () => { + debug("SAVING SETTINGS"); + + if (domain === "") { + toast.error("Your Retool instance name cannot be blank, please fill in this field."); + return; + } + + try { + await storage.save({ + domain, + app, + version, + env, + urlParams, + hashParams, + workflowUrl: "", + workflowApiKey: "", + }); + toast.success("Settings saved."); + storage.load(); + } catch (e) { + const error = e as Error; + toast.error(error.message); + } + }; + + return ( + <> + + +
+

General Config

+ + +

Current App

+ + + + + + + + + + + + + + Extra URL Params + {urlParams.length > 0 && + urlParams.map((entry) => { + const key = `URL_${entry.index}`; + return ( + { + debug(`[SET]`, { key, index, target, data }); + setUrlParams((old) => { + return old.map((entry) => { + if (entry.index === index) { + entry[target] = data; + } + return entry; + }); + }); + }} + onValueChange={({ index, target, data }) => { + debug(`[SET]`, { key, index, target, data }); + setUrlParams((old) => { + return old.map((entry) => { + if (entry.index === index) { + entry[target] = data; + } + return entry; + }); + }); + }} + onRemove={(indexToRemove) => { + debug("[DEL]", { key, indexToRemove }); + setUrlParams((old) => { + return old.filter((entry) => { + return entry.index !== indexToRemove; + }); + }); + }} + /> + ); + })} + + + + + + + Hash Params + {hashParams.length > 0 && + hashParams.map((entry) => { + const key = `HASH_${entry.index}`; + return ( + { + debug(`[SET]`, { key, index, target, data }); + setHashParams((old) => { + return old.map((entry) => { + if (entry.index === index) { + entry[target] = data; + } + return entry; + }); + }); + }} + onValueChange={({ index, target, data }) => { + debug(`[SET]`, { key, index, target, data }); + setHashParams((old) => { + return old.map((entry) => { + if (entry.index === index) { + entry[target] = data; + } + return entry; + }); + }); + }} + onRemove={(indexToRemove) => { + debug("[DEL]", { key, indexToRemove }); + setHashParams((old) => { + return old.filter((entry) => { + return entry.index !== indexToRemove; + }); + }); + }} + /> + ); + })} + + + + + + + + + +
+ +
+ + +
+ + + ); +}; + +export default OptionsForm; + +type ParamUpdate = { + index: number; + target: "param" | "value"; + data: string; +}; + +const ParamInputGroup: React.FC<{ + index: number; + param: string; + value: string; + onRemove: (index: number) => void; + onKeyChange: (data: ParamUpdate) => void; + onValueChange: (data: ParamUpdate) => void; +}> = ({ index, param, value, onKeyChange, onValueChange, onRemove }) => { + return ( + + { + onKeyChange({ + index, + target: "param", + data: e.target.value, + }); + }} + /> + { + onValueChange({ + index, + target: "value", + data: e.target.value, + }); + }} + /> + + + ); +}; diff --git a/src/pages/Options/Tabs/ConfigTab/VersionInput.tsx b/src/pages/Options/Tabs/ConfigTab/VersionInput.tsx new file mode 100644 index 0000000..c7cd52c --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/VersionInput.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; + +import type { AppVersion } from "../../../../types"; + +type Props = { + version: AppVersion; + onChange: (env: Props["version"]) => void; +}; + +export default function VersionInput({ version, onChange }: Props) { + return ( + + Version + { + const value = e.target.value as Props["version"]; + if (/(?:[0-9]+\.){2}[0-9]+/.test(value)) { + onChange(value); + } else { + onChange("latest"); + } + }} + /> + Input version: "1.2.3" or "latest" + + ); +} diff --git a/src/pages/Options/Tabs/ConfigTab/_component.tsx b/src/pages/Options/Tabs/ConfigTab/_component.tsx new file mode 100644 index 0000000..a7dc825 --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab/_component.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import {} from "react-bootstrap"; + +type Props = { + // +}; + +export default function COMPONENT(props: Props) { + return; +} diff --git a/src/pages/Options/Tabs/StorageTab/AppCard.tsx b/src/pages/Options/Tabs/StorageTab/AppCard.tsx new file mode 100644 index 0000000..4b2f7d1 --- /dev/null +++ b/src/pages/Options/Tabs/StorageTab/AppCard.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Badge, Button, Card, CardLink, Container, Row } from "react-bootstrap"; + +import { useExtensionState } from "../../../../hooks/useExtensionState"; + +import type { RetoolApp } from "../../../../types"; + +type Props = { + app: RetoolApp; + isActive: boolean; +}; + +function AppCard({ app, isActive }: Props) { + const setActiveApp = useExtensionState((s) => s.setActiveApp); + + return ( + // + + +
+ {app.name} + + {app.version[0] === "l" ? app.version : `v${app.version}`} + +
+ + {/* {isActive && ⭐️} */} + {app.env} +
+ + + Public App: {app.public ? "Yes" : "No"} + + {app.query.length > 0 && ( + +
Query Params
+
+ {app.query.map((p) => ( + <> +
{p.param}
+
{p.value}
+ + ))} +
+
+ )} + {app.hash.length > 0 && ( + +
Hash Params
+
+ {app.hash.map((p) => ( + <> +
{p.param}
+
{p.value}
+ + ))} +
+
+ )} +
+ +
+
+
+ ); +} + +export default AppCard; diff --git a/src/pages/Options/Tabs/StorageTab/StorageTab.tsx b/src/pages/Options/Tabs/StorageTab/StorageTab.tsx new file mode 100644 index 0000000..67b58c8 --- /dev/null +++ b/src/pages/Options/Tabs/StorageTab/StorageTab.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import Alert from "react-bootstrap/Alert"; +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Container from "react-bootstrap/Container"; +import Row from "react-bootstrap/Row"; + +import { useExtensionState } from "../../../../hooks/useExtensionState"; +import AppCard from "./AppCard"; + +import type { SerializedSettings } from "../../../../lib/storage"; + +type Props = { + settings: SerializedSettings; +}; + +function StorageTab({ settings }: Props) { + const reset = useExtensionState((s) => s.reset); + const state = useExtensionState((state) => JSON.stringify(state, null, 2)); + + const apps = useExtensionState((s) => s.apps); + const activeApp = useExtensionState((s) => s.getActiveApp()); + + return ( + + + + + Storage + Here are all your saved Retool App Definitions +
+
+ +
+
+ +
+ + {!apps.length ? ( + <> + ) : ( + apps.map((app) => ( + + + + )) + )} + + + + +
{state}
+ +
+
+ ); +} + +export default StorageTab; diff --git a/src/pages/Options/Tabs/WorkflowTab.tsx b/src/pages/Options/Tabs/WorkflowTab.tsx new file mode 100644 index 0000000..13bd98c --- /dev/null +++ b/src/pages/Options/Tabs/WorkflowTab.tsx @@ -0,0 +1,103 @@ +import React, { useMemo, useState } from "react"; +import Accordion from "react-bootstrap/Accordion"; +import Button from "react-bootstrap/Button"; +import Container from "react-bootstrap/Container"; +import Form from "react-bootstrap/Form"; + +import { useExtensionState } from "../../../hooks/useExtensionState"; +import { useWorkflow } from "../../../hooks/useWorkflow"; + +import type { SerializedSettings } from "../../../lib/storage"; + +type Props = { + settings: SerializedSettings; +}; + +const WorkflowTab: React.FC = ({ settings }) => { + // const state = useExtensionState((state) => []); + const { + data: appList, + error: appListError, + isLoading, + workflowUrl, + workflowApiKey, + setWorkflowUrl, + setWorkflowApiKey, + } = useWorkflow(`${settings?.workflowUrl}`, `${settings.workflowApiKey}`); + + const [useWorkflowList, setUseWorkflowList] = useState(false); + return ( + + + + +
+ + Workflow App Name Provider +
+
+ + +

+ Enable this feature to swap the App Name from an input field, into a + dynamic list fetched from a Retool Workflow. +

+ Workflow URL + setWorkflowUrl(e.target.value)} + /> + + Supply a Retool workflow URL that returns a 200 with a JSON body + formatted {"{ apps: string[] }"} + +
+ + Workflow API Key + setWorkflowApiKey(e.target.value)} + /> + Copy this value from Retool + + + + + + {!useWorkflowList ? ( +

❌ Disabled

+ ) : isLoading ? ( +

🚀 Fetching...

+ ) : appListError ? ( +

💣 Error! {appListError}

+ ) : appList ? ( +

✅ Success. Loaded {appList.length} app names.

+ ) : ( +

🔦 No results returned.

+ )} +
+
+
+
+ ); +}; + +export default WorkflowTab; diff --git a/src/pages/Options/index.css b/src/pages/Options/index.css index a88f1dc..309148f 100644 --- a/src/pages/Options/index.css +++ b/src/pages/Options/index.css @@ -5,4 +5,48 @@ body, html { padding: 0; margin: 0; +} + +body { + height: 100vh; +} + +.tab-content { + height: calc(100vh - (162px)) !important; + overflow-y: auto; +} + +ul.nav-tabs li button:hover { + background-color: #FFF; +} + +dt, +dd { + display: block; + float: left; +} + +dt { + margin: 0; + padding: 0; + clear: both; +} + +dt::after { + content: "="; + margin: 0 3px; +} + + +.environment-production { + background-color: rgb(60, 146, 220); +} + +.environment-staging { + background-color: rgb(233, 171, 17); + color: black; +} + +.environment-development { + background-color: rgb(71, 139, 96); } \ No newline at end of file diff --git a/src/types/extension.ts b/src/types/extension.ts new file mode 100644 index 0000000..bd00f67 --- /dev/null +++ b/src/types/extension.ts @@ -0,0 +1,20 @@ +export type SemVer = `${number}.${number}.${number}`; + +export type AppVersion = SemVer | "latest"; + +export type AppEnvironment = "production" | "staging" | "development"; + +export type UrlParamSpec = { + index: number; + param: string; + value: string; +}; + +export type RetoolApp = { + name: string; + public: boolean; + env: AppEnvironment; + version: AppVersion; + hash: UrlParamSpec[]; + query: UrlParamSpec[]; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 33515a8..16f1243 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,12 +1,7 @@ -import type { RetoolUrlConfig } from "../lib//RetoolURL"; +import type { RetoolUrlConfig } from "../lib/RetoolURL"; export * from "./events"; - -export type ParamEntry = { - index: number; - param: string; - value: string; -}; +export * from "./extension"; type PartialRetoolUrl = Omit; diff --git a/tsconfig.json b/tsconfig.json index b904441..2223136 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "strict": true, "target": "es5", "lib": [ "dom", @@ -10,7 +11,6 @@ "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", diff --git a/webpack/plugins.js b/webpack/plugins.js index d4e66d7..688b30f 100644 --- a/webpack/plugins.js +++ b/webpack/plugins.js @@ -16,30 +16,28 @@ const properAppName = packageInfo.name .map((x) => `${x[0].toUpperCase()}${x.slice(1)}`) .join(" "); -const copyManifestPlugin = new CopyWebpackPlugin({ - patterns: [ - { - from: manifestPath, - to: outputPath, - force: true, - transform: (content) => - Buffer.from( - JSON.stringify({ - description: process.env.npm_package_description, - version: process.env.npm_package_version, - ...JSON.parse(content.toString()), - }) - ), - }, - ], -}); - const commonPlugins = [ isDevelopment && new ReactRefreshWebpackPlugin(), new CleanWebpackPlugin({ verbose: false }), new webpack.ProgressPlugin(), - new webpack.EnvironmentPlugin(["NODE_ENV"]), - copyManifestPlugin, + new webpack.EnvironmentPlugin(["NODE_ENV", "DEBUG"]), + new CopyWebpackPlugin({ + patterns: [ + { + from: manifestPath, + to: outputPath, + force: true, + transform: (content) => + Buffer.from( + JSON.stringify({ + description: process.env.npm_package_description, + version: process.env.npm_package_version, + ...JSON.parse(content.toString()), + }) + ), + }, + ], + }), new CopyWebpackPlugin({ patterns: imagePatterns }), ].filter(Boolean);