├── .DS_Store ├── .babelrc ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── .DS_Store ├── electron │ ├── main.js │ ├── preload.ts │ ├── prisma.ts │ └── protocol.js ├── src │ ├── components │ │ ├── activeExperiments.tsx │ │ ├── experiment.tsx │ │ ├── experimentCreate.tsx │ │ ├── instructions.tsx │ │ └── selectPath.tsx │ ├── constants │ │ └── routes.ts │ ├── core │ │ ├── App.tsx │ │ ├── nav.tsx │ │ └── routes.tsx │ ├── css │ │ └── styles.css │ ├── index.html │ ├── index.js │ ├── index.tsx │ ├── loading │ │ ├── components │ │ │ └── loading.tsx │ │ ├── index.html │ │ └── index.js │ ├── modal │ │ ├── components │ │ │ └── editor.tsx │ │ ├── index.html │ │ └── index.js │ ├── pages │ │ ├── config │ │ │ ├── ConfigureVariantComponents │ │ │ │ ├── ConfigureVariant.tsx │ │ │ │ ├── SubmitVariant.tsx │ │ │ │ └── VariantRow.tsx │ │ │ ├── TestConfigInstructions.tsx │ │ │ ├── TestingConfig.tsx │ │ │ ├── Unused │ │ │ │ ├── DatabaseClear.tsx │ │ │ │ ├── ExperimentDropDown.tsx │ │ │ │ └── NameExperiment.tsx │ │ │ └── VariantDisplayComponents │ │ │ │ ├── DeleteVariant.tsx │ │ │ │ ├── EditVariant.tsx │ │ │ │ └── VariantDisplay.tsx │ │ └── home │ │ │ └── home.tsx │ └── redux │ │ ├── experimentsSlice.ts │ │ └── store.ts └── templates │ ├── middleware.ts │ ├── nimble.config.json │ └── staticConfig.json ├── dev-scripts ├── launchDevServer.js └── prepareDevServer.js ├── images └── icon.png ├── middleware.ts ├── nimble.config.json ├── nimbleStore2.db ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── nimbleStore2.db └── schema.prisma ├── renderer.d.ts ├── tailwind.config.js ├── tsconfig.json ├── webpack.config.js ├── webpack.development.js └── webpack.production.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "module-resolver", 5 | { 6 | "cwd": "babelrc", 7 | "alias": { 8 | "Constants": "./app/src/constants", 9 | "Components": "./app/src/components", 10 | "Core": "./app/src/core", 11 | "Pages": "./app/src/pages", 12 | "Redux": "./app/src/redux", 13 | "Images": "./resources/images" 14 | } 15 | }, 16 | "@babel/plugin-syntax-dynamic-import" 17 | ] 18 | ], 19 | "presets": [ 20 | "@babel/preset-env", 21 | "@babel/preset-react", 22 | "@babel/preset-typescript" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | node_modules 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | .cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # TernJS port file 121 | .tern-port 122 | 123 | # Stores VSCode versions used for testing VSCode extensions 124 | .vscode-test 125 | 126 | # yarn v2 127 | .yarn/cache 128 | .yarn/unplugged 129 | .yarn/build-state.yml 130 | .yarn/install-state.gz 131 | .pnp.* 132 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | Logo 6 |
7 |
8 | 9 | ## NimbleAB 10 | ## About 11 | Nimble AB is a lightweight NextJS AB testing platform designed to streamline AB testing on NextJS apps that want to take advantage of SSG load times in UX experiments. The platform's goal is to make the developer experience of configuring and implementing AB tests on static sites fast and painless so that businesses can optimize load times on AB tests without sacrificing the dynamicism required for high-impact testing. 12 | 13 | For more info visit our [website](https://nimbleab.io/) or read our [Medium article](https://nimblelabs.medium.com/6b54e84e473) 14 | 15 | ## Tech Stack 16 |
17 | 18 | [![Typescript][TS.js]][TS-url] [![JavaScript][JavaScript]][JavaScript-url] [![React][React.js]][React-url] [![React Router][React Router]][React-Router-url] [![Node.js][Node.js]][Node-url] 19 | [![Prisma][Prisma.js]][Prisma-url] [![Jest][Jest]][Jest-url] [![Tailwind][Tailwind]][Tailwind-url] [![DaisyUI][DaisyUI]][DaisyUI-url][![Electron.js][Electron.js]][Electron-url] [![AWS][AWS]][AWS-url] [![Next][Next.js]][Next-url] [![Supabase][Supabase]][Supabase-url] [![Express][Express.js]][Express-url] 20 |
21 | 22 | ## Download 23 | [Windows](https://nimbleab-production-build.s3.us-east-2.amazonaws.com/NimbleAB+Setup+1.0.0.exe) 24 | [Mac](https://nimbleab-production-build.s3.us-east-2.amazonaws.com/NimbleAB-1.0.0-mac.zip) 25 | 26 | **Mac Users, If you are unable to open the app make sure to drag the application to the Applications folder and run the following command:** 27 | 28 | `sudo xattr -r -d com.apple.quarantine /Applications/nimbleAB.app` 29 | 30 | ## Usage 31 | Nimble AB offers an NPM package for SSG decisioning that can be installed here (placeholder) 32 | 33 | Our Desktop app can be used for full end to end test construction and variant creation. Download link is above. Create experiments, build and edit variants, and use config files generated to your local repo on your CDN to serve pages. 34 | 35 | **NPM Package Usage** 36 | 37 | Install the package below 38 | 39 | `npm install nimbleab` 40 | 41 | The package expects a config object in the following format: 42 | 43 | ```json 44 | { 45 | "experiment_path": "/pages", 46 | "experiment_name": "Blog test 2", 47 | "experiment_id": "57056b01-39bd-43c5-85e1-fba6611bb2b2", 48 | "device_type": "desktop", 49 | "variants": [ 50 | { 51 | "id": "333896e0-09e7-4b29-9398-e250b60941c4", 52 | "fileName": "testa", 53 | "weight": 25 54 | }, 55 | { 56 | "id": "05e1af45-b7a2-417c-a43d-d1b29d6a4b15", 57 | "fileName": "testb", 58 | "weight": 25 59 | }, 60 | { 61 | "id": "b6205652-b885-47b6-968b-1635d2e6dc48", 62 | "fileName": "testc", 63 | "weight": 50 64 | } 65 | ] 66 | } 67 | ``` 68 | 69 | A user can either configure a static test independent of our underlying experiment platform by adjusting weights and URLs on the static config deployed on an edge function that can run Javascript. The package will not function properly without weights summing to 100 so be sure to validate this. Verbose erroring in this case is a future roadmap feature. Make sure that all IDs are valid UUID Version 4. 70 | 71 | A user can also call our API to return variants using their experiment Id. This can be found in the nimble.config.json file on your local repo after experiment creation in the Electron app. 72 | 73 | **Desktop app usage** 74 | 75 | Nimble Labs is proud to offer our open source Desktop app for public use. Download link is above. To use: 76 | 77 | - Dowload the desktop app (links above) 78 | - Create an experiment 79 | - Name your experiment something semantic 80 | - Press the Open Directory button 81 | - Navigate to a Next JS project 82 | - Choose your project's src or app directory 83 | - Once the directory is selected, then select the path that you would like to run the experiment on 84 | - Press create experiment to be taken to the variants config page 85 | - Configure variants 86 | - Add variants using the inputs at the top of the page. The file path will be the name of the file storing the variant, so naming in a semantically useful way is recommended. 87 | - Create the weight for the variant. Many tests will be 50/50 or 33/33/34 but customize the weights as needed to achieve the randomness desired to meet business requirements. 88 | - Once all variants have been configured, hit edit to make adjustments as needed 89 | - Edit variant code 90 | - Hit edit on the variants table to make changes to the variants. Ensure to save when finished; these changes will save down to your local. 91 | - Deploy 92 | - Once all edits are complete, the necessary middleware will be automatically saved down into the nimble.config.json file and the variants folder inside your local repo. Just deploy the new pages to your hosting infrastructure, and the middleware will perform decisioning according to the weights previously configured. 93 | 94 | ## Features Roadmap 95 | Open issues for any issues encountered and the team will investigate and implement fixes as able. 96 | 97 | Our longer term features roadmap is as follows: 98 | 99 | Highest priority 100 | * Backwards compatibility to older versions of NextJs (<13.0) 101 | * Github deploys from the UI 102 | * Support for Typescript projects 103 | * Ability to delete experiments 104 | * Validation improvements on our Front End (weights validation doesn't take place currently and is on the user) 105 | * Project validation (provide user feedback if their project isn't NextJs validated) 106 | 107 | Future facing 108 | * Adding file tree into the editor 109 | * CLI support for experiment maintenance 110 | * Verbose error handling on client 111 | * End to End Testing 112 | 113 | Contributions are appreciated and will be reviewed as fast as we are able. Merged contributions will be credited as authors. 114 | 115 | ## Authors 116 | | Developed By | Github | LinkedIn | 117 | | :----------: | :---------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------: | 118 | | Andrew Kraus | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/ajkraus04) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/andrewjkraus/) | 119 | | Zhenwei Liu | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/lzwaaron) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/zhenwei--liu/) | 120 | | James Boswell | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/jamesboswell1994) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/james-boswell/) | 121 | 122 | 123 | 124 | [React.js]: https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB 125 | [React-url]: https://reactjs.org/ 126 | [TS.js]: https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white 127 | [TS-url]: https://www.typescriptlang.org/ 128 | [D3.js]: https://img.shields.io/badge/d3.js-F9A03C?style=for-the-badge&logo=d3.js&logoColor=white 129 | [D3-url]: https://d3js.org/ 130 | [React Router]: https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white 131 | [React-Router-url]: https://reactrouter.com/en/main 132 | [JavaScript]: https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E 133 | [JavaScript-url]: https://www.javascript.com/ 134 | [Node.js]: https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white 135 | [Node-url]: https://nodejs.org/ 136 | [Kubernetes]: https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white 137 | [Kubernetes-url]: https://kubernetes.io/ 138 | [Jest]: https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white 139 | [Jest-url]: https://jestjs.io/ 140 | [AWS]: https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white 141 | [AWS-url]: https://aws.amazon.com/ 142 | [DaisyUI]: https://img.shields.io/badge/daisyui-5A0EF8?style=for-the-badge&logo=daisyui&logoColor=white 143 | [DaisyUI-url]: https://daisyui.com/ 144 | [Tailwind]: https://img.shields.io/badge/Tailwind-%231DA1F2.svg?style=for-the-badge&logo=tailwind-css&logoColor=white 145 | [Tailwind-url]: https://tailwindcss.com/ 146 | [MUI]: https://img.shields.io/badge/MUI-%230081CB.svg?style=for-the-badge&logo=mui&logoColor=white 147 | [MUI-url]: https://mui.com/ 148 | [SocketIO]: https://img.shields.io/badge/Socket.io-black?style=for-the-badge&logo=socket.io&badgeColor=010101 149 | [SocketIO-url]: https://socket.io/ 150 | [Electron.js]: https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white 151 | [Electron-url]: https://www.electronjs.org/ 152 | [Prisma.js]: https://img.shields.io/badge/Prisma-3982CE?style=for-the-badge&logo=Prisma&logoColor=white 153 | [Prisma-url]: https://www.prisma.io/ 154 | [Next.js]: https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white 155 | [Next-url]: https://nextjs.org/ 156 | [Supabase]: https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white 157 | [Supabase-url]: https://supabase.com/ 158 | [Express.js]: https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB 159 | [Express-url]: https://expressjs.com/ 160 | -------------------------------------------------------------------------------- /app/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/app/.DS_Store -------------------------------------------------------------------------------- /app/electron/main.js: -------------------------------------------------------------------------------- 1 | const { 2 | app, 3 | protocol, 4 | BrowserWindow, 5 | session, 6 | ipcMain, 7 | dialog, 8 | Menu, 9 | MenuItem, 10 | } = require("electron"); 11 | const Store = require("electron-store"); 12 | const { 13 | default: installExtension, 14 | REDUX_DEVTOOLS, 15 | REACT_DEVELOPER_TOOLS, 16 | } = require("electron-devtools-installer"); 17 | const axios = require("axios"); 18 | const prisma = require("./prisma.ts"); 19 | const reflect = require("reflect-metadata"); 20 | 21 | const Protocol = require("./protocol"); 22 | const path = require("path"); 23 | const fs = require("fs"); 24 | const crypto = require("crypto"); 25 | const { data } = require("autoprefixer"); 26 | const isDev = process.env.NODE_ENV === "development"; 27 | const port = 40992; // Hardcoded; needs to match webpack.development.js and package.json 28 | const selfHost = `http://localhost:${port}`; 29 | 30 | // Keep a global reference of the window object, if you don't, the window will 31 | // be closed automatically when the JavaScript object is garbage collected. 32 | let win; 33 | 34 | //window for variant editor modal 35 | let childWindow; 36 | 37 | let menuBuilder; 38 | const store = new Store({ 39 | path: app.getPath("userData"), 40 | }); 41 | 42 | async function createWindow() { 43 | //Add Autoupdating functionality here 44 | 45 | if (!isDev) { 46 | protocol.registerBufferProtocol(Protocol.scheme, Protocol.requestHandler); 47 | } 48 | 49 | win = new BrowserWindow({ 50 | width: 1100, 51 | height: 800, 52 | minHeight: 900, 53 | minWidth: 600, 54 | icon: path.join(__dirname, "../../images/icon.png"), 55 | title: "Application is starting up...", 56 | webPreferences: { 57 | devTools: true, 58 | nodeIntegration: false, 59 | nodeIntegrationInWorker: false, 60 | nodeIntegrationInSubFrames: false, 61 | contextIsolation: true, 62 | enableRemoteModule: false, 63 | preload: path.join(__dirname, "preload.ts"), 64 | // disableBlinkFeatures: "Auxclick", 65 | }, 66 | }); 67 | //set initial background color 68 | win.setBackgroundColor("#3b19fc"); 69 | 70 | //Loads local server in DevMode. Modal Only Loads in Dev mode if chunks are changed. Production is Ready 71 | if (isDev) { 72 | win.loadURL(selfHost); 73 | } else { 74 | win.loadURL(`${Protocol.scheme}://rse/index.html`); 75 | } 76 | 77 | win.webContents.on("did-finish-load", () => { 78 | win.setTitle(`Nimble Labs`); 79 | }); 80 | 81 | //Loads DevTools in DevMode 82 | if (isDev) { 83 | win.webContents.once("dom-ready", async () => { 84 | await installExtension([REDUX_DEVTOOLS]) 85 | .then((name) => console.log(`Added Extension ${name}`)) 86 | .catch((err) => console.log("An error occured: ", err)) 87 | .finally(() => { 88 | win.webContents.openDevTools(); 89 | }); 90 | }); 91 | } 92 | 93 | //Emits when window is closed 94 | win.on("closed", () => { 95 | win = null; 96 | }); 97 | 98 | // https://electronjs.org/docs/tutorial/security#4-handle-session-permission-requests-from-remote-content 99 | const ses = session; 100 | const partition = "default"; 101 | ses 102 | .fromPartition( 103 | partition 104 | ) /* eng-disable PERMISSION_REQUEST_HANDLER_JS_CHECK */ 105 | .setPermissionRequestHandler((webContents, permission, permCallback) => { 106 | const allowedPermissions = []; // Full list here: https://developer.chrome.com/extensions/declare_permissions#manifest 107 | 108 | if (allowedPermissions.includes(permission)) { 109 | permCallback(true); // Approve permission request 110 | } else { 111 | console.error( 112 | `The application tried to request permission for '${permission}'. This permission was not whitelisted and has been blocked.` 113 | ); 114 | 115 | permCallback(false); // Deny 116 | } 117 | }); 118 | 119 | //Uncomment this once menu is set up 120 | 121 | // menuBuilder = MenuBuilder(win, app.name); 122 | } 123 | 124 | //Function to create text editor modal 125 | async function createTextEditorModal(filePath) { 126 | // the filepath argument is to be used to place the user into whatever path contains their variants that way they don't need to 127 | // traverse the whole file tree to get there 128 | if (!isDev) { 129 | protocol.registerBufferProtocol(Protocol.scheme, Protocol.requestHandler); 130 | } 131 | childWindow = new BrowserWindow({ 132 | width: 1000, 133 | height: 800, 134 | title: "Application is starting up...", 135 | parent: win, 136 | modal: false, 137 | webPreferences: { 138 | devTools: isDev, 139 | nodeIntegration: false, 140 | nodeIntegrationInWorker: false, 141 | nodeIntegrationInSubFrames: false, 142 | contextIsolation: true, 143 | enableRemoteModule: false, 144 | preload: path.join(__dirname, "preload.ts"), 145 | }, 146 | }); 147 | 148 | if (isDev) { 149 | childWindow.loadURL(selfHost + "/modal.html"); 150 | } else { 151 | childWindow.loadURL(`${Protocol.scheme}://rse/modal.html`); 152 | } 153 | 154 | childWindow.webContents.on("did-finish-load", () => { 155 | childWindow.setTitle(`Nimble Labs`); 156 | 157 | //make sure not modifying files in this project directory 158 | if (!filePath.includes(__dirname)) { 159 | const data = fs.readFileSync(filePath); 160 | childWindow.webContents.send("file-path", { data, filePath }); 161 | } 162 | }); 163 | 164 | //Loads Redux DevTools when in DevMode 165 | if (isDev) { 166 | childWindow.webContents.once("dom-ready", async () => { 167 | await installExtension([REDUX_DEVTOOLS]) 168 | .then((name) => console.log(`Added Extension ${name}`)) 169 | .catch((err) => console.log("An error occured: ", err)) 170 | .finally(() => { 171 | childWindow.webContents.openDevTools(); 172 | }); 173 | }); 174 | } 175 | 176 | //Emits when window is closed 177 | childWindow.on("closed", () => { 178 | childWindow = null; 179 | }); 180 | 181 | // https://electronjs.org/docs/tutorial/security#4-handle-session-permission-requests-from-remote-content 182 | const ses = session; 183 | const partition = "default"; 184 | ses 185 | .fromPartition( 186 | partition 187 | ) /* eng-disable PERMISSION_REQUEST_HANDLER_JS_CHECK */ 188 | .setPermissionRequestHandler((webContents, permission, permCallback) => { 189 | const allowedPermissions = []; // Full list here: https://developer.chrome.com/extensions/declare_permissions#manifest 190 | 191 | if (allowedPermissions.includes(permission)) { 192 | permCallback(true); // Approve permission request 193 | } else { 194 | console.error( 195 | `The application tried to request permission for '${permission}'. This permission was not whitelisted and has been blocked.` 196 | ); 197 | 198 | permCallback(false); // Deny 199 | } 200 | }); 201 | 202 | //Add Custom Menu Builder for the Modal 203 | //Add save button to menu 204 | const menu = new Menu(); 205 | menu.append( 206 | new MenuItem({ 207 | label: "File", 208 | submenu: [ 209 | { 210 | click: () => childWindow.webContents.send("save-file"), 211 | label: "Save", 212 | accelerator: process.platform === "darwin" ? "Cmd+s" : "Ctrl+s", 213 | }, 214 | ], 215 | }) 216 | ); 217 | console.log(menu); 218 | childWindow.setMenu(menu); 219 | } 220 | 221 | // Needs to be called before app is ready; 222 | // gives our scheme access to load relative files, 223 | // as well as local storage, cookies, etc. 224 | // https://electronjs.org/docs/api/protocol#protocolregisterschemesasprivilegedcustomschemes 225 | protocol.registerSchemesAsPrivileged([ 226 | { 227 | scheme: Protocol.scheme, 228 | privileges: { 229 | standard: true, 230 | secure: true, 231 | }, 232 | }, 233 | ]); 234 | 235 | //Set App Name 236 | app.setName("Nimble AB"); 237 | // This method will be called when Electron has finished 238 | // initialization and is ready to create browser windows. 239 | // Some APIs can only be used after this event occurs. 240 | //init o 241 | app.whenReady().then(() => { 242 | createWindow(); 243 | }); 244 | 245 | // Quit when all windows are closed. 246 | app.on("window-all-closed", () => { 247 | // On macOS it is common for applications and their menu bar 248 | // to stay active until the user quits explicitly with Cmd + Q 249 | if (process.platform !== "darwin") { 250 | app.quit(); 251 | } 252 | }); 253 | 254 | app.on("activate", () => { 255 | // On macOS it's common to re-create a window in the app when the 256 | // dock icon is clicked and there are no other windows open. 257 | if (win === null) { 258 | createWindow(); 259 | } 260 | }); 261 | 262 | // https://electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation 263 | app.on("web-contents-created", (event, contents) => { 264 | contents.on("will-navigate", (contentsEvent, navigationUrl) => { 265 | /* eng-disable LIMIT_NAVIGATION_JS_CHECK */ 266 | const parsedUrl = new URL(navigationUrl); 267 | const validOrigins = [selfHost]; 268 | 269 | // Log and prevent the app from navigating to a new page if that page's origin is not whitelisted 270 | if (!validOrigins.includes(parsedUrl.origin)) { 271 | console.error( 272 | `The application tried to navigate to the following address: '${parsedUrl}'. This origin is not whitelisted and the attempt to navigate was blocked.` 273 | ); 274 | 275 | contentsEvent.preventDefault(); 276 | } 277 | }); 278 | 279 | contents.on("will-redirect", (contentsEvent, navigationUrl) => { 280 | const parsedUrl = new URL(navigationUrl); 281 | const validOrigins = []; 282 | 283 | // Log and prevent the app from redirecting to a new page 284 | if (!validOrigins.includes(parsedUrl.origin)) { 285 | console.error( 286 | `The application tried to redirect to the following address: '${navigationUrl}'. This attempt was blocked.` 287 | ); 288 | 289 | contentsEvent.preventDefault(); 290 | } 291 | }); 292 | 293 | // https://electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation 294 | contents.on( 295 | "will-attach-webview", 296 | (contentsEvent, webPreferences, params) => { 297 | // Disable Node.js integration 298 | webPreferences.nodeIntegration = false; 299 | } 300 | ); 301 | 302 | // https://electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows 303 | // This code replaces the old "new-window" event handling; 304 | // https://github.com/electron/electron/pull/24517#issue-447670981 305 | contents.setWindowOpenHandler(({ url }) => { 306 | const parsedUrl = new URL(url); 307 | const validOrigins = []; 308 | 309 | // Log and prevent opening up a new window 310 | if (!validOrigins.includes(parsedUrl.origin)) { 311 | console.error( 312 | `The application tried to open a new window at the following address: '${url}'. This attempt was blocked.` 313 | ); 314 | 315 | return { 316 | action: "deny", 317 | }; 318 | } 319 | 320 | return { 321 | action: "allow", 322 | }; 323 | }); 324 | }); 325 | 326 | //Choose Directory Functionality 327 | async function handleFileOpen() { 328 | const { canceled, filePaths } = await dialog.showOpenDialog(win, { 329 | properties: ["openDirectory"], 330 | }); 331 | if (!canceled) { 332 | store.set("directoryPath", filePaths[0]); 333 | return { basename: path.basename(filePaths[0]), fullPath: filePaths[0] }; 334 | } else { 335 | return; 336 | } 337 | } 338 | 339 | //Gets all paths for Next Js Directory 340 | function handleDirectoryPaths() { 341 | const dirPath = store.get("directoryPath"); 342 | console.log(dirPath); 343 | const pathsArr = []; 344 | const fullPaths = [dirPath]; 345 | const map = { app: "/" }; 346 | if (path.basename(dirPath) === "app") pathsArr.push("/"); 347 | //Recurses through directory only pulling acitve paths 348 | // Can make this more refined by looking for only directories with page.jsx in it 349 | function parsePaths(dirPath) { 350 | const dirFiles = fs.readdirSync(dirPath); 351 | // console.log(dirFiles); 352 | for (const file of dirFiles) { 353 | const stats = fs.statSync(path.join(dirPath, file)); 354 | 355 | // console.log(file); 356 | if (stats.isDirectory()) { 357 | if (file[0] === "(") { 358 | parsePaths(path.join(dirPath, file)); 359 | } else { 360 | if (map[file]) pathsArr.push(map[file]); 361 | else pathsArr.push("/" + file); 362 | fullPaths.push(dirPath + "/" + file); 363 | parsePaths(path.join(dirPath, file)); 364 | } 365 | } 366 | } 367 | } 368 | 369 | parsePaths(dirPath); 370 | store.set("dirPaths", fullPaths); 371 | console.log(store.get("dirPaths")); 372 | return pathsArr; 373 | } 374 | 375 | function handleGetExperiments() { 376 | const experiments = store.get("experiments"); 377 | return experiments; 378 | } 379 | 380 | // takes an experiment object 381 | async function handleAddExperiment(event, experiment) { 382 | console.log(experiment); 383 | const { 384 | Experiment_name, 385 | Device_Type, 386 | Repo_id, 387 | experiment_path, 388 | experiment_uuid, 389 | directory_path, 390 | } = experiment; 391 | let new_directory_path = directory_path; 392 | console.log("basename", path.basename(directory_path)); 393 | if (path.basename(directory_path) === "src") new_directory_path += "/app"; 394 | try { 395 | //Creates a variants folder in the experiment path 396 | fs.mkdir( 397 | path.join(new_directory_path, experiment_path, "variants"), 398 | (err) => console.log(err) 399 | ); 400 | 401 | //copies middleware file into new directory 402 | fs.copyFile( 403 | path.join(__dirname, "../templates/middleware.ts"), 404 | path.join(directory_path, `middleware.ts`), 405 | fs.constants.COPYFILE_EXCL, 406 | (err) => console.log(err) 407 | ); 408 | 409 | fs.copyFile( 410 | path.join(__dirname, "../templates/nimble.config.json"), 411 | path.join(directory_path, "nimble.config.json"), 412 | fs.constants.COPYFILE_EXCL, 413 | (err) => console.log(err) 414 | ); 415 | 416 | console.log("reached this "); 417 | const data = fs.readFileSync( 418 | path.join(directory_path, "nimble.config.json") 419 | ); 420 | 421 | const parsed_data = JSON.parse(data); 422 | 423 | const paths = parsed_data.map((el) => el.experiment_path); 424 | console.log(paths); 425 | if (!paths.includes(experiment_path)) { 426 | parsed_data.push({ 427 | experiment_path: experiment_path, 428 | experiment_name: Experiment_name, 429 | experiment_id: experiment_uuid, 430 | device_type: Device_Type, 431 | variants: [], 432 | }); 433 | const newExperiment = await prisma.experiments.create({ 434 | data: { 435 | Experiment_Name: Experiment_name, 436 | Device_Type, 437 | Repo_id, 438 | experiment_path, 439 | experiment_uuid, 440 | }, 441 | }); 442 | //Adds Experiment to database on supabase 443 | axios.post("https://nimblebackend-te9u.onrender.com/createExperiment", { 444 | experiment_name: Experiment_name, 445 | experimentId: experiment_uuid, 446 | experiment_path, 447 | device_type: Device_Type, 448 | }); 449 | console.log(directory_path); 450 | } else { 451 | const msg = "Experiment Already Created"; 452 | return msg; 453 | } 454 | 455 | fs.writeFileSync( 456 | path.join(directory_path, "nimble.config.json"), 457 | JSON.stringify(parsed_data) 458 | ); 459 | 460 | console.log("New experiment created"); 461 | } catch (error) { 462 | console.error( 463 | "Error creating experiment with name ", 464 | experiment, 465 | "error message: ", 466 | error 467 | ); 468 | } 469 | } 470 | 471 | async function handleAddVariant(event, variant) { 472 | // destructure the variant object 473 | console.log(variant); 474 | const { 475 | filePath, 476 | weight, 477 | directoryPath, 478 | experimentPath, 479 | variantUuid, 480 | experiment_uuid, 481 | } = variant; 482 | let new_directory_path = directoryPath; 483 | console.log("basename", path.basename(directoryPath)); 484 | if (path.basename(directoryPath) === "src") new_directory_path += "/app"; 485 | 486 | // front end doesn't have access to the integer ID. Use the uuid to get the integer to use for the local 487 | 488 | const experimentObj = await prisma.Experiments.findFirst({ 489 | where: { 490 | experiment_uuid: experiment_uuid, 491 | }, 492 | }); 493 | 494 | const experimentId = experimentObj.id; 495 | 496 | try { 497 | const newVariant = await prisma.Variants.create({ 498 | data: { 499 | filePath: filePath, 500 | weights: weight, 501 | Experiment_Id: experimentId, 502 | 503 | // this is on the schema but may not be needed. For now a blank array 504 | }, 505 | }); 506 | console.log(variantUuid); 507 | //Add variants to supabase 508 | axios.post("https://nimblebackend-te9u.onrender.com/createVariant", { 509 | variant_id: variantUuid, 510 | experimentId: experiment_uuid, 511 | variant_weight: weight, 512 | variant_name: filePath, 513 | }); 514 | 515 | fs.mkdirSync( 516 | path.join(new_directory_path, experimentPath, "variants", filePath) 517 | ); 518 | //Creates variant in variants folder 519 | fs.copyFile( 520 | path.join(new_directory_path, experimentPath, `page.js`), 521 | path.join( 522 | new_directory_path, 523 | experimentPath, 524 | "variants", 525 | `${filePath}`, 526 | "page.js" 527 | ), 528 | (err) => console.log(err) 529 | ); 530 | 531 | const data = fs.readFileSync( 532 | path.join(directoryPath, "nimble.config.json") 533 | ); 534 | const parsed_data = JSON.parse(data); 535 | //Adds variant to corresponding experiment 536 | for (let i = 0; i < parsed_data.length; i++) { 537 | if (parsed_data[i].experiment_path === experimentPath) { 538 | parsed_data[i].variants.push({ 539 | id: variantUuid, 540 | fileName: filePath, 541 | weight: weight, 542 | }); 543 | } 544 | } 545 | fs.writeFileSync( 546 | path.join(directoryPath, "nimble.config.json"), 547 | JSON.stringify(parsed_data) 548 | ); 549 | console.log("New variant added"); 550 | } catch (error) { 551 | console.error( 552 | "Error creating variant with data: ", 553 | variant, 554 | "error message: ", 555 | error 556 | ); 557 | } 558 | } 559 | 560 | async function handleGetVariants(event, experimentId) { 561 | console.log("reached the getVariants function"); 562 | try { 563 | const expVariants = await prisma.experiments.findMany({ 564 | where: { 565 | experiment_uuid: experimentId, 566 | }, 567 | select: { 568 | Variants: true, 569 | }, 570 | }); 571 | // console.log(variants); 572 | return JSON.stringify(expVariants); 573 | } catch (error) { 574 | console.error( 575 | "Error retrieving variant with experimentID ", 576 | experimentId, 577 | "error message: ", 578 | error 579 | ); 580 | } 581 | } 582 | 583 | async function handleGetExperiments(event, experimentId) { 584 | console.log("reached the getExperiments function"); 585 | try { 586 | const experiments = await prisma.experiments.findMany({ 587 | where: { 588 | id: experimentId, 589 | }, 590 | }); 591 | console.log(experiments); 592 | return JSON.stringify(experiments); 593 | } catch (error) { 594 | console.error( 595 | "Error fetching experiment with experimentID ", 596 | experimentId, 597 | "error message: ", 598 | error 599 | ); 600 | } 601 | } 602 | async function handleAddRepo(event, repo) { 603 | // console.log(repo); 604 | try { 605 | const { FilePath } = repo; 606 | // const data = await prisma.Repos.upsert({ 607 | // where: { FilePath }, 608 | // create: { FilePath }, 609 | // update: { FilePath }, 610 | // }); 611 | 612 | const data = await prisma.Repos.create({ 613 | data: { FilePath }, 614 | }); 615 | console.log(data); 616 | return data; 617 | } catch (err) { 618 | console.log(err); 619 | } 620 | } 621 | 622 | //Creates Text Editor Modal 623 | async function handleCreateTextEditor(event, value) { 624 | console.log(value); 625 | console.log(Object.keys(value) + " are keys passed down"); 626 | 627 | const { filePath, experimentPath, directoryPath } = value; 628 | let newDirectoryPath = directoryPath; 629 | if (path.basename(directoryPath) === "src") newDirectoryPath += "/app"; 630 | await createTextEditorModal( 631 | newDirectoryPath + 632 | experimentPath + 633 | "/variants" + 634 | "/" + 635 | filePath + 636 | "/page.js" 637 | ); 638 | 639 | // const data = fs.readFileSync(filePath) 640 | 641 | console.log("hi"); 642 | } 643 | 644 | //Gets the Repo from Local DB 645 | async function handleGetRepo(event, repoId) { 646 | try { 647 | const repo = await prisma.Repos.findFirst({ 648 | where: { id: repoId }, 649 | }); 650 | console.log(repo); 651 | return repo; 652 | } catch (err) { 653 | console.log(err); 654 | } 655 | } 656 | 657 | async function handleCloseModal(event, value) { 658 | try { 659 | const { data, filePath } = value; 660 | console.log(data); 661 | fs.writeFile(filePath, data, (err) => console.log(err)); 662 | childWindow.close(); 663 | } catch (err) { 664 | console.log(err); 665 | } 666 | } 667 | 668 | async function handleRemoveVariant(event, value) { 669 | const { filePath } = value; 670 | console.log("reached removeVariant. Variant path to remove " + filePath); 671 | try { 672 | const { data, filePath } = value; 673 | console.log(data); 674 | // query the database to get all IDs for a given filepath 675 | const variantObj = await prisma.variants.findMany({ 676 | where: { 677 | filePath: filePath, 678 | }, 679 | }); 680 | 681 | await prisma.variants.delete({ 682 | where: { 683 | id: variantObj[0].id, 684 | }, 685 | }); 686 | 687 | // remove from database 688 | axios.delete("https://nimblebackend-te9u.onrender.com/deleteVariant", { 689 | variant_id: variantObj[0].variantUuid, 690 | }); 691 | } catch (err) { 692 | console.log(err); 693 | } 694 | } 695 | //Event Listeners for Client Side Actions 696 | ipcMain.handle("dialog:openFile", handleFileOpen); 697 | ipcMain.handle("directory:parsePaths", handleDirectoryPaths); 698 | ipcMain.handle("experiment:getExperiments", handleGetExperiments); 699 | ipcMain.handle("modal:createModal", handleCreateTextEditor); 700 | ipcMain.handle("modal:closeModal", handleCloseModal); 701 | // Database API 702 | ipcMain.handle("database:addExperiment", handleAddExperiment); 703 | ipcMain.handle("database:addVariant", handleAddVariant); 704 | ipcMain.handle("database:getVariants", handleGetVariants); 705 | ipcMain.handle("database:addRepo", handleAddRepo); 706 | ipcMain.handle("database:getRepo", handleGetRepo); 707 | ipcMain.handle("database:removeVariant", handleRemoveVariant); 708 | 709 | //File System API 710 | ipcMain.on("save-file", async (_event, value) => { 711 | try { 712 | const { data, filePath } = value; 713 | if (filePath.includes(__dirname)) return; 714 | 715 | console.log(data); 716 | fs.writeFile(filePath, data, (err) => { 717 | if (err) console.log(err); 718 | }); 719 | } catch (err) { 720 | console.log(err); 721 | } 722 | }); 723 | 724 | const isMac = process.platform === "darwin"; 725 | 726 | const template = [ 727 | // { role: 'appMenu' } 728 | ...(isMac 729 | ? [ 730 | { 731 | label: app.name, 732 | submenu: [ 733 | { role: "about" }, 734 | { type: "separator" }, 735 | { role: "services" }, 736 | { type: "separator" }, 737 | { role: "hide" }, 738 | { role: "hideOthers" }, 739 | { role: "unhide" }, 740 | { type: "separator" }, 741 | { role: "quit" }, 742 | ], 743 | }, 744 | ] 745 | : []), 746 | // { role: 'fileMenu' } 747 | { 748 | label: "File", 749 | submenu: [ 750 | isMac ? { role: "close" } : { role: "quit" }, 751 | { 752 | click: () => childWindow.webContents.send("save-file"), 753 | label: "Save", 754 | accelerator: process.platform === isMac ? "Cmd+s" : "Ctrl+s", 755 | }, 756 | ], 757 | }, 758 | // { role: 'editMenu' } 759 | { 760 | label: "Edit", 761 | submenu: [ 762 | { role: "undo" }, 763 | { role: "redo" }, 764 | { type: "separator" }, 765 | { role: "cut" }, 766 | { role: "copy" }, 767 | { role: "paste" }, 768 | ...(isMac 769 | ? [ 770 | { role: "pasteAndMatchStyle" }, 771 | { role: "delete" }, 772 | { role: "selectAll" }, 773 | { type: "separator" }, 774 | { 775 | label: "Speech", 776 | submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], 777 | }, 778 | ] 779 | : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), 780 | ], 781 | }, 782 | // { role: 'viewMenu' } 783 | { 784 | label: "View", 785 | submenu: [ 786 | { role: "reload" }, 787 | { role: "forceReload" }, 788 | { role: "toggleDevTools" }, 789 | { type: "separator" }, 790 | { role: "resetZoom" }, 791 | { role: "zoomIn" }, 792 | { role: "zoomOut" }, 793 | { type: "separator" }, 794 | { role: "togglefullscreen" }, 795 | ], 796 | }, 797 | // { role: 'windowMenu' } 798 | { 799 | label: "Window", 800 | submenu: [ 801 | { role: "minimize" }, 802 | { role: "zoom" }, 803 | ...(isMac 804 | ? [ 805 | { type: "separator" }, 806 | { role: "front" }, 807 | { type: "separator" }, 808 | { role: "window" }, 809 | ] 810 | : [{ role: "close" }]), 811 | ], 812 | }, 813 | { 814 | role: "help", 815 | submenu: [ 816 | { 817 | label: "Learn More", 818 | click: async () => { 819 | const { shell } = require("electron"); 820 | await shell.openExternal("https://nimbleab.io"); 821 | }, 822 | }, 823 | ], 824 | }, 825 | ]; 826 | 827 | const menu = Menu.buildFromTemplate(template); 828 | Menu.setApplicationMenu(menu); 829 | -------------------------------------------------------------------------------- /app/electron/preload.ts: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require("electron"); 2 | 3 | contextBridge.exposeInMainWorld("electronAPI", { 4 | openFile: () => ipcRenderer.invoke("dialog:openFile"), 5 | parsePaths: () => ipcRenderer.invoke("directory:parsePaths"), 6 | createModal: (value) => ipcRenderer.invoke("modal:createModal", value), 7 | addExperiment: (experiment) => 8 | ipcRenderer.invoke("database:addExperiment", experiment), 9 | addVariant: (variant) => ipcRenderer.invoke("database:addVariant", variant), 10 | addRepo: (repo) => ipcRenderer.invoke("database:addRepo", repo), 11 | getExperiments: () => ipcRenderer.invoke("experiment:getExperiments"), 12 | getVariants: (experimentId) => 13 | ipcRenderer.invoke("database:getVariants", experimentId), 14 | getRepo: (repoId) => ipcRenderer.invoke("database:getRepo", repoId), 15 | loadFile: (callback) => ipcRenderer.on("file-path", callback), 16 | saveFile: (callback) => ipcRenderer.on("save-file", callback), 17 | closeFile: (value) => ipcRenderer.invoke("modal:closeModal", value), 18 | removeVariant: (value) => ipcRenderer.invoke("database:removeVariant", value), 19 | }); 20 | -------------------------------------------------------------------------------- /app/electron/prisma.ts: -------------------------------------------------------------------------------- 1 | const { PrismaClient } = require("@prisma/client"); 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | module.exports = prisma; 6 | -------------------------------------------------------------------------------- /app/electron/protocol.js: -------------------------------------------------------------------------------- 1 | /* 2 | Reasonably Secure Electron 3 | Copyright (C) 2021 Bishop Fox 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | ------------------------------------------------------------------------- 10 | Implementing a custom protocol achieves two goals: 11 | 1) Allows us to use ES6 modules/targets for Angular 12 | 2) Avoids running the app in a file:// origin 13 | */ 14 | 15 | const fs = require('fs'); 16 | const path = require('path'); 17 | 18 | const DIST_PATH = path.join(__dirname, '../../app/dist'); 19 | const scheme = 'app'; 20 | 21 | const mimeTypes = { 22 | '.js': 'text/javascript', 23 | '.mjs': 'text/javascript', 24 | '.html': 'text/html', 25 | '.htm': 'text/html', 26 | '.json': 'application/json', 27 | '.css': 'text/css', 28 | '.svg': 'image/svg+xml', 29 | '.ico': 'image/vnd.microsoft.icon', 30 | '.png': 'image/png', 31 | '.jpg': 'image/jpeg', 32 | '.map': 'text/plain', 33 | }; 34 | 35 | function charset(mimeExt) { 36 | return ['.html', '.htm', '.js', '.mjs'].some((m) => m === mimeExt) 37 | ? 'utf-8' 38 | : null; 39 | } 40 | 41 | function mime(filename) { 42 | const mimeExt = path.extname(`${filename || ''}`).toLowerCase(); 43 | const mimeType = mimeTypes[mimeExt]; 44 | return mimeType ? { mimeExt, mimeType } : { mimeExt: null, mimeType: null }; 45 | } 46 | 47 | function requestHandler(req, next) { 48 | const reqUrl = new URL(req.url); 49 | let reqPath = path.normalize(reqUrl.pathname); 50 | if (reqPath === '/') { 51 | reqPath = '/index.html'; 52 | } 53 | const reqFilename = path.basename(reqPath); 54 | fs.readFile(path.join(DIST_PATH, reqPath), (err, data) => { 55 | const { mimeExt, mimeType } = mime(reqFilename); 56 | if (!err && mimeType !== null) { 57 | next({ 58 | mimeType, 59 | charset: charset(mimeExt), 60 | data, 61 | }); 62 | } else { 63 | console.error(err); 64 | } 65 | }); 66 | } 67 | 68 | module.exports = { 69 | scheme, 70 | requestHandler, 71 | }; 72 | -------------------------------------------------------------------------------- /app/src/components/activeExperiments.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { IElectronAPI } from '../../../renderer'; 3 | import ExperimentComponent from './experiment'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | const activeExperiment = (): React.JSX.Element => { 7 | const [experiments, setExperiments] = useState([]); 8 | const [receivedExperiments, setReceivedExperiments] = useState(false); 9 | 10 | const getExperiments = async (): Promise => { 11 | //Gets All experiments from DB 12 | const data = await window.electronAPI.getExperiments(); 13 | await setExperiments(JSON.parse(data)); 14 | }; 15 | useEffect(() => { 16 | console.log('running'); 17 | getExperiments(); 18 | setReceivedExperiments(true); 19 | }, []); 20 | let expComp: any = experiments.map((el) => ); 21 | 22 | return ( 23 |
24 |

Active Experiment

25 |

(Name, Path, Edit)

26 |
27 |
28 | {/* */} 29 | 30 | 31 |
{receivedExperiments && expComp}
32 |
33 |

34 |
35 |
36 | {/* */} 37 |
38 | ); 39 | }; 40 | 41 | export default activeExperiment; 42 | -------------------------------------------------------------------------------- /app/src/components/experiment.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import { useSelector } from "react-redux"; 4 | import { IElectronAPI } from "../../../renderer"; 5 | import { updateRepoPath } from "../redux/experimentsSlice"; 6 | interface experimentProps { 7 | data: any; 8 | } 9 | const experiment = ({ data }: experimentProps): React.JSX.Element => { 10 | const [clicked, setClicked] = useState(false); 11 | const [fullFilePath, setFullFilePath] = useState(""); 12 | 13 | // console.log(fullFilePath); 14 | //Display relevant experiment data 15 | const { 16 | Experiment_Name, 17 | experiment_path, 18 | Device_Type, 19 | Repo_id, 20 | experiment_uuid, 21 | } = data; 22 | //When Edit button is clicked take to Config experiment page 23 | async function handleEditClick(): Promise { 24 | const { FilePath } = await window.electronAPI.getRepo(Repo_id); 25 | console.log("File path in the local sqlite db", FilePath); 26 | setFullFilePath(FilePath); 27 | updateRepoPath(FilePath); 28 | setClicked(true); 29 | } 30 | return ( 31 |
32 |
33 |

{Experiment_Name}

34 |
35 |
36 |

{experiment_path}

37 |
38 | 41 | {/* Sends State to /config Page */} 42 | {clicked && ( 43 | 54 | )} 55 |
56 | ); 57 | }; 58 | 59 | export default experiment; 60 | -------------------------------------------------------------------------------- /app/src/components/experimentCreate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { IElectronAPI } from '../../../renderer'; 3 | import { Navigate } from 'react-router-dom'; 4 | import { useSelector } from 'react-redux'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | import axios from 'axios'; 7 | 8 | import SelectPath from './selectPath'; 9 | import { useDispatch } from 'react-redux'; 10 | import { updateFullFilePath } from '../redux/experimentsSlice'; 11 | 12 | const ExperimentCreate = (): React.JSX.Element => { 13 | const Dispatch = useDispatch(); 14 | //Determine if navigate 15 | const [configPage, setConfigPage] = useState(false); 16 | //Opened Directory basename 17 | const [filePath, setFilePath] = useState(''); 18 | //Opened Directotry Full File Path 19 | const [fullFilePath, setFullFilePath] = useState(''); 20 | const fullFilePath_2 = useSelector((state: any) => state.fullFilePath); 21 | //Makes sure Directory is opened 22 | const [allowSelect, setAllowSelect] = useState(true); 23 | //All available paths to run experiment on 24 | const [dirPaths, setDirPaths] = useState([]); 25 | //Name of experiment 26 | const [experimentName, setExperimentName] = useState(''); 27 | //Path Experiment will run on 28 | const [experimentPath, setExperimentPath] = useState(''); 29 | //The experiment ID 30 | const [experimentId, setExperimentId] = useState(uuidv4()); 31 | //The Repo ID 32 | const [repoId, setRepoId] = useState(''); 33 | //Error Message State 34 | const [error, setError] = useState(false) 35 | //Error Didnt Select a Repo 36 | const [repoError, setRepoError] = useState(false) 37 | 38 | async function handleCreateExperiment(): Promise { 39 | //Add Repo and Add Experiment 40 | const repo_data = await window.electronAPI.addRepo({ 41 | FilePath: fullFilePath, 42 | }); 43 | const { id } = repo_data; 44 | // const id = "2" 45 | setRepoId(id); 46 | const data = await window.electronAPI.addExperiment({ 47 | Experiment_name: experimentName, 48 | Device_Type: 'desktop', 49 | Repo_id: id, 50 | experiment_path: experimentPath, 51 | experiment_uuid: experimentId, 52 | directory_path: fullFilePath, 53 | }); 54 | console.log(data) 55 | if (data === 'Experiment Already Created') { 56 | setError(true) 57 | } else{ 58 | console.log('end of new experiment post'); 59 | setConfigPage(true);} 60 | } 61 | 62 | async function handleClick() { 63 | try { 64 | const { basename, fullPath } = await window.electronAPI.openFile(); 65 | setRepoError(false) 66 | console.log(basename); 67 | setFilePath(basename); 68 | console.log('the full path', fullPath); 69 | setFullFilePath(fullPath); 70 | Dispatch(updateFullFilePath(fullPath)); 71 | const paths = await window.electronAPI.parsePaths(); 72 | setDirPaths(paths); 73 | setAllowSelect(false); 74 | } catch(err) { 75 | setRepoError(true) 76 | } 77 | } 78 | 79 | function handleNameChange(name: string): void { 80 | setExperimentName(name); 81 | } 82 | 83 | return ( 84 |
85 |

Create Experiment

86 | handleNameChange(e.target.value)} 90 | placeholder="Experiment Name" 91 | > 92 |
93 | 96 |
97 |

{filePath}

98 |
99 |
100 | 105 | 108 | {error &&

Experiment Path already in use

} 109 | {repoError &&

Make sure to select a valid repo

} 110 | {configPage && ( 111 | 122 | )} 123 |
124 | ); 125 | }; 126 | 127 | export default ExperimentCreate; 128 | -------------------------------------------------------------------------------- /app/src/components/instructions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | const Instructions = (): React.JSX.Element => { 5 | 6 | 7 | return ( 8 |
9 |

Instructions

10 |

You can either create an experiment or edit an existing one!

11 |

1. Name your experiment! Make sure the name relates to the repo you are running the experiment on.

12 |

2. Open a Next JS Project's app or src folder.

13 |

3. Choose a path you wish to run your experiment on. Make sure it is a public facing path!

14 |

4. Lastly, press the Create Experiment button to take you to the variant configuation page.

15 |
16 | ); 17 | }; 18 | 19 | export default Instructions; 20 | -------------------------------------------------------------------------------- /app/src/components/selectPath.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TextField from '@mui/material/TextField'; 3 | import Autocomplete from '@mui/material/Autocomplete'; 4 | 5 | export type SelectPathProps = { 6 | disabled: boolean; 7 | dirPaths: Array; 8 | setExperimentPath: React.Dispatch>; 9 | }; 10 | export default function SelectPath({ 11 | disabled, 12 | dirPaths, 13 | setExperimentPath, 14 | }: SelectPathProps) { 15 | console.log(disabled); 16 | //Store patch in redux 17 | 18 | return ( 19 | { 25 | if (path) setExperimentPath(path); 26 | }} 27 | sx={{ 28 | '& .MuiOutlinedInput-root': { 29 | // border: '1px solid white', 30 | borderRadius: '3', 31 | padding: '2', 32 | }, 33 | '& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline': { 34 | border: '1px solid #eee', 35 | }, 36 | '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { 37 | border: '1px solid #a991f7', 38 | }, 39 | }} // sx={{ width: }} 40 | className="w-96 " 41 | renderInput={(params) => ( 42 | 52 | )} 53 | /> 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const pathNames: paths = { 2 | HOME: '/', 3 | TESTINGCONFIG: '/config', 4 | }; 5 | 6 | export type paths = { 7 | HOME: string; 8 | TESTINGCONFIG: string; 9 | }; 10 | -------------------------------------------------------------------------------- /app/src/core/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AppRoutes from './routes'; 4 | import { HashRouter } from 'react-router-dom'; 5 | import Nav from './nav'; 6 | import { Provider } from 'react-redux'; 7 | import store from '../redux/store'; 8 | // import TestingConfig from "../pages/config/Config"; 9 | 10 | const App = () => { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | export default App; 23 | -------------------------------------------------------------------------------- /app/src/core/nav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { pathNames } from '../constants/routes'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | const Nav = (): React.JSX.Element => { 6 | return ( 7 |
8 |
9 | 10 | Nimble AB 11 | 12 |
13 |
14 | ); 15 | }; 16 | export default Nav; 17 | -------------------------------------------------------------------------------- /app/src/core/routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router'; 3 | import { pathNames } from '../constants/routes'; 4 | import loadable from '@loadable/component'; 5 | import { HashRouter } from 'react-router-dom'; 6 | 7 | // Load bundles asynchronously so that the initial render happens faster 8 | 9 | //To be replaced with our different pages 10 | const Home = loadable( 11 | () => import(/* webpackChunkName: "WelcomeChunk" */ '../pages/home/home') 12 | ); 13 | 14 | const TestingConfig = loadable(() => import('../pages/config/TestingConfig')); 15 | 16 | const AppRoutes = (): React.JSX.Element => { 17 | return ( 18 | // 19 | 20 | }> 21 | }> 22 | 23 | // 24 | ); 25 | }; 26 | 27 | export default AppRoutes; 28 | -------------------------------------------------------------------------------- /app/src/css/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* width */ 6 | ::-webkit-scrollbar { 7 | width: 10px; 8 | } 9 | 10 | /* Track */ 11 | ::-webkit-scrollbar-track { 12 | background: #f1f1f1; 13 | } 14 | 15 | /* Handle */ 16 | ::-webkit-scrollbar-thumb { 17 | background: #888; 18 | border-radius: 5px; 19 | } 20 | 21 | /* Handle on hover */ 22 | ::-webkit-scrollbar-thumb:hover { 23 | background: #555; 24 | } 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './css/styles.css'; 4 | import App from './core/App'; 5 | 6 | const element = document.getElementById('target'); 7 | const root = createRoot(element); 8 | 9 | root.render(); 10 | -------------------------------------------------------------------------------- /app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { Root, createRoot } from 'react-dom/client'; 3 | // import { store, history } from './redux/store'; 4 | import './css/styles.css'; 5 | import App from './core/App'; 6 | 7 | const element = document.getElementById('target'); 8 | const root: Root = createRoot(element!); 9 | 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /app/src/loading/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const loading = () : React.JSX.Element => { 4 | 5 | return ( 6 |
7 |

Nimble AB

8 |
9 | ) 10 | } 11 | 12 | export default loading; 13 | -------------------------------------------------------------------------------- /app/src/loading/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/loading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '../css/styles.css'; 4 | import Loading from './components/loading'; 5 | const element = document.getElementById('target'); 6 | const root = createRoot(element); 7 | 8 | root.render(); 9 | 10 | -------------------------------------------------------------------------------- /app/src/modal/components/editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import Editor from '@monaco-editor/react'; 3 | 4 | const Editors = (): React.JSX.Element => { 5 | const [filePath, setFilePath] = useState(''); 6 | const [fileText, setFileText] = useState('') 7 | const textRef = useRef('') 8 | 9 | const handleEditorChange = (editor:any, monaco:any) => { 10 | textRef.current = editor 11 | } 12 | 13 | const handleClose = () => { 14 | window.electronAPI.closeFile({data: textRef.current, filePath}); 15 | } 16 | 17 | 18 | //Receives file to load from Main Proccess 19 | window.electronAPI.loadFile((_event:any, value:any)=>{ 20 | const {data, filePath} = value; 21 | setFilePath(filePath); 22 | setFileText(new TextDecoder().decode(data)); 23 | textRef.current = new TextDecoder().decode(data); 24 | }) 25 | 26 | //Talks with main proccess to Save updated Text 27 | window.electronAPI.saveFile((_event:any, value:any)=>{ 28 | console.log("Saving") 29 | _event.sender.send('save-file',{data: textRef.current, filePath}) 30 | setFileText(fileText) 31 | }) 32 | 33 | return ( 34 | 35 |
36 |

Variant Editor

37 | 46 | 47 |
48 | 49 | 50 | ); 51 | }; 52 | 53 | export default Editors; 54 | -------------------------------------------------------------------------------- /app/src/modal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/modal/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '../css/styles.css'; 4 | import Editor from './components/editor'; 5 | const element = document.getElementById('target'); 6 | const root = createRoot(element); 7 | 8 | root.render(); 9 | -------------------------------------------------------------------------------- /app/src/pages/config/ConfigureVariantComponents/ConfigureVariant.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import VariantRow from "./VariantRow"; 3 | import SubmitVariant from "./SubmitVariant"; 4 | 5 | const ConfigureVariant = () => { 6 | // state value to show the config or not 7 | const [show, showUnshow] = useState(false); 8 | const variantObj = {}; 9 | const variantContext = React.createContext({}); 10 | 11 | const handleSubmit = async () => { 12 | // when we submit variant we should add a variant to local, close off the row. The table component will read the state change and update 13 | show; 14 | // add to local 15 | window.electronAPI.addVariant(variantObj); 16 | }; 17 | // 18 | return ( 19 | 20 |
21 | 27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default ConfigureVariant; 34 | -------------------------------------------------------------------------------- /app/src/pages/config/ConfigureVariantComponents/SubmitVariant.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useContext } from "react"; 3 | import { useLocation } from "react-router-dom"; 4 | import axios from "axios"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { experimentContext } from "../TestingConfig"; 7 | interface VariantProps { 8 | // deviceType: string; 9 | weight: number | null; 10 | filePath: string; 11 | experiment_ID: number | string; 12 | } 13 | 14 | const SubmitVariant: React.FC = (props) => { 15 | const [variant, updateVariant] = useState({}); 16 | 17 | // let reload 18 | const { reload } = useContext(experimentContext); 19 | 20 | const location = useLocation(); 21 | 22 | const submitToDB = async () => { 23 | const variantUuid = uuidv4(); 24 | const { 25 | directoryPath, 26 | experimentId, 27 | experimentPath, 28 | fullFilePath, 29 | repoId, 30 | } = location.state; 31 | console.log("the state", location.state); 32 | try { 33 | console.log(props.filePath); 34 | const variantObj = { 35 | filePath: props.filePath, 36 | weight: props.weight, 37 | experimentId: props.experiment_ID, 38 | experiment_uuid: experimentId, 39 | experimentPath, 40 | directoryPath, 41 | variantUuid, 42 | }; 43 | await window.electronAPI.addVariant(variantObj); 44 | console.log("variant added"); 45 | reload(); 46 | } catch (error) { 47 | console.log("error in the Submit Variant component ", error); 48 | } 49 | return; 50 | }; 51 | 52 | return ( 53 |
54 | 57 |
58 | ); 59 | }; 60 | 61 | export default SubmitVariant; 62 | -------------------------------------------------------------------------------- /app/src/pages/config/ConfigureVariantComponents/VariantRow.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState, useContext } from "react"; 2 | import axios from "axios"; 3 | import { createClient } from "@supabase/supabase-js"; 4 | import CreateVariant from "../VariantDisplayComponents/EditVariant"; 5 | import SubmitVariant from "./SubmitVariant"; 6 | import { experimentContext } from "../TestingConfig"; 7 | 8 | interface Row { 9 | variantURL: string; 10 | weight: number | null; 11 | deviceType: string; 12 | } 13 | 14 | const VariantRow: React.FC = () => { 15 | // set up state with the row's data 16 | const [thisRow, setThisRow] = useState({ 17 | variantURL: "", 18 | weight: null, 19 | deviceType: "", 20 | }); 21 | 22 | let directoryPath: string = ""; 23 | let experimentName: string = ""; 24 | let experimentPath: string = ""; 25 | let experimentId: string = ""; 26 | const context = useContext(experimentContext); 27 | if (context) { 28 | directoryPath = context.directoryPath; 29 | experimentName = context.experimentName; 30 | experimentPath = context.experimentPath; 31 | experimentId = context.experimentId; 32 | } 33 | 34 | const [isDestroyed, setIsDestroyed] = useState(false); 35 | 36 | // handle text change in the variant input 37 | const handleVariantChange = (e: ChangeEvent) => { 38 | const { weight, deviceType } = thisRow; 39 | const text = e.target.value; 40 | setThisRow({ 41 | variantURL: text, 42 | weight: weight, 43 | deviceType: deviceType, 44 | }); 45 | }; 46 | 47 | const handleWeightChange = (e: ChangeEvent) => { 48 | const { variantURL, deviceType } = thisRow; 49 | const weight = parseFloat(e.target.value); 50 | if (!isNaN(weight)) { 51 | setThisRow({ 52 | variantURL: variantURL, 53 | weight: weight, 54 | deviceType: deviceType, 55 | }); 56 | } 57 | }; 58 | 59 | const handleDestroy = () => { 60 | setIsDestroyed(true); 61 | }; 62 | 63 | const handleSubmit = () => { 64 | console.log("insert database logic here"); 65 | }; 66 | 67 | // check for is destroyed in the FC itself, render null if so 68 | if (isDestroyed) return null; 69 | 70 | return ( 71 |
72 | 79 | 86 | 87 | 92 |
93 | ); 94 | }; 95 | 96 | export default VariantRow; 97 | -------------------------------------------------------------------------------- /app/src/pages/config/TestConfigInstructions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const TestConfigInstructions = (): React.JSX.Element => { 4 | return ( 5 |
6 |

Instructions

7 |

8 | 9 | Configure a variant above. Add a file path name and a weight 10 | 11 |

12 |

13 | Weights represent the percentage of page loads that should show a 14 | variant. 15 |

16 |

17 | The file path selected will save to your local. Make it semantically 18 | relevant to the changes 19 |

20 |

21 | Click edit to open the code editor to make the changes required for your 22 | variant. Save when finished and the updates will write to your repo 23 |

24 |

25 | When finished with a valid experiment your local will have a variants 26 | folder containing all created variants, and a config.json middleware 27 | file that will return variants according to the weights and set a cookie 28 | to give return visitors a consistent experience 29 |

30 |
31 | ); 32 | }; 33 | 34 | export default TestConfigInstructions; 35 | -------------------------------------------------------------------------------- /app/src/pages/config/TestingConfig.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import axios from "axios"; 4 | import { createClient } from "@supabase/supabase-js"; 5 | import VariantRow from "./ConfigureVariantComponents/VariantRow"; 6 | import CreateVariant from "./VariantDisplayComponents/EditVariant"; 7 | import { PrismaClient } from "@prisma/client"; 8 | import { IElectronAPI } from "../../../../renderer"; 9 | import { createContext, useEffect } from "react"; 10 | import VariantDisplay from "./VariantDisplayComponents/VariantDisplay"; 11 | import exp from "constants"; 12 | import { v4 as uuidv4 } from "uuid"; 13 | import { useLocation } from "react-router-dom"; 14 | import { UseSelector } from "react-redux/es/hooks/useSelector"; 15 | import ExperimentDropDown from "./Unused/ExperimentDropDown"; 16 | import ConfigureVariant from "./ConfigureVariantComponents/ConfigureVariant"; 17 | import { useSelector } from "react-redux"; 18 | import { RootState } from "../../redux/store"; 19 | import TestConfigInstructions from "./TestConfigInstructions"; 20 | 21 | interface RowProps { 22 | index: number; 23 | } 24 | 25 | export interface Variant { 26 | filePath: string; 27 | weight: number; 28 | deviceType: string; 29 | } 30 | 31 | interface Experiment { 32 | name: string; 33 | variants: []; 34 | } 35 | 36 | interface ExperimentContextType { 37 | experimentId: any; 38 | experimentPath: string; 39 | repoId: string | number; 40 | directoryPath: string; 41 | experimentName: string; 42 | reload: () => void; 43 | } 44 | 45 | export const experimentContext = createContext({ 46 | experimentId: "", 47 | experimentPath: "", 48 | repoId: "", 49 | directoryPath: "", 50 | experimentName: "", 51 | // there is a downstream typescript problem that requires a function to be placed here. Placeholder added. Go easy on us... 52 | reload: () => { 53 | return 1; 54 | }, 55 | }); 56 | 57 | const TestingConfig: React.FC = () => { 58 | // declare state variables 59 | const [rows, setRows] = useState[]>([]); 60 | const [totalWeight, setTotalWeight] = useState(0); 61 | const [variants, setVariants] = useState([]); 62 | const [experimentObj, updateExperimentObj] = useState({}); 63 | const [reset, causeReset] = useState(false); 64 | const [resetFlag, setResetFlag] = useState(false); 65 | // use Redux for the repo path 66 | const repoPath = useSelector( 67 | (state: RootState) => state.experiments.repoPath 68 | ); 69 | 70 | const changeHandler = () => { 71 | setResetFlag((prevResetFlag) => !prevResetFlag); 72 | console.log("Reached the change handler"); 73 | }; 74 | // get state data sent from the home page 75 | const location = useLocation(); 76 | const { 77 | experimentName, 78 | experimentPath, 79 | repoId, 80 | experimentId, 81 | directoryPath, 82 | } = location.state; 83 | 84 | // get variants data from the server 85 | const getVariants = async (id: number | string) => { 86 | try { 87 | // async call to the local server 88 | const variantsString = await window.electronAPI.getVariants(experimentId); 89 | // returned as a JSON formatted string; parse out 90 | const rawVariants = JSON.parse(variantsString); 91 | // the server returns an object with an array of length 1 containing an array of objects. This is due Prisma default formatting. Assign the variants variable this array of variant objects 92 | const variants = rawVariants[0].Variants; 93 | // generate the meaningful data we want 94 | const newVariants = variants.map((variant: any) => ({ 95 | filePath: variant.filePath, 96 | weight: variant.weights, 97 | deviceType: variant.deviceType, // deprecated ; removal of variant-level references to device type is an opp to address tech debt 98 | })); 99 | 100 | setVariants(newVariants); // Update the variants state 101 | } catch (error) { 102 | console.error("An error occurred:", error); 103 | } 104 | }; 105 | 106 | // component functionality: get experiment if exists on user's local 107 | async function getExperimentdata() { 108 | const experimentData = await window.electronAPI.getExperiments(); 109 | // if experiment data is falsy, inform the user. This indicates larger breakage 110 | if (!experimentData) { 111 | alert( 112 | "No experiment was found - please contact Nimble Team with bug report" 113 | ); 114 | } else { 115 | console.log("Returned the experiment data"); 116 | return experimentData; 117 | } 118 | } 119 | 120 | async function main() { 121 | try { 122 | console.log(repoPath + " if there's a file path, redux is working"); 123 | const experimentObjectString = await getExperimentdata(); 124 | 125 | const experimentObject = JSON.parse(experimentObjectString); 126 | getVariants(experimentId); 127 | } catch (error) { 128 | console.error("An error occurred:", error); 129 | } 130 | } 131 | 132 | useEffect(() => { 133 | main(); 134 | }, [resetFlag]); 135 | 136 | // getVariants(experimentId); 137 | //use effect to listen out for updates to variant rows 138 | // rounded-xl w-1/2 h-96 bg-slate-800 text-white p-2 flex flex-col items-center 139 | return ( 140 |
141 | {" "} 142 | 152 |
153 |
154 | {experimentName ? ( 155 |

156 | Configuration for experiment

{" "} 157 | {experimentName} 158 |

159 | ) : ( 160 | "No experiment active; return to home and create new" 161 | )} 162 | 163 |
164 | 165 |
166 | 167 |
168 |
169 | ); 170 | }; 171 | 172 | export default TestingConfig; 173 | -------------------------------------------------------------------------------- /app/src/pages/config/Unused/DatabaseClear.tsx: -------------------------------------------------------------------------------- 1 | // stretch: feature flag 2 | // add a DB clear button to clear test data 3 | -------------------------------------------------------------------------------- /app/src/pages/config/Unused/ExperimentDropDown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useEffect } from "react"; 3 | 4 | const ExperimentDropDown: React.FC = () => { 5 | // this component needs to render a drop down of all experience names on the user's local. 6 | // pull the experiments from local, save into array 7 | 8 | // initialize state values for open and closed drop down 9 | const [open, setOpen] = useState(false); 10 | const [experimentNames, setExperimentNames] = useState([]); 11 | // an open handler for the dropdown click 12 | const handleOpen = () => { 13 | setOpen(!open); 14 | }; 15 | 16 | useEffect(() => { 17 | const getExperiments = async () => { 18 | try { 19 | const experiments = await window.electronAPI.getExperiments(); 20 | setExperimentNames(experiments); 21 | console.log(experimentNames, " are the experiment names"); 22 | } catch (Error) { 23 | // no verbose messaging because we are logging to client 24 | console.log("Error fetching experiments"); 25 | } 26 | }; 27 | }, [open]); 28 | 29 | // return a button and drop down 30 | 31 | return ( 32 |
33 | 39 | {open ? ( 40 |
41 |

Is Open

42 | 50 |
51 | ) : ( 52 |
Is Closed
53 | )} 54 |
55 | ); 56 | }; 57 | 58 | export default ExperimentDropDown; 59 | -------------------------------------------------------------------------------- /app/src/pages/config/Unused/NameExperiment.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { 4 | updateExperimentName, 5 | updateExperimentId, 6 | } from "../../../redux/experimentsSlice"; 7 | 8 | interface RootState { 9 | experimentName: string; 10 | experimentId: number; 11 | repoPath: string; 12 | } 13 | const NameExperiment: React.FC = () => { 14 | const Dispatch = useDispatch(); 15 | const [name, updateName] = useState(""); 16 | // const Selector = useSelector() 17 | 18 | const storageName = useSelector((state: RootState) => state.experimentName); 19 | const handleChange = (e: ChangeEvent) => { 20 | Dispatch(updateExperimentName(e.target.value)); 21 | // console.log(useSelector((state: RootState) => state.experimentName)); 22 | console.log(storageName); 23 | }; 24 | 25 | const handleExpSubmit = async () => { 26 | // schema definition as of right now: const {experimentName, deviceType} = experiment 27 | const experiment = { 28 | experimentName: useSelector((state: RootState) => state.experimentName), 29 | // CHANGE THIS LATER 30 | deviceType: "desktop", 31 | }; 32 | if (experiment) await window.electronAPI.addExperiment(experiment); 33 | // await window.electronAPI.addExperiment("new Experiment"); 34 | else alert("experiment must have a name"); 35 | }; 36 | return ( 37 |
38 | 39 | 40 |
41 | ); 42 | }; 43 | 44 | export default NameExperiment; 45 | -------------------------------------------------------------------------------- /app/src/pages/config/VariantDisplayComponents/DeleteVariant.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, createContext } from "react"; 2 | import { experimentContext } from "../TestingConfig"; 3 | interface DeleteProps { 4 | filePath: string; 5 | } 6 | const DeleteVariant: React.FC = (props) => { 7 | const [variantPath, setVariantPath] = useState(""); 8 | 9 | const { reload } = useContext(experimentContext); 10 | 11 | let directoryPath: string = ""; 12 | let experimentName: string = ""; 13 | let experimentPath: string = ""; 14 | let experimentId: string = ""; 15 | const context = useContext(experimentContext); 16 | if (context) { 17 | directoryPath = context.directoryPath; 18 | experimentName = context.experimentName; 19 | experimentPath = context.experimentPath; 20 | experimentId = context.experimentId; 21 | } 22 | 23 | const handleClick = async () => { 24 | // open the modal 25 | console.log("opened the modal"); 26 | // pass down directory, experiment path, filepath 27 | await window.electronAPI.removeVariant({ 28 | filePath: props.filePath, 29 | }); 30 | reload(); 31 | }; 32 | 33 | return ( 34 |
35 | 38 |
39 | ); 40 | }; 41 | 42 | export default DeleteVariant; 43 | -------------------------------------------------------------------------------- /app/src/pages/config/VariantDisplayComponents/EditVariant.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, createContext } from "react"; 2 | interface VariantProps { 3 | experimentID: string; 4 | directoryPath: string; 5 | experimentPath: string; 6 | filePath: string; 7 | } 8 | 9 | const CreateVariant: React.FC = (props) => { 10 | const [variantPath, setVariantPath] = useState(""); 11 | 12 | // get the "repo" path 13 | 14 | const createVariant = async () => { 15 | const variant = { 16 | filePath: variantPath, 17 | experimentId: props.experimentID, 18 | }; 19 | console.log("insert the create variant code"); 20 | return; 21 | }; 22 | 23 | const handleClick = async () => { 24 | // open the modal 25 | console.log("opened the modal"); 26 | // pass down directory, experiment path, filepath 27 | await window.electronAPI.createModal({ 28 | experimentPath: props.experimentPath, 29 | directoryPath: props.directoryPath, 30 | filePath: props.filePath, 31 | }); 32 | }; 33 | 34 | return ( 35 |
36 | 39 |
40 | ); 41 | }; 42 | 43 | export default CreateVariant; 44 | -------------------------------------------------------------------------------- /app/src/pages/config/VariantDisplayComponents/VariantDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react"; 2 | import { Variant } from "../TestingConfig"; 3 | import CreateVariant from "./EditVariant"; 4 | import { experimentContext } from "../TestingConfig"; 5 | import DeleteVariant from "./DeleteVariant"; 6 | 7 | interface VariantProps { 8 | variant: Variant[]; 9 | } 10 | 11 | const VariantDisplay: React.FC = ({ variant }) => { 12 | const [weightsWarning, setWeightsWarning] = useState(false); 13 | const [variants, setDisplayVariants] = useState([]); 14 | const [reset, causeReset] = useState(false); 15 | 16 | let directoryPath: string = ""; 17 | let experimentName: string = ""; 18 | let experimentPath: string = ""; 19 | let experimentId: string = ""; 20 | const context = useContext(experimentContext); 21 | if (context) { 22 | directoryPath = context.directoryPath; 23 | experimentName = context.experimentName; 24 | experimentPath = context.experimentPath; 25 | experimentId = context.experimentId; 26 | } 27 | // get the variant data to display 28 | useEffect(() => { 29 | setDisplayVariants(variant); 30 | 31 | const totalWeight = variant.reduce( 32 | (sum, currentVariant) => sum + currentVariant.weight, 33 | 0 34 | ); 35 | if (totalWeight != 100) { 36 | setWeightsWarning(true); 37 | } 38 | }, [variant]); 39 | 40 | return ( 41 |
42 | {/*

Variants

*/} 43 | 44 | 45 | 46 | 52 | 58 | 64 | 70 | 71 | 72 | 73 | {variants.map((variant, index) => ( 74 | 75 | 76 | 77 | 85 | 88 | 89 | ))} 90 | 91 |
50 | Variants 51 | 56 | Weight 57 | 62 | Edit 63 | 68 | Delete 69 |
{variant.filePath}{variant.weight} 78 | 84 | 86 | 87 |
92 | {weightsWarning ? ( 93 |
94 |

95 | Warning - weights must sum to 100 for experiment to be valid 96 |

97 |
98 | ) : ( 99 |
100 | )} 101 |
102 | ); 103 | }; 104 | 105 | export default VariantDisplay; 106 | -------------------------------------------------------------------------------- /app/src/pages/home/home.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ExperimentCreate from "../../components/experimentCreate"; 3 | import ActiveExperiment from "../../components/activeExperiments"; 4 | import Instructions from "../../components/instructions"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | 7 | const Home = (): React.JSX.Element => { 8 | const setUserId = () => { 9 | // check to see if user ID cookie exists, if yes then do nothing, if no then set a new one // check to see if user ID cookie exists, if yes then do nothing, if no then set a new one 10 | const userIdCookie = document.cookie.includes("user_id"); 11 | if (!userIdCookie) { 12 | const newUserId = uuidv4(); 13 | document.cookie = "user_id=" + newUserId; 14 | } 15 | }; 16 | return ( 17 | <> 18 |
19 |
20 | 21 | 22 |
23 | 24 |
25 | 26 | ); 27 | }; 28 | 29 | export default Home; 30 | -------------------------------------------------------------------------------- /app/src/redux/experimentsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const initialState = { 4 | experimentId: null, 5 | experimentName: '', 6 | repoPath: '/', 7 | fullFilePath: '', 8 | }; 9 | const experimentSlice = createSlice({ 10 | name: 'experiment', 11 | initialState, 12 | reducers: { 13 | updateExperimentId: (state, action) => { 14 | state.experimentId = action.payload; 15 | }, 16 | updateExperimentName: (state, action) => { 17 | state.experimentName = action.payload; 18 | }, 19 | updateRepoPath: (state, action) => { 20 | state.repoPath = action.payload; 21 | }, 22 | updateFullFilePath: (state, action) => { 23 | state.fullFilePath = action.payload; 24 | }, 25 | }, 26 | }); 27 | 28 | // // Export actions 29 | export const { 30 | updateExperimentId, 31 | updateExperimentName, 32 | updateRepoPath, 33 | updateFullFilePath, 34 | } = experimentSlice.actions; 35 | 36 | // // Export reducer 37 | export default experimentSlice.reducer; 38 | -------------------------------------------------------------------------------- /app/src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import experimentsReducer from './experimentsSlice'; 3 | 4 | const store = configureStore({ 5 | reducer: { 6 | experiments: experimentsReducer, 7 | }, 8 | }); 9 | 10 | export default store; 11 | 12 | export type RootState = ReturnType; 13 | export type AppDispatch = typeof store.dispatch; 14 | -------------------------------------------------------------------------------- /app/templates/middleware.ts: -------------------------------------------------------------------------------- 1 | // importing required modules and types from the 'next/server' package 2 | import { NextRequest, NextResponse, userAgent } from 'next/server'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | // importing the variants config from the JSON file 5 | import variantsConfig from './nimble.config.json'; 6 | import { NextURL } from 'next/dist/server/web/next-url'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | import { ChildProcess } from 'child_process'; 9 | 10 | // initialize Supabase client - https://supabase.com/docs/reference/javascript/initializing 11 | const supabaseUrl = 'https://tawrifvzyjqcddwuqjyq.supabase.co'; 12 | const supabaseKey = 13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRhd3JpZnZ6eWpxY2Rkd3VxanlxIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTI2NTc2MjcsImV4cCI6MjAwODIzMzYyN30.-VekGbd6Iwey0Q32SQA0RxowZtqSlDptBhlt2r-GZBw'; 14 | const supabase = createClient(supabaseUrl, supabaseKey); 15 | 16 | //initialize experiment - only input f 17 | // const experiment = variants.filter("exper") 18 | 19 | // defining a type for the variant with properties: id, fileName, and weight 20 | 21 | type Variant = { 22 | id: string; 23 | fileName: string; 24 | weight: number; 25 | // experiment_id: string; 26 | }; 27 | 28 | // export const config = { 29 | // matcher: '/blog', //experiment path 30 | // }; 31 | 32 | // middleware function that determines which variant to serve based on device type and possibly cookie values 33 | export async function middleware(req: NextRequest) { 34 | // extract the device details from the user agent of the request - https://nextjs.org/docs/messages/middleware-parse-user-agent 35 | const {ua} = userAgent(req); 36 | // console.log(data) 37 | function mobile_check(a){ 38 | if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) return true; 39 | else return false 40 | }; 41 | 42 | // determine the device type, whether it's mobile or desktop 43 | const deviceType = mobile_check(ua) === true ? 'mobile' : 'desktop'; 44 | 45 | const url = req.nextUrl; 46 | const currentPath = url.pathname; 47 | 48 | // find the experiment configuration for the current path 49 | const experimentConfig = variantsConfig.find( 50 | (config) => config.experiment_path === currentPath 51 | ); 52 | 53 | // if no experiment configuration found for the current path, return the URL without any changes 54 | if (!experimentConfig || experimentConfig.device_type !== deviceType) { 55 | return NextResponse.rewrite(url); 56 | } 57 | 58 | // function to choose a variant based on device type and weights of available variants 59 | function chooseVariant( 60 | deviceType: 'mobile' | 'desktop', 61 | variants: Variant[] 62 | ): Variant { 63 | // calculate the total weight of all variants 64 | let totalWeight = variants.reduce((sum, v) => sum + v.weight, 0); 65 | 66 | // generate a random value within the range of the total weight 67 | let randomValue = Math.random() * totalWeight; 68 | 69 | // loop through variants to find a matching variant based on its weight 70 | for (const variant of variants) { 71 | if (randomValue < variant.weight) { 72 | return variant; 73 | } 74 | randomValue -= variant.weight; 75 | } 76 | 77 | // default to the first variant if no variant is matched 78 | return variants[0]; 79 | } 80 | 81 | // // check for existing cookie 82 | // const expVariantID = req.cookies.get('expVariantID')?.value; 83 | 84 | // // choose an experiment and then a variant inside the experiment 85 | // const experiment = variantsConfig.filter( 86 | // (experiments) => experiments.experiment_name === 'test1' 87 | // ); 88 | 89 | // const experimentId = experiment[0].experiment_id; //change string based on test name 90 | // // console.log(experimentId); 91 | 92 | const experimentId = experimentConfig.experiment_id; 93 | const expVariantID = req.cookies.get('expVariantID')?.value; 94 | 95 | // prioritize experiment selection via query parameter 96 | // first check if a variant has been selected based on the expVariantID cookie 97 | // if not, then choose a variant based on the device type and the weights of the available variants 98 | 99 | let chosenExperiment: string = expVariantID 100 | ? expVariantID?.split('_')[0] 101 | : experimentId; 102 | // console.log('chosenExperiment :>> ', chosenExperiment); 103 | 104 | async function getVariant( 105 | experimentConfig: any, 106 | varID: string 107 | ): Promise { 108 | // console.log(experiment[0].variants); 109 | // return experiment[0].variants.filter((variant) => variant.id === varID)[0]; 110 | return experimentConfig.variants.filter( 111 | (variant: { id: string }) => variant.id === varID 112 | )[0]; 113 | } 114 | // if (expVariantID) console.log(getVariant(expVariantID?.split('_')[1])); 115 | 116 | // let chosenVariant: Variant = expVariantID 117 | // ? await getVariant(expVariantID.split('_')[1]) 118 | // : chooseVariant(deviceType, experiment[0].variants); 119 | 120 | let chosenVariant: Variant = expVariantID 121 | ? await getVariant(experimentConfig, expVariantID.split('_')[1]) 122 | : chooseVariant(deviceType, experimentConfig.variants); 123 | 124 | // console.log('chosenVariant :>> ', chosenVariant); 125 | // asynchronously call the increment RPC function in Supabase without waiting for it to complete 126 | // create a separate static_variants table and static_increment function for the staticConfig (https://supabase.com/dashboard/project/tawrifvzyjqcddwuqjyq/database/functions) per https://www.youtube.com/watch?v=n5j_mrSmpyc 127 | 128 | 129 | 130 | supabase 131 | .rpc('increment', { row_id: chosenVariant.id }) 132 | .then(({ data, error }) => { 133 | if (error) { 134 | console.error('Error incrementing variant count:', error); 135 | } else { 136 | console.log(data); 137 | } 138 | }); 139 | 140 | // rewrite the request to serve the chosen variant's file 141 | 142 | // const url = req.nextUrl; 143 | // url.pathname = url.pathname.replace( 144 | // '/blog', 145 | // `/blog/${chosenVariant.fileName}` 146 | // ); 147 | 148 | url.pathname = url.pathname.replace( 149 | currentPath, 150 | `${currentPath}/variants/${chosenVariant.fileName}` 151 | ); 152 | 153 | const res = NextResponse.rewrite(url); 154 | 155 | // if the variant ID doesn't exist in the cookies, set it now for future requests 156 | if (!expVariantID) { 157 | res.cookies.set('expVariantID', `${experimentId}_${chosenVariant.id}`, { 158 | path: '/', 159 | httpOnly: true, 160 | maxAge: 10 * 365 * 24 * 60 * 60, // set the cookie to expire in 10 years 161 | }); 162 | } 163 | 164 | // return the response with the rewritten url or any set cookies 165 | return res; 166 | } 167 | 168 | -------------------------------------------------------------------------------- /app/templates/nimble.config.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | ] 4 | -------------------------------------------------------------------------------- /app/templates/staticConfig.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "experiment_path": "/blog", 4 | "experiment_name": "test1", 5 | "experiment_id": "59f734ad-078e-4665-a783-537af9f92bf4", 6 | "device_type": "mobile", 7 | "variants": [ 8 | { 9 | "id": "05292789-0f23-4b86-b940-d8845038607e", 10 | "fileName": "1", 11 | "weight": 33 12 | }, 13 | { 14 | "id": "82846299-fa14-4bee-a170-134d5e9d77ee", 15 | "fileName": "2", 16 | "weight": 33 17 | }, 18 | { 19 | "id": "9d323bda-3ddd-4f8d-9397-0fdee2fb4108", 20 | "fileName": "3", 21 | "weight": 33 22 | } 23 | ] 24 | }, 25 | { 26 | "experiment_path": "/home", 27 | "experiment_name": "test2", 28 | "experiment_id": "test2id", 29 | "device_type": "mobile", 30 | "variants": [ 31 | { 32 | "id": "1", 33 | "fileName": "variant1.html", 34 | "weight": 50 35 | }, 36 | { 37 | "id": "2", 38 | "fileName": "variant2.html", 39 | "weight": 30 40 | }, 41 | { 42 | "id": "3", 43 | "fileName": "variant3.html", 44 | "weight": 20 45 | } 46 | ] 47 | } 48 | ] 49 | -------------------------------------------------------------------------------- /dev-scripts/launchDevServer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { exec } = require('child_process'); 3 | const logFilePath = './dev-scripts/webpack-dev-server.log'; 4 | const errorLogFilePath = './dev-scripts/webpack-dev-server-error.log'; 5 | const interval = 100; 6 | const showHint = 600 * 3; // show hint after 3 minutes (60 sec * 3) 7 | let hintCounter = 1; 8 | 9 | // Poll webpack-dev-server.log until the webpack bundle has compiled successfully 10 | const intervalId = setInterval(function () { 11 | try { 12 | if (fs.existsSync(logFilePath)) { 13 | const log = fs.readFileSync(logFilePath, { 14 | encoding: 'utf8', 15 | }); 16 | 17 | // "compiled successfully" is the string we need to find 18 | // to know that webpack is done bundling everything and we 19 | // can load our Electron app with no issues. We split up the 20 | // validation because the output contains non-standard characters. 21 | const compiled = log.indexOf('compiled'); 22 | if (compiled >= 0 && log.indexOf('successfully', compiled) >= 0) { 23 | console.log( 24 | 'Webpack development server is ready, launching Electron app.' 25 | ); 26 | clearInterval(intervalId); 27 | 28 | // Start our electron app 29 | const electronProcess = exec( 30 | 'cross-env NODE_ENV=development electron .' 31 | ); 32 | electronProcess.stdout.on('data', function (data) { 33 | process.stdout.write(data); 34 | }); 35 | electronProcess.stderr.on('data', function (data) { 36 | process.stdout.write(data); 37 | }); 38 | } else if (log.indexOf('Module build failed') >= 0) { 39 | if (fs.existsSync(errorLogFilePath)) { 40 | const errorLog = fs.readFileSync(errorLogFilePath, { 41 | encoding: 'utf8', 42 | }); 43 | 44 | console.log(errorLog); 45 | console.log( 46 | `Webpack failed to compile; this error has also been logged to '${errorLogFilePath}'.` 47 | ); 48 | clearInterval(intervalId); 49 | 50 | return process.exit(1); 51 | } else { 52 | console.log('Webpack failed to compile, but the error is unknown.'); 53 | clearInterval(intervalId); 54 | 55 | return process.exit(1); 56 | } 57 | } else { 58 | hintCounter++; 59 | 60 | // Show hint so user is not waiting/does not know where to 61 | // look for an error if it has been thrown and/or we are stuck 62 | if (hintCounter > showHint) { 63 | console.error( 64 | `Webpack is likely failing for an unknown reason, please check '${errorLogFilePath}' for more details.` 65 | ); 66 | clearInterval(intervalId); 67 | 68 | return process.exit(1); 69 | } 70 | } 71 | } 72 | } catch (error) { 73 | // Exit with an error code 74 | console.error('Webpack or electron fatal error' + error); 75 | clearInterval(intervalId); 76 | 77 | return process.exit(1); 78 | } 79 | }, interval); 80 | -------------------------------------------------------------------------------- /dev-scripts/prepareDevServer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { exec } = require('child_process'); 3 | const logFilePath = './dev-scripts/webpack-dev-server.log'; 4 | const errorLogFilePath = './dev-scripts/webpack-dev-server-error.log'; 5 | 6 | console.log( 7 | `Preparing webpack development server. (Logging webpack output to '${logFilePath}')` 8 | ); 9 | 10 | // Delete the old webpack-dev-server.log if it is present 11 | try { 12 | fs.unlinkSync(logFilePath); 13 | } catch (error) { 14 | // Existing webpack-dev-server log file may not exist 15 | } 16 | 17 | // Delete the old webpack-dev-server-error.log if it is present 18 | try { 19 | fs.unlinkSync(errorLogFilePath); 20 | } catch (error) { 21 | // Existing webpack-dev-server-error log file may not exist 22 | } 23 | 24 | // Start the webpack development server 25 | exec('npm run dev-server'); 26 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/images/icon.png -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | // importing required modules and types from the 'next/server' package 2 | import { NextRequest, NextResponse, userAgent } from 'next/server'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | // importing the variants config from the JSON file 5 | import variantsConfig from './nimble.config.json'; 6 | import { NextURL } from 'next/dist/server/web/next-url'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | import { ChildProcess } from 'child_process'; 9 | 10 | // initialize Supabase client - https://supabase.com/docs/reference/javascript/initializing 11 | const supabaseUrl = 'https://tawrifvzyjqcddwuqjyq.supabase.co'; 12 | const supabaseKey = 13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRhd3JpZnZ6eWpxY2Rkd3VxanlxIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTI2NTc2MjcsImV4cCI6MjAwODIzMzYyN30.-VekGbd6Iwey0Q32SQA0RxowZtqSlDptBhlt2r-GZBw'; 14 | const supabase = createClient(supabaseUrl, supabaseKey); 15 | 16 | //initialize experiment - only input f 17 | // const experiment = variants.filter("exper") 18 | 19 | // defining a type for the variant with properties: id, fileName, and weight 20 | 21 | type Variant = { 22 | id: string; 23 | fileName: string; 24 | weight: number; 25 | // experiment_id: string; 26 | }; 27 | 28 | // export const config = { 29 | // matcher: '/blog', //experiment path 30 | // }; 31 | 32 | // middleware function that determines which variant to serve based on device type and possibly cookie values 33 | export async function middleware(req: NextRequest) { 34 | // extract the device details from the user agent of the request - https://nextjs.org/docs/messages/middleware-parse-user-agent 35 | const {ua} = userAgent(req); 36 | // console.log(data) 37 | function mobile_check(a){ 38 | if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) return true; 39 | else return false 40 | }; 41 | 42 | // determine the device type, whether it's mobile or desktop 43 | const deviceType = mobile_check(ua) === true ? 'mobile' : 'desktop'; 44 | 45 | const url = req.nextUrl; 46 | const currentPath = url.pathname; 47 | 48 | // find the experiment configuration for the current path 49 | const experimentConfig = variantsConfig.find( 50 | (config) => config.experiment_path === currentPath 51 | ); 52 | 53 | // if no experiment configuration found for the current path, return the URL without any changes 54 | if (!experimentConfig || experimentConfig.device_type !== deviceType) { 55 | return NextResponse.rewrite(url); 56 | } 57 | 58 | // function to choose a variant based on device type and weights of available variants 59 | function chooseVariant( 60 | deviceType: 'mobile' | 'desktop', 61 | variants: Variant[] 62 | ): Variant { 63 | // calculate the total weight of all variants 64 | let totalWeight = variants.reduce((sum, v) => sum + v.weight, 0); 65 | 66 | // generate a random value within the range of the total weight 67 | let randomValue = Math.random() * totalWeight; 68 | 69 | // loop through variants to find a matching variant based on its weight 70 | for (const variant of variants) { 71 | if (randomValue < variant.weight) { 72 | return variant; 73 | } 74 | randomValue -= variant.weight; 75 | } 76 | 77 | // default to the first variant if no variant is matched 78 | return variants[0]; 79 | } 80 | 81 | // // check for existing cookie 82 | // const expVariantID = req.cookies.get('expVariantID')?.value; 83 | 84 | // // choose an experiment and then a variant inside the experiment 85 | // const experiment = variantsConfig.filter( 86 | // (experiments) => experiments.experiment_name === 'test1' 87 | // ); 88 | 89 | // const experimentId = experiment[0].experiment_id; //change string based on test name 90 | // // console.log(experimentId); 91 | 92 | const experimentId = experimentConfig.experiment_id; 93 | const expVariantID = req.cookies.get('expVariantID')?.value; 94 | 95 | // prioritize experiment selection via query parameter 96 | // first check if a variant has been selected based on the expVariantID cookie 97 | // if not, then choose a variant based on the device type and the weights of the available variants 98 | 99 | let chosenExperiment: string = expVariantID 100 | ? expVariantID?.split('_')[0] 101 | : experimentId; 102 | // console.log('chosenExperiment :>> ', chosenExperiment); 103 | 104 | async function getVariant( 105 | experimentConfig: any, 106 | varID: string 107 | ): Promise { 108 | // console.log(experiment[0].variants); 109 | // return experiment[0].variants.filter((variant) => variant.id === varID)[0]; 110 | return experimentConfig.variants.filter( 111 | (variant: { id: string }) => variant.id === varID 112 | )[0]; 113 | } 114 | // if (expVariantID) console.log(getVariant(expVariantID?.split('_')[1])); 115 | 116 | // let chosenVariant: Variant = expVariantID 117 | // ? await getVariant(expVariantID.split('_')[1]) 118 | // : chooseVariant(deviceType, experiment[0].variants); 119 | 120 | let chosenVariant: Variant = expVariantID 121 | ? await getVariant(experimentConfig, expVariantID.split('_')[1]) 122 | : chooseVariant(deviceType, experimentConfig.variants); 123 | 124 | // console.log('chosenVariant :>> ', chosenVariant); 125 | // asynchronously call the increment RPC function in Supabase without waiting for it to complete 126 | // create a separate static_variants table and static_increment function for the staticConfig (https://supabase.com/dashboard/project/tawrifvzyjqcddwuqjyq/database/functions) per https://www.youtube.com/watch?v=n5j_mrSmpyc 127 | 128 | 129 | 130 | supabase 131 | .rpc('increment', { row_id: chosenVariant.id }) 132 | .then(({ data, error }) => { 133 | if (error) { 134 | console.error('Error incrementing variant count:', error); 135 | } else { 136 | console.log(data); 137 | } 138 | }); 139 | 140 | // rewrite the request to serve the chosen variant's file 141 | 142 | // const url = req.nextUrl; 143 | // url.pathname = url.pathname.replace( 144 | // '/blog', 145 | // `/blog/${chosenVariant.fileName}` 146 | // ); 147 | 148 | url.pathname = url.pathname.replace( 149 | currentPath, 150 | `${currentPath}/variants/${chosenVariant.fileName}` 151 | ); 152 | 153 | const res = NextResponse.rewrite(url); 154 | 155 | // if the variant ID doesn't exist in the cookies, set it now for future requests 156 | if (!expVariantID) { 157 | res.cookies.set('expVariantID', `${experimentId}_${chosenVariant.id}`, { 158 | path: '/', 159 | httpOnly: true, 160 | maxAge: 10 * 365 * 24 * 60 * 60, // set the cookie to expire in 10 years 161 | }); 162 | } 163 | 164 | // return the response with the rewritten url or any set cookies 165 | return res; 166 | } 167 | 168 | -------------------------------------------------------------------------------- /nimble.config.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | ] 4 | -------------------------------------------------------------------------------- /nimbleStore2.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/nimbleStore2.db -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NimbleAB", 3 | "description": "An IDE for AB Testing", 4 | "version": "1.0.0", 5 | "main": "app/electron/main.js", 6 | "author": "Team JAZ", 7 | "license": "MIT", 8 | "types": "./index.d.ts", 9 | "build": { 10 | "productName": "NimbleAB", 11 | "appId": "com.Nimble|electron.NimbleAB", 12 | "directories": { 13 | "buildResources": "images" 14 | }, 15 | "asar": "true", 16 | "files": [ 17 | "app/dist/**/*", 18 | "app/electron/**/*" 19 | ], 20 | "mac": { 21 | "category": "public.app-category.productivity", 22 | "target": { 23 | "target": "default", 24 | "arch": [ 25 | "x64", 26 | "arm64" 27 | ] 28 | } 29 | }, 30 | "dmg": { 31 | "sign": false, 32 | "background": null, 33 | "backgroundColor": "#FFFFFF", 34 | "window": { 35 | "width": "400", 36 | "height": "300" 37 | }, 38 | "contents": [ 39 | { 40 | "x": 100, 41 | "y": 100 42 | }, 43 | { 44 | "x": 300, 45 | "y": 100, 46 | "type": "link", 47 | "path": "/Applications" 48 | } 49 | ] 50 | }, 51 | "win": { 52 | "target": [ 53 | { 54 | "target": "nsis", 55 | "arch": [ 56 | "x64" 57 | ] 58 | } 59 | ] 60 | }, 61 | "linux": { 62 | "target": [ 63 | "deb", 64 | "rpm", 65 | "snap", 66 | "AppImage" 67 | ] 68 | }, 69 | "extraResources": [ 70 | "prisma/nimbleStore2.db", 71 | "node_modules/.prisma/**/*", 72 | "node_modules/@prisma/client/**/*" 73 | ] 74 | }, 75 | "scripts": { 76 | "postinstall": "electron-builder install-app-deps", 77 | "audit-app": "npx electronegativity -i ./ -x LimitNavigationGlobalCheck,PermissionRequestHandlerGlobalCheck", 78 | "dev-server": "cross-env NODE_ENV=development webpack serve --config ./webpack.development.js > dev-scripts/webpack-dev-server.log 2> dev-scripts/webpack-dev-server-error.log", 79 | "dev": "concurrently --success first \"node dev-scripts/prepareDevServer.js\" \"node dev-scripts/launchDevServer.js\" -k", 80 | "prod-build": "cross-env NODE_ENV=production npx webpack --mode=production --config ./webpack.production.js", 81 | "prod": "npm run prod-build && electron .", 82 | "pack": "electron-builder --dir", 83 | "dist": "npm run test && npm run prod-build && electron-builder", 84 | "dist-mac": "npm run prod-build && electron-builder --mac", 85 | "dist-linux": "npm run prod-build && electron-builder --linux", 86 | "dist-windows": "npm run prod-build && electron-builder --windows", 87 | "dist-all": "npx prisma generate && npm run prod-build && npx prisma generate && electron-builder install-app-deps && electron-builder --mac --linux --windows", 88 | "test": "mocha" 89 | }, 90 | "dependencies": { 91 | "@emotion/react": "^11.11.1", 92 | "@emotion/styled": "^11.11.0", 93 | "@loadable/component": "^5.15.2", 94 | "@monaco-editor/react": "^4.5.2", 95 | "@mui/material": "^5.14.6", 96 | "@prisma/client": "^5.2.0", 97 | "@reduxjs/toolkit": "^1.8.3", 98 | "@supabase/supabase-js": "^2.33.1", 99 | "@types/loadable__component": "^5.13.4", 100 | "@types/react": "^18.2.21", 101 | "@types/react-dom": "^18.0.6", 102 | "@uiw/react-codemirror": "^4.21.12", 103 | "autoprefixer": "^10.4.15", 104 | "axios": "^1.5.0", 105 | "easy-redux-undo": "^1.0.5", 106 | "electron-devtools-installer": "^3.2.0", 107 | "electron-store": "^8.1.0", 108 | "glob": "^10.3.3", 109 | "install": "^0.13.0", 110 | "npm": "^9.8.1", 111 | "postcss": "^8.4.28", 112 | "process": "^0.11.10", 113 | "react": "^18.2.0", 114 | "react-dom": "^18.2.0", 115 | "react-redux": "^8.0.2", 116 | "react-router": "^6.3.0", 117 | "react-router-dom": "^6.3.0", 118 | "redux": "^4.2.0", 119 | "redux-first-history": "^5.1.1", 120 | "reflect-metadata": "^0.1.13", 121 | "sqlite": "^5.0.1", 122 | "sqlite3": "^5.1.6", 123 | "supabase": "^1.88.0", 124 | "tailwindcss": "^3.3.3", 125 | "typeorm": "^0.3.17", 126 | "typescript": "^4.9.5", 127 | "uuid": "^9.0.0" 128 | }, 129 | "devDependencies": { 130 | "@babel/core": "^7.18.9", 131 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 132 | "@babel/plugin-transform-react-jsx": "^7.18.6", 133 | "@babel/preset-env": "^7.18.9", 134 | "@babel/preset-react": "^7.18.6", 135 | "@babel/preset-typescript": "^7.18.6", 136 | "@doyensec/electronegativity": "^1.9.1", 137 | "@types/loadable__component": "^5.13.4", 138 | "@types/node": "^20.5.7", 139 | "@types/react": "^18.2.21", 140 | "@types/react-dom": "^18.0.6", 141 | "@types/uuid": "^9.0.3", 142 | "autoprefixer": "^10.4.15", 143 | "babel-loader": "^8.2.5", 144 | "babel-plugin-module-resolver": "^4.1.0", 145 | "buffer": "^6.0.3", 146 | "clean-webpack-plugin": "^4.0.0", 147 | "concurrently": "^7.3.0", 148 | "cross-env": "^7.0.3", 149 | "crypto-browserify": "^3.12.0", 150 | "csp-html-webpack-plugin": "^5.1.0", 151 | "css-loader": "^6.7.1", 152 | "css-minimizer-webpack-plugin": "^4.0.0", 153 | "daisyui": "^3.6.3", 154 | "electron": "^19.0.10", 155 | "electron-builder": "^23.0.2", 156 | "electron-debug": "^3.2.0", 157 | "html-loader": "^4.1.0", 158 | "html-webpack-plugin": "^5.5.0", 159 | "mini-css-extract-plugin": "^2.6.1", 160 | "mocha": "^10.0.0", 161 | "path-browserify": "^1.0.1", 162 | "postcss": "^8.4.28", 163 | "postcss-cli": "^10.1.0", 164 | "postcss-loader": "^7.3.3", 165 | "postcss-preset-env": "^9.1.1", 166 | "prisma": "^5.2.0", 167 | "spectron": "^19.0.0", 168 | "stream-browserify": "^3.0.0", 169 | "style-loader": "^3.3.3", 170 | "tailwindcss": "^3.3.3", 171 | "typescript": "^4.9.5", 172 | "webpack": "^5.74.0", 173 | "webpack-cli": "^4.10.0", 174 | "webpack-dev-server": "^4.9.3", 175 | "webpack-merge": "^5.8.0" 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | module.exports = { 3 | plugins: [tailwindcss("./tailwind.config.js"), require("autoprefixer")], 4 | }; 5 | -------------------------------------------------------------------------------- /prisma/nimbleStore2.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NimbleABApp/c04180a1466f6cbe7cba08e044009d0ea6a96cd2/prisma/nimbleStore2.db -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "../node_modules/.prisma/client" 4 | previewFeatures = ["napi"] 5 | binaryTargets = ["darwin", "windows", "darwin-arm64"] 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = "file:nimbleStore2.db" 11 | } 12 | 13 | model Experiments { 14 | id Int @id @default(autoincrement()) 15 | Experiment_Name String? 16 | Device_Type String? 17 | Repo_id Int? 18 | experiment_path String? 19 | experiment_uuid String? 20 | Repos Repos? @relation(fields: [Repo_id], references: [id], onDelete: NoAction, onUpdate: NoAction) 21 | Variants Variants[] 22 | } 23 | 24 | model Variants { 25 | id Int @id @default(autoincrement()) 26 | filePath String 27 | weights Decimal 28 | Experiment_Id Int 29 | Experiments Experiments @relation(fields: [Experiment_Id], references: [id], onDelete: NoAction, onUpdate: NoAction) 30 | } 31 | 32 | model Repos { 33 | id Int @id @default(autoincrement()) 34 | FilePath String 35 | Experiments Experiments[] 36 | } 37 | -------------------------------------------------------------------------------- /renderer.d.ts: -------------------------------------------------------------------------------- 1 | export interface IElectronAPI { 2 | openFile: () => Promise; 3 | parsePaths: () => Promise; 4 | getExperiments: () => Promise; 5 | createModal: (value: any) => Promise; 6 | addExperiment: (experiment: object) => Promise; 7 | addVariant: (name: object) => Promise; 8 | addRepo: (repo: object) => Promise; 9 | getVariants: (expId: number | string) => Promise; 10 | getRepo: (repoId: any) => Promise; 11 | loadFile: (callback: any) => Promise; 12 | saveFile: (callback: any) => Promise; 13 | closeFile: (value: any) => Promise; 14 | removeVariant: (variant: object) => Promise; 15 | } 16 | 17 | declare global { 18 | interface Window { 19 | electronAPI: IElectronAPI; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/src/**/*.{ts,tsx,js,jsx,html}', 5 | './app/dist/*.{html,js}', 6 | './app/src/index.html', 7 | ], 8 | plugins: [require('daisyui')], 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "umd", 5 | "lib": [ 6 | "ES2015", 7 | "ES2016", 8 | "ES2017", 9 | "ES2018", 10 | "ES2019", 11 | "ES2020", 12 | "ESNext", 13 | "dom" 14 | ], 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "jsx": "react", 18 | "noEmit": true, 19 | "sourceMap": true, 20 | /* Strict Type-Checking Options */ 21 | "strict": true, 22 | "noImplicitAny": true, 23 | "strictNullChecks": true, 24 | /* Module Resolution Options */ 25 | "moduleResolution": "node", 26 | "forceConsistentCasingInFileNames": true, 27 | "esModuleInterop": true 28 | // "emitDeclarationOnly": true 29 | }, 30 | "include": ["app/src"], 31 | "exclude": ["node_modules", "renderer.d.ts"] 32 | } 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | const webpack = require("webpack"); 4 | const path = require("path"); 5 | 6 | module.exports = { 7 | target: "web", // Our app can run without electron 8 | entry: { home: "./app/src/index.js", modal: "./app/src/modal/index.js", loading: "./app/src/loading/index.js"}, // The entry point of our app; these entry points can be named and we can also have multiple if we'd like to split the webpack bundle into smaller files to improve script loading speed between multiple pages of our app 9 | output: { 10 | path: path.resolve(__dirname, "app/dist"), // Where all the output files get dropped after webpack is done with them 11 | filename: "[name].js", // The name of the webpack bundle that's generated 12 | publicPath: "/", 13 | }, 14 | resolve: { 15 | fallback: { 16 | crypto: require.resolve("crypto-browserify"), 17 | buffer: require.resolve("buffer/"), 18 | path: require.resolve("path-browserify"), 19 | stream: require.resolve("stream-browserify"), 20 | }, 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | // loads .html files 26 | test: /\.(html)$/, 27 | include: [path.resolve(__dirname, "app/src")], 28 | use: { 29 | loader: "html-loader", 30 | options: { 31 | sources: { 32 | list: [ 33 | { 34 | tag: "img", 35 | attribute: "data-src", 36 | type: "src", 37 | }, 38 | ], 39 | }, 40 | }, 41 | }, 42 | }, 43 | // loads .js/jsx/tsx files 44 | { 45 | test: /\.[jt]sx?$/, 46 | include: [path.resolve(__dirname, "app/src")], 47 | loader: "babel-loader", 48 | resolve: { 49 | extensions: [".js", ".jsx", ".ts", ".tsx", ".json"], 50 | }, 51 | }, 52 | // loads .css files 53 | { 54 | test: /\.css$/, 55 | include: [ 56 | path.resolve(__dirname, "app/src/"), 57 | // webpackPaths.srcRendererPath, 58 | path.resolve(__dirname, "node_modules/"), 59 | ], 60 | use: ["style-loader", "css-loader", "postcss-loader"], 61 | }, 62 | // loads common image formats 63 | { 64 | test: /\.(svg|png|jpg|gif)$/, 65 | include: [path.resolve(__dirname, "resources/images")], 66 | type: "asset/inline", 67 | }, 68 | // loads common font formats 69 | { 70 | test: /\.(eot|woff|woff2|ttf)$/, 71 | include: [path.resolve(__dirname, "resources/fonts")], 72 | type: "asset/inline", 73 | }, 74 | ], 75 | }, 76 | plugins: [ 77 | // fix "process is not defined" error; 78 | // https://stackoverflow.com/a/64553486/1837080 79 | new webpack.ProvidePlugin({ 80 | process: "process/browser.js", 81 | }), 82 | new CleanWebpackPlugin(), 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /webpack.development.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const CspHtmlWebpackPlugin = require('csp-html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const { merge } = require('webpack-merge'); 5 | const base = require('./webpack.config'); 6 | const path = require('path'); 7 | 8 | module.exports = merge(base, { 9 | mode: 'development', 10 | devtool: 'source-map', // Show the source map so we can debug when developing locally 11 | devServer: { 12 | host: 'localhost', 13 | port: '40992', 14 | hot: true, // Hot-reload this server if changes are detected 15 | compress: true, // Compress (gzip) files that are served 16 | static: { 17 | directory: path.resolve(__dirname, 'app/dist'), // Where we serve the local dev server's files from 18 | watch: true, // Watch the directory for changes 19 | staticOptions: { 20 | ignored: /node_modules/, // Ignore this path, probably not needed since we define directory above 21 | }, 22 | }, 23 | }, 24 | plugins: [ 25 | new MiniCssExtractPlugin(), 26 | new HtmlWebpackPlugin({ 27 | template: path.resolve(__dirname, 'app/src/index.html'), 28 | filename: 'index.html', 29 | chunks: ['home'], 30 | }), 31 | new HtmlWebpackPlugin({ 32 | filename: 'modal.html', 33 | template: 'app/src/modal/index.html', 34 | chunks: ['modal'], 35 | }), 36 | new HtmlWebpackPlugin({ 37 | filename: 'loading.html', 38 | template: 'app/src/loading/index.html', 39 | chunks: ['loading'], 40 | }), 41 | ], 42 | }); 43 | -------------------------------------------------------------------------------- /webpack.production.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const CspHtmlWebpackPlugin = require("csp-html-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 5 | const { merge } = require("webpack-merge"); 6 | const base = require("./webpack.config"); 7 | const path = require("path"); 8 | 9 | module.exports = merge(base, { 10 | mode: "production", 11 | devtool: "source-map", 12 | plugins: [ 13 | new MiniCssExtractPlugin(), 14 | new HtmlWebpackPlugin({ 15 | template: path.resolve(__dirname, "app/src/index.html"), 16 | filename: "index.html", 17 | base: "app://rse", 18 | chunks: ["home"], 19 | }), 20 | new HtmlWebpackPlugin({ 21 | filename: "modal.html", 22 | template: "app/src/modal/index.html", 23 | base: "app://rse", 24 | chunks: ["modal"], 25 | }), 26 | new HtmlWebpackPlugin({ 27 | filename:"loading.html", 28 | template: "app/src/loading/index.html", 29 | base: "app://rse", 30 | chunks: ["loading"] 31 | }) 32 | 33 | // You can paste your CSP in this website https://csp-evaluator.withgoogle.com/ 34 | // for it to give you suggestions on how strong your CSP is 35 | ], 36 | optimization: { 37 | minimize: true, 38 | minimizer: [ 39 | "...", // This adds default minimizers to webpack. For JS, Terser is used. // https://webpack.js.org/configuration/optimization/#optimizationminimizer 40 | new CssMinimizerPlugin(), 41 | ], 42 | }, 43 | }); 44 | --------------------------------------------------------------------------------