diff --git a/src/hooks/useAppUrl.ts b/src/hooks/useAppUrl.ts new file mode 100644 index 0000000..b685c3d --- /dev/null +++ b/src/hooks/useAppUrl.ts @@ -0,0 +1,23 @@ +import { useExtensionState } from "./useExtensionState"; + +import type { RetoolApp } from "../types"; + +export function useAppUrl(app: RetoolApp) { + const domain = useExtensionState((s) => s.domain); + + const base = `https://${domain}.retool.com/`; + const path = `${app.public ? "p" : "app"}/${app.name}`; + const url = new URL(path, base); + + 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 index 602fe1a..b634d49 100644 --- a/src/hooks/useExtensionState.ts +++ b/src/hooks/useExtensionState.ts @@ -4,7 +4,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import ChromeStateStorage from "@/lib/ChromeStateStorage"; import { DEMO_APPS, INSPECTOR_APP } from "@/lib/EmbeddableApps"; -import type { RetoolApp } from "@/types"; +import type { RetoolApp } from "@/types/extension"; type State = { domain: string; diff --git a/src/hooks/useRetoolAppStore.ts b/src/hooks/useRetoolAppStore.ts index db023d6..548bdce 100644 --- a/src/hooks/useRetoolAppStore.ts +++ b/src/hooks/useRetoolAppStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -import type { RetoolApp, UrlParamSpec } from "../types"; +import type { RetoolApp, UrlParamSpec } from "../types/extension"; type Actions = { setAppName: (name: string) => void; diff --git a/src/hooks/useThrottle.ts b/src/hooks/useThrottle.ts deleted file mode 100644 index 190ccdf..0000000 --- a/src/hooks/useThrottle.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useCallback, useState } from "react"; - -export function useThrottle void>( - fn: T, - delay: number = 1000 -): T { - const [isThrottled, setIsThrottled] = useState(false); - - const throttledFunction = useCallback( - (...args: Parameters) => { - if (!isThrottled) { - setIsThrottled(true); - fn(...args); - setTimeout(() => { - setIsThrottled(false); - }, delay); - } - }, - [isThrottled, fn, delay] - ); - - return throttledFunction as T; -} diff --git a/src/lib/ChromeStateStorage.ts b/src/lib/ChromeStateStorage.ts index 778b72f..958ee7e 100644 --- a/src/lib/ChromeStateStorage.ts +++ b/src/lib/ChromeStateStorage.ts @@ -48,4 +48,7 @@ function runtimePromise(handler: PromiseHandler): Promise { type Resolver = (value: T | PromiseLike) => void; -type PromiseHandler = (resolve: Resolver, rejectIfRuntimeError: () => void) => void; +type PromiseHandler = ( + resolve: Resolver, + rejectIfRuntimeError: () => void +) => void; diff --git a/src/lib/ChromeStorage.ts b/src/lib/ChromeStorage.ts index 821b724..3b04414 100644 --- a/src/lib/ChromeStorage.ts +++ b/src/lib/ChromeStorage.ts @@ -32,14 +32,20 @@ export class ChromeStorage { reject(e); } return promise.then((saved) => { - chrome.runtime.sendMessage({ source: "storage", event: "update", data: saved }); + chrome.runtime.sendMessage({ + source: "storage", + event: "update", + data: saved, + }); }); } public async load(): Promise { const { promise, resolve, reject } = Promise.withResolvers(); try { - chrome.storage.sync.get(undefined, (saved: Options) => resolve(saved as T)); + chrome.storage.sync.get(undefined, (saved: Options) => + resolve(saved as T) + ); } catch (e) { reject(e); } @@ -48,7 +54,11 @@ export class ChromeStorage { private _handleMessage(message: any) { const msg = message as StorageUpdated; - if (msg?.source === "storage" && msg?.event === "update" && this._updateHandler) { + if ( + msg?.source === "storage" && + msg?.event === "update" && + this._updateHandler + ) { this._updateHandler(msg.data); } } diff --git a/src/lib/EmbeddableApps.ts b/src/lib/EmbeddableApps.ts index 60bbb72..5e031c5 100644 --- a/src/lib/EmbeddableApps.ts +++ b/src/lib/EmbeddableApps.ts @@ -1,4 +1,4 @@ -import type { RetoolApp } from "../../types"; +import type { RetoolApp } from "@/types"; export const INSPECTOR_APP: RetoolApp = { name: "app-embedder-for-retool-inspector", diff --git a/src/lib/RetoolURL.ts b/src/lib/RetoolURL.ts deleted file mode 100644 index fe15062..0000000 --- a/src/lib/RetoolURL.ts +++ /dev/null @@ -1,120 +0,0 @@ -export function retoolAppToUrl() { - // -} - -export function retoolUrl(config: RetoolUrlConfig) { - const url = new RetoolURL(config.domain); - if (config?.app) url.app(config.app); - if (config?.env) url.env(config.env); - if (config?.version) url.version(config.version); - if (config?.embed) url.embed(config.embed); - if (config?.hideNav) url.hideNav(config.hideNav); - if (config?.hideTimer) url.hideTimer(config.hideTimer); - if (config?.historyOffset) url.historyOffset(config.historyOffset); - return url; -} - -export class RetoolURL { - private _path = ""; - private _appId = ""; - private _domain: string; - private _environment: Environment = "development"; - private _releaseVersion: RetoolVersion = "latest"; - private _embed!: boolean; - private _hideNav!: boolean; - private _hideTimer!: boolean; - private _historyOffset?: number; - - get [Symbol.toStringTag]() { - return "RetoolURL"; - } - - get raw() { - return this.toString(); - } - - get base() { - return `https://${this._domain}.retool.com/${this._path}/${this._appId}`; - } - - constructor(domain: string) { - this._domain = domain; - } - - domain(domain: string) { - this._domain = domain; - return this; - } - - app(appId: string) { - this._path = "app"; - this._appId = appId; - return this; - } - - env(value: Environment) { - this._environment = value; - return this; - } - - version(value: RetoolVersion) { - this._releaseVersion = value; - return this; - } - - embed(value = true) { - this._embed = value; - return this; - } - - hideNav(value = true) { - this._hideNav = value; - return this; - } - - hideTimer(value = true) { - this._hideTimer = value; - return this; - } - - historyOffset(value: number) { - this._historyOffset = value; - return this; - } - - toString() { - const url = new URL(this.base); - const params = new URLSearchParams(); - - params.set("_environment", this._environment); - params.set("_releaseVersion", this._releaseVersion); - if (this._embed) params.set("_embed", this._embed.toString()); - if (this._hideNav) params.set("_hideNav", this._hideNav.toString()); - if (this._hideTimer) params.set("_hideTimer", this._hideTimer.toString()); - - if (this._historyOffset !== undefined) { - params.set("_historyOffset", this._historyOffset.toString()); - } - - url.search = params.toString(); - return url.toString(); - } -} - -export type Environment = "production" | "staging" | "development"; - -export type SemVer = `${number}.${number}.${number}`; - -export type RetoolVersion = SemVer | "latest"; - -export type RetoolUrlConfig = { - domain: string; -} & Partial<{ - app: string; - env: Environment; - version: RetoolVersion; - embed: boolean; - hideNav: boolean; - hideTimer: boolean; - historyOffset: number; -}>; diff --git a/src/pages/Options/Tabs/ConfigTab.redux.tsx b/src/pages/Options/Tabs/ConfigTab.redux.tsx new file mode 100644 index 0000000..09caede --- /dev/null +++ b/src/pages/Options/Tabs/ConfigTab.redux.tsx @@ -0,0 +1,336 @@ +import React, { useMemo, useReducer, useState } from "react"; +import { Button, Col, Form, InputGroup, Row } from "react-bootstrap"; +// eslint-disable-next-line import/no-named-as-default +import toast from "react-hot-toast"; + +import { useActiveApp } from "@/hooks/useActiveApp"; +import { debug, log } from "@/lib/logger"; + +import ActiveAppUrl from "../components/ActiveAppUrl"; +import AppNameInput from "../components/AppNameInput"; +import DomainInput from "../components/DomainInput"; +import EnvironmentSelect from "../components/EnvironmentSelect"; +import VersionInput from "../components/VersionInput"; + +import type { AppEnvironment, AppVersion, RetoolApp } from "@/types/extension"; +// An enum with all the types of actions to use in our reducer +type ReducerActionKind = { + SET_NAME: "SET_NAME"; + SET_VERSION: "SET_VERSION"; + SET_ENVIRONMENT: "SET_ENVIRONMENT"; + UPDATE_HASH: "UPDATE_HASH"; + UPDATE_QUERY: "UPDATE_QUERY"; +}; + +type ReducerActions = { + type: keyof ReducerActionKind; + payload: string; +}; + +function appReducer(app: RetoolApp, action: ReducerActions): RetoolApp { + debug(action); + const { payload } = action; + switch (action.type) { + case "SET_NAME": { + return { ...app, name: payload }; + } + case "SET_VERSION": { + return { ...app, version: payload as AppVersion }; + } + case "SET_ENVIRONMENT": { + return { ...app, env: payload as AppEnvironment }; + } + case "UPDATE_HASH": { + return { ...app, env: payload as AppEnvironment }; + } + case "UPDATE_QUERY": { + } + default: { + throw Error("Unknown action: " + action.type); + } + } +} + +function OptionsForm() { + const { app, updateApp } = useActiveApp(); + // const [draftApp, setDraftApp] = useState(() => Object.assign({}, app)); + const [draftApp, dispatch] = useReducer(appReducer, Object.assign({}, app)); + + debug(draftApp); + + function setVersion(version: any) { + dispatch({ type: "SET_VERSION", payload: version }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } + + /* + 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(); + toast.success("Settings saved."); + storage.load(); + } catch (e) { + const error = e as Error; + toast.error(error.message); + } + }; +*/ + return ( + <> + + +
+

General Config

+ + +

Current App

+ debug(name)} /> + + + debug(version)} + /> + + + debug(env)} + /> + + + + + + + Extra URL Params + {app?.query && + app?.query.map((entry) => { + const key = `URL_${entry.index}`; + return ( + { + debug(`[QUERY|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(`[QUERY|SET]`, { key, index, target, data }); + // setUrlParams((old) => { + // return old.map((entry) => { + // if (entry.index === index) { + // entry[target] = data; + // } + // return entry; + // }); + // }); + }} + onRemove={(indexToRemove) => { + debug("[QUERY|DEL]", { key, indexToRemove }); + // setUrlParams((old) => { + // return old.filter((entry) => { + // return entry.index !== indexToRemove; + // }); + // }); + }} + /> + ); + })} + + + + + + + Hash Params + {app?.hash && + app.hash.map((entry) => { + const key = `HASH_${entry.index}`; + return ( + { + debug(`[HASH|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(`[HASH|SET]`, { key, index, target, data }); + // setHashParams((old) => { + // return old.map((entry) => { + // if (entry.index === index) { + // entry[target] = data; + // } + // return entry; + // }); + // }); + }} + onRemove={(indexToRemove) => { + debug("[HASH|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.tsx b/src/pages/Options/Tabs/ConfigTab.tsx index 09cced3..13a6ff8 100644 --- a/src/pages/Options/Tabs/ConfigTab.tsx +++ b/src/pages/Options/Tabs/ConfigTab.tsx @@ -1,9 +1,10 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useReducer, useState } from "react"; import { Button, Col, Form, InputGroup, Row } from "react-bootstrap"; // eslint-disable-next-line import/no-named-as-default import toast from "react-hot-toast"; import { useActiveApp } from "@/hooks/useActiveApp"; +import { useRetoolAppStore } from "@/hooks/useRetoolAppStore"; import { debug, log } from "@/lib/logger"; import ActiveAppUrl from "../components/ActiveAppUrl"; @@ -12,8 +13,33 @@ import DomainInput from "../components/DomainInput"; import EnvironmentSelect from "../components/EnvironmentSelect"; import VersionInput from "../components/VersionInput"; +import type { AppEnvironment, AppVersion, RetoolApp } from "@/types/extension"; + function OptionsForm() { const { app, updateApp } = useActiveApp(); + const draftApp = useRetoolAppStore(Object.assign({}, app)); + + // const [draftApp, setDraftApp] = useState(() => Object.assign({}, app)); + + debug(draftApp); + + function setVersion(version: any) { + dispatch({ type: "SET_VERSION", payload: version }); + } + + function handleChangeTask(task) { + dispatch({ + type: "changed", + task: task, + }); + } + + function handleDeleteTask(taskId) { + dispatch({ + type: "deleted", + id: taskId, + }); + } /* const handleSaveSettings = async () => { @@ -27,16 +53,7 @@ function OptionsForm() { } try { - await storage.save({ - domain, - app, - version, - env, - urlParams, - hashParams, - workflowUrl: "", - workflowApiKey: "", - }); + await storage.save(); toast.success("Settings saved."); storage.load(); } catch (e) { @@ -54,13 +71,19 @@ function OptionsForm() {

Current App

- + debug(name)} /> - + debug(version)} + /> - + debug(env)} + /> diff --git a/src/pages/Options/Tabs/JSONTab.tsx b/src/pages/Options/Tabs/JSONTab.tsx index 3bcd293..a06cb74 100644 --- a/src/pages/Options/Tabs/JSONTab.tsx +++ b/src/pages/Options/Tabs/JSONTab.tsx @@ -1,37 +1,30 @@ import JsonView from "@uiw/react-json-view"; import { githubLightTheme } from "@uiw/react-json-view/githubLight"; -import React, { useMemo } from "react"; -import { - Card, - CardBody, - CardHeader, - CardTitle, - Col, - Row, -} from "react-bootstrap"; +import React from "react"; +import { Card } from "react-bootstrap"; import Container from "react-bootstrap/Container"; import { useExtensionStatePrimitives } from "@/hooks/useExtensionStatePrimitives"; -import { log } from "@/lib/logger"; function JSONTab() { const state = useExtensionStatePrimitives(); - // const jsonState = useMemo(() => JSON.stringify(state, null, 2), [state]); - - const setCode = (code: string) => { - log(code); - }; - return (
- + Current State - - + +
diff --git a/src/pages/Options/Tabs/StorageTab.tsx b/src/pages/Options/Tabs/StorageTab.tsx index 1ccf0d4..88df74b 100644 --- a/src/pages/Options/Tabs/StorageTab.tsx +++ b/src/pages/Options/Tabs/StorageTab.tsx @@ -6,10 +6,8 @@ import { useExtensionState } from "@/hooks/useExtensionState"; import AppCard from "../components/AppCard"; function StorageTab() { - const reset = useExtensionState((s) => s.reset); - const apps = useExtensionState((s) => s.apps); - const activeApp = useExtensionState((s) => s.getActiveApp()); + const reset = useExtensionState((s) => s.reset); return ( @@ -32,12 +30,8 @@ function StorageTab() { <> ) : ( apps.map((app) => ( - - + + )) )} diff --git a/src/pages/Options/components/AppCard.tsx b/src/pages/Options/components/AppCard.tsx index 63af863..cd66398 100644 --- a/src/pages/Options/components/AppCard.tsx +++ b/src/pages/Options/components/AppCard.tsx @@ -4,20 +4,23 @@ import { clsx } from "clsx"; import React from "react"; import { Badge, Button, Card, Col, Row } from "react-bootstrap"; -import { useComposedUrl } from "@/hooks/useComposedUrl"; +import { useActiveApp } from "@/hooks/useActiveApp"; +import { useAppUrl } from "@/hooks/useAppUrl"; import { useExtensionState } from "@/hooks/useExtensionState"; import type { RetoolApp } from "@/types"; type Props = { app: RetoolApp; - isActive: boolean; }; -function AppCard({ app, isActive }: Props) { - const domain = useExtensionState((s) => s.domain); +function AppCard({ app }: Props) { + const appUrl = useAppUrl(app); + const { app: activeApp } = useActiveApp(); const setActiveApp = useExtensionState((s) => s.setActiveApp); + const isActive = app.name === activeApp?.name; + return ( Query Params
{app.query.map((p) => ( - <> +
{p.param}
{p.value}
- +
))}
@@ -63,10 +66,10 @@ function AppCard({ app, isActive }: Props) {
Hash Params
{app.hash.map((p) => ( - <> +
{p.param}
{p.value}
- +
))}
@@ -78,7 +81,7 @@ function AppCard({ app, isActive }: Props) { target="_blank" rel="noreferrer" className="btn-sm d-flex align-items-center gap-1" - href={useComposedUrl(domain, app)} + href={appUrl} > Open in Retool diff --git a/src/pages/Options/components/AppNameInput.tsx b/src/pages/Options/components/AppNameInput.tsx index da97c5f..418652e 100644 --- a/src/pages/Options/components/AppNameInput.tsx +++ b/src/pages/Options/components/AppNameInput.tsx @@ -1,11 +1,12 @@ import React from "react"; import Form from "react-bootstrap/Form"; -import { useActiveApp } from "@/hooks/useActiveApp"; - -export default function AppNameInput() { - const { app, updateApp } = useActiveApp(); +type Props = { + name: string; + onChange: (name: string) => void; +}; +export default function AppNameInput({ name, onChange }: Partial) { return ( @@ -13,8 +14,10 @@ export default function AppNameInput() { (required) updateApp({ name: e.target.value })} + value={name} + onChange={(e) => { + if (onChange) onChange(e.target.value); + }} /> Use the "Share" button in the editor and copy the name / id from the URL diff --git a/src/pages/Options/components/EnvironmentSelect.tsx b/src/pages/Options/components/EnvironmentSelect.tsx index 0ddb1d7..30e0f27 100644 --- a/src/pages/Options/components/EnvironmentSelect.tsx +++ b/src/pages/Options/components/EnvironmentSelect.tsx @@ -2,26 +2,25 @@ import clsx from "clsx"; import React from "react"; import Form from "react-bootstrap/Form"; -import { useActiveApp } from "@/hooks/useActiveApp"; +import type { AppEnvironment } from "@/types/extension"; -import type { AppEnvironment } from "@/types"; +type Props = { + env?: AppEnvironment; + onChange: (env: AppEnvironment) => void; +}; -export default function EnvironmentSelect() { - const { app, updateApp } = useActiveApp(); +export default function EnvironmentSelect({ env, onChange }: Props) { + const handleChange = onChange ?? function () {}; return ( -
 
+
 
Environment
{ - updateApp({ - env: e.target.value as AppEnvironment, - }); - }} + value={env} + onChange={(e) => handleChange(e.target.value as AppEnvironment)} > diff --git a/src/pages/Options/components/VersionInput.tsx b/src/pages/Options/components/VersionInput.tsx index a5162ad..879e985 100644 --- a/src/pages/Options/components/VersionInput.tsx +++ b/src/pages/Options/components/VersionInput.tsx @@ -1,25 +1,28 @@ import React from "react"; import Form from "react-bootstrap/Form"; -import { useActiveApp } from "@/hooks/useActiveApp"; +import type { AppVersion } from "@/types/extension"; -import type { AppVersion } from "@/types"; +type Props = { + version: AppVersion | undefined; + onChange?: (version: AppVersion) => void; +}; -export default function VersionInput() { - const { app, updateApp } = useActiveApp(); +export default function VersionInput({ version, onChange }: Props) { + const handleChange = onChange ?? function () {}; return ( Version { const value = e.target.value as AppVersion; + let version: AppVersion = "latest"; if (/(?:[0-9]+\.){2}[0-9]+/.test(value)) { - updateApp({ version: value }); - } else { - updateApp({ version: "latest" }); + version = value; } + handleChange(version); }} /> diff --git a/src/pages/Options/index.css b/src/pages/Options/index.css index f47fe75..da6c958 100644 --- a/src/pages/Options/index.css +++ b/src/pages/Options/index.css @@ -18,7 +18,7 @@ body { } .tab-content { - height: calc(100vh - (162px)) !important; + height: calc(100vh - (160px)) !important; overflow-y: auto; } diff --git a/src/types/events.ts b/src/types/events.ts deleted file mode 100644 index 8ebe74d..0000000 --- a/src/types/events.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type MyEvents = { - ON_INSTALLED: SimpleEvent; - OPEN_OPTIONS: SimpleEvent; - RELOAD_RETOOL_EMBED: SimpleEvent; -}; - -type Nothing = void | undefined | {}; -type SimpleEvent = { - in: Nothing; - out: Nothing; -}; diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 45143cd..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RetoolUrlConfig } from "../lib/RetoolURL"; - -export * from "./events"; -export * from "./extension"; - -type PartialRetoolUrl = Omit< - RetoolUrlConfig, - "embed" | "hideNav" | "hideTimer" | "historyOffset" ->; - -export type ExtensionSettings = Partial< - PartialRetoolUrl & { - workflowUrl: string; - workflowApiKey: string; - } ->;