├── .eslintrc.json ├── .firebaserc ├── .github └── workflows │ ├── firebase-deploy-test.yml │ ├── firebase-deploy.yml │ └── node.js.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── __tests__ ├── firestore-rules.test.js ├── index.test.jsx ├── validate-csv.test.js └── validate-json.test.js ├── components ├── AuthCheck.js ├── CodeBlock.js ├── CopyButton.js ├── Footer.js ├── JsPsychIcon.js ├── Loader.js ├── Navbar.js ├── OpenCollectiveIcon.js ├── SignInForm.js ├── account │ ├── ChangePassword.js │ ├── DeleteAccount.js │ └── OSFToken.js └── dashboard │ ├── CodeHints.js │ ├── ExperimentActive.js │ ├── ExperimentInfo.js │ ├── ExperimentValidation.js │ └── Title.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions ├── .gitignore ├── __tests__ │ ├── base64data-emulator.test.js │ ├── data-emulator.test.js │ └── get-condition-emulator.test.js ├── api-base64.js ├── api-condition.js ├── api-data.js ├── api-messages.js ├── app.js ├── index.js ├── package-lock.json ├── package.json ├── put-file-osf.js ├── validate-csv.js ├── validate-json.js └── write-log.js ├── jest.config.js ├── lib ├── context.js ├── firebase.js ├── theme.js └── utils.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── admin │ ├── [experiment_id].js │ ├── account.js │ ├── deleted-account.js │ ├── index.js │ └── new.js ├── api-docs.js ├── contact.js ├── faq.js ├── getting-started.js ├── index.js ├── redirect.js ├── reset-password.js ├── signin.js └── signup.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── homepipe.png ├── logo.png ├── site.webmanifest └── vercel.svg └── styles ├── Home.module.css └── globals.css /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "osf-relay", 4 | "test": "datapipe-test" 5 | }, 6 | "targets": {}, 7 | "etags": {} 8 | } -------------------------------------------------------------------------------- /.github/workflows/firebase-deploy-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Deploy to Test 5 | 6 | on: 7 | push: 8 | branches: ["test"] 9 | 10 | env: 11 | #FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 12 | NEXT_PUBLIC_FIREBASE_CONFIG: ${{ secrets.FIREBASE_TEST_CONFIG }} 13 | 14 | jobs: 15 | deploy: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [20.x] 21 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: "npm" 30 | - uses: 'google-github-actions/auth@v2' 31 | with: 32 | credentials_json: '${{ secrets.GOOGLE_TEST_CREDENTIALS }}' # Replace with the name of your GitHub Actions secret 33 | - name: Install firebase tools 34 | run: npm install -g firebase-tools@12.1.0 35 | - name: Enable firebase webframeworks 36 | run: firebase experiments:enable webframeworks 37 | - run: npm ci 38 | - name: Run npm ci in /functions 39 | working-directory: functions 40 | run: npm ci 41 | - name: Use test firebase project 42 | run: firebase use test 43 | - name: Deploy firebase project 44 | run: firebase deploy 45 | -------------------------------------------------------------------------------- /.github/workflows/firebase-deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Deploy to Production 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | 10 | env: 11 | #FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 12 | NEXT_PUBLIC_FIREBASE_CONFIG: ${{ secrets.FIREBASE_PRODUCTION_CONFIG }} 13 | 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [20.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: "npm" 31 | - uses: 'google-github-actions/auth@v2' 32 | with: 33 | credentials_json: '${{ secrets.GOOGLE_PRODUCTION_CREDENTIALS }}' 34 | - name: Install firebase tools 35 | run: npm install -g firebase-tools@12.1.0 36 | - name: Enable firebase webframeworks 37 | run: firebase experiments:enable webframeworks 38 | - run: npm ci 39 | - name: Run npm ci in /functions 40 | working-directory: functions 41 | run: npm ci 42 | - name: Use default firebase project 43 | run: firebase use default 44 | - name: Deploy firebase project 45 | run: firebase deploy 46 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Test 5 | 6 | on: 7 | pull_request: 8 | branches: ["main"] 9 | push: 10 | branches: ["test"] 11 | 12 | env: 13 | #FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 14 | NEXT_PUBLIC_FIREBASE_CONFIG: ${{ secrets.FIREBASE_TEST_CONFIG }} 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [20.x] 23 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: "npm" 32 | - uses: 'google-github-actions/auth@v2' 33 | with: 34 | credentials_json: '${{ secrets.GOOGLE_TEST_CREDENTIALS }}' # Replace with the name of your GitHub Actions secret 35 | - name: Install firebase tools 36 | run: npm install -g firebase-tools 37 | - name: Enable firebase webframeworks 38 | run: firebase experiments:enable webframeworks 39 | - run: npm ci 40 | - name: Run npm ci in /functions 41 | working-directory: functions 42 | run: npm ci 43 | - name: Select project 44 | run: firebase use test 45 | - name: Launch firestore emulator and test 46 | run: firebase emulators:exec 'npm run test-ci' 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 107 | 108 | # dependencies 109 | /node_modules 110 | /.pnp 111 | .pnp.js 112 | 113 | # testing 114 | /coverage 115 | 116 | # next.js 117 | /.next/ 118 | /out/ 119 | 120 | # production 121 | /build 122 | 123 | # misc 124 | .DS_Store 125 | *.pem 126 | 127 | # debug 128 | npm-debug.log* 129 | yarn-debug.log* 130 | yarn-error.log* 131 | .pnpm-debug.log* 132 | 133 | # local env files 134 | .env*.local 135 | 136 | # vercel 137 | .vercel 138 | 139 | # typescript 140 | *.tsbuildinfo 141 | next-env.d.ts 142 | 143 | osf-token-info.txt 144 | 145 | .firebase 146 | .idea/ 147 | .emulator-data 148 | 149 | testServiceAccount.json 150 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Josh de Leeuw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataPipe 2 | 3 | Send your experiment data directly to the OSF, for free. 4 | 5 | https://pipe.jspsych.org 6 | 7 | ## Citation 8 | 9 | If you use this for academic work, please cite: 10 | 11 | de Leeuw, J. R. (2024). DataPipe: Born-open data collection for online experiments. *Behavior Research Methods, 56*(3), 2499-2506. 12 | 13 | -------------------------------------------------------------------------------- /__tests__/firestore-rules.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { 6 | assertFails, 7 | assertSucceeds, 8 | initializeTestEnvironment, 9 | } from '@firebase/rules-unit-testing'; 10 | import { doc, collection, getDoc, getDocs, setDoc, updateDoc, deleteDoc } from 'firebase/firestore'; 11 | import { readFileSync } from 'fs'; 12 | let testEnv; 13 | 14 | async function seedDB(data){ 15 | await testEnv.withSecurityRulesDisabled(async (context) => { 16 | const dbAdmin = context.firestore(); 17 | for (const [key, value] of Object.entries(data)) { 18 | await setDoc(doc(dbAdmin, key), value); 19 | } 20 | }); 21 | } 22 | 23 | beforeAll(async () => { 24 | testEnv = await initializeTestEnvironment({ 25 | projectId: 'osf-relay', 26 | firestore: { 27 | rules: readFileSync('firestore.rules', 'utf8'), 28 | host: 'localhost', 29 | port: 8080 30 | } 31 | }); 32 | }); 33 | 34 | afterAll(async () => { 35 | await testEnv.cleanup(); 36 | }); 37 | 38 | describe('/users', () => { 39 | it('should deny read access to unauthenticated users', async () => { 40 | const unauth = testEnv.unauthenticatedContext(); 41 | 42 | await assertFails(getDoc(doc(unauth.firestore(), 'users/123'))); 43 | }); 44 | 45 | it('should allow read access to authenticated users for their own doc', async () => { 46 | const user123 = testEnv.authenticatedContext('user123'); 47 | 48 | await assertSucceeds(getDoc(doc(user123.firestore(), 'users/user123'))); 49 | }); 50 | 51 | it('should deny read access to authenticated users for another users doc', async () => { 52 | const user123 = testEnv.authenticatedContext('user123'); 53 | 54 | await assertFails(getDoc(doc(user123.firestore(), 'users/user456'))); 55 | }); 56 | 57 | it('should allow writes with the right data', async () => { 58 | const user123 = testEnv.authenticatedContext('user123'); 59 | 60 | await assertSucceeds(setDoc(doc(user123.firestore(), 'users/user123'), { 61 | email: 'john@doe.com', 62 | experiments: ['exp1', 'exp2'], 63 | osfToken: 'abc123', 64 | uid: 'user123' 65 | })); 66 | }); 67 | 68 | it('should deny writes when uid does not match authenticated user id', async () => { 69 | const user123 = testEnv.authenticatedContext('user123'); 70 | 71 | await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { 72 | email: 'john@doe.com', 73 | experiments: ['exp1', 'exp2'], 74 | osfToken: 'abc123', 75 | uid: 'user456' 76 | })); 77 | }); 78 | 79 | 80 | it('should deny writes that have extra keys', async () => { 81 | const user123 = testEnv.authenticatedContext('user123'); 82 | 83 | await assertFails(setDoc(doc(user123.firestore(), 'users/user123'), { 84 | name: 'John Doe', 85 | email: 'john@doe.com', 86 | extra: 'extra' 87 | })); 88 | }) 89 | }); 90 | 91 | describe('/experiments', () => { 92 | it('should deny read access to unauthenticated users', async () => { 93 | const unauth = testEnv.unauthenticatedContext(); 94 | 95 | await assertFails(getDoc(doc(unauth.firestore(), 'experiments/123'))); 96 | }); 97 | 98 | it('should allow read access to authenticated users for their own doc', async () => { 99 | const data = { 100 | 'experiments/123': { 101 | owner: 'user123', 102 | } 103 | } 104 | await seedDB(data); 105 | 106 | const user123 = testEnv.authenticatedContext('user123'); 107 | 108 | await assertSucceeds(getDoc(doc(user123.firestore(), 'experiments/123'))); 109 | }); 110 | 111 | it('should deny read access to authenticated users for someone elses doc', async () => { 112 | const data = { 113 | 'experiments/456': { 114 | owner: 'user456', 115 | } 116 | } 117 | await seedDB(data); 118 | 119 | const user123 = testEnv.authenticatedContext('user123'); 120 | 121 | await assertFails(getDoc(doc(user123.firestore(), 'experiments/456'))); 122 | }); 123 | 124 | }); -------------------------------------------------------------------------------- /__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Home from "../pages/index"; 3 | import "@testing-library/jest-dom"; 4 | 5 | describe("Home", () => { 6 | it("renders Home component", () => { 7 | render(); 8 | expect(screen.getByText("Create an account")).toBeInTheDocument(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /__tests__/validate-csv.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import validateCSV from "../functions/validate-csv"; 6 | 7 | describe("validateCSV", () => { 8 | it("should return true when all required fields are present", () => { 9 | const csv = `foo,bar\nbaz,qux`; 10 | const requiredFields = ['foo']; 11 | expect(validateCSV(csv, requiredFields)).toBe(true); 12 | }); 13 | 14 | it("should return true when all required fields are present (multiple fields)", () => { 15 | const csv = `foo,bar\nbaz,qux`; 16 | const requiredFields = ['foo', 'bar']; 17 | expect(validateCSV(csv, requiredFields)).toBe(true); 18 | }); 19 | 20 | it("should return false when a required field is missing", () => { 21 | const csv = `foo,bar\nbaz,qux`; 22 | const requiredFields = ['foo', 'baz']; 23 | expect(validateCSV(csv, requiredFields)).toBe(false); 24 | }); 25 | 26 | }); -------------------------------------------------------------------------------- /__tests__/validate-json.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import validateJSON from "../functions/validate-json"; 6 | 7 | describe("validateJSON", () => { 8 | it("should return true when all required fields are present", () => { 9 | const json = `{"foo": "bar"}`; 10 | const requiredFields = ['foo']; 11 | expect(validateJSON(json, requiredFields)).toBe(true); 12 | }); 13 | 14 | it("should return false when a required field is missing", () => { 15 | const json = `{"foo": "bar"}`; 16 | const requiredFields = ['foo', 'baz']; 17 | expect(validateJSON(json, requiredFields)).toBe(false); 18 | }); 19 | 20 | it("should return true when all required fields are present in an array", () => { 21 | const json = `[{"foo": "bar"}, {"foo": "baz"}]`; 22 | const requiredFields = ['foo']; 23 | expect(validateJSON(json, requiredFields)).toBe(true); 24 | }); 25 | 26 | it("should return true when all required fields are present in at least one array item", () => { 27 | const json = `[{"foo": "bar"}, {"baz": "qux"}]`; 28 | const requiredFields = ['foo']; 29 | expect(validateJSON(json, requiredFields)).toBe(true); 30 | }); 31 | 32 | it("should return true when all required fields are present in all array items", () => { 33 | const json = `[{"trial_type": "bar", "baz":"qux"}, {"trial_type": "bar", "baz":"quz"}]`; 34 | const requiredFields = ['trial_type']; 35 | expect(validateJSON(json, requiredFields)).toBe(true); 36 | }); 37 | 38 | it("should return true when all required fields are present in at least one array item, even if in different items", () => { 39 | const json = `[{"foo": "bar"}, {"baz": "qux"}]`; 40 | const requiredFields = ['foo', 'baz']; 41 | expect(validateJSON(json, requiredFields)).toBe(true); 42 | }); 43 | 44 | it("should return false for invalid JSON string", () => { 45 | const json = `{"foo": "bar"`; 46 | const requiredFields = ['foo']; 47 | expect(validateJSON(json, requiredFields)).toBe(false); 48 | }); 49 | 50 | it("should work for real jsPsych data", () => { 51 | const json = `[{"rt":1161,"stimulus":"You are in condition 0","response":0,"trial_type":"html-button-response","trial_index":0,"time_elapsed":1163,"internal_node_id":"0.0-0.0","subject":"9om0mjpqjm"},{"rt":3676,"response":"button","png":null,"trial_type":"sketchpad","trial_index":1,"time_elapsed":4844,"internal_node_id":"0.0-1.0","subject":"9om0mjpqjm"}]` 52 | const requiredFields = ['trial_type']; 53 | expect(validateJSON(json, requiredFields)).toBe(true); 54 | }); 55 | }); -------------------------------------------------------------------------------- /components/AuthCheck.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | import { UserContext } from "../lib/context"; 3 | import SignInForm from "./SignInForm"; 4 | import { useRouter } from "next/router"; 5 | 6 | export default function AuthCheck({ children, fallback, fallbackRoute }) { 7 | const router = useRouter(); 8 | const { user } = useContext(UserContext); 9 | 10 | useEffect(() => { 11 | if (!user && fallbackRoute) { 12 | router.push(fallbackRoute); 13 | } 14 | }, [user, router, fallbackRoute]); 15 | 16 | return user 17 | ? children 18 | : fallback || ; 19 | } 20 | -------------------------------------------------------------------------------- /components/CodeBlock.js: -------------------------------------------------------------------------------- 1 | import { Box, Container, HStack } from "@chakra-ui/react"; 2 | import CopyButton from "./CopyButton"; 3 | 4 | const customScrollBarCSS = { 5 | "::-webkit-scrollbar": { 6 | backgroundColor: "gray.800", 7 | height: "8px", 8 | paddingTop: "10px", 9 | borderRadius: "8px", 10 | }, 11 | "::-webkit-scrollbar-thumb": { 12 | background: "gray.600", 13 | borderRadius: "8px", 14 | }, 15 | }; 16 | 17 | export default function CodeBlock({ children, ...props }) { 18 | let lines = children.split("\n"); 19 | // remove first line if it is empty 20 | if (lines[0].trim() === "") { 21 | lines.shift(); 22 | } 23 | // get indent of the first line 24 | const indent = lines[0].match(/^\s*/)[0].length; 25 | // remove indent from all lines 26 | lines = lines.map((line) => line.slice(indent)); 27 | // join lines back together 28 | const code = lines.join("\n"); 29 | 30 | return ( 31 | 32 | 33 | 40 | {code} 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /components/CopyButton.js: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/react"; 2 | import { useClipboard } from "@chakra-ui/react"; 3 | import { CheckIcon } from "@chakra-ui/icons"; 4 | 5 | export default function CopyButton({ code }) { 6 | const { hasCopied, onCopy } = useClipboard(code); 7 | return ( 8 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Container, 4 | SimpleGrid, 5 | HStack, 6 | VStack, 7 | Link, 8 | Stack, 9 | Text, 10 | Button, 11 | } from "@chakra-ui/react"; 12 | import { OpenCollectiveIcon } from "./OpenCollectiveIcon"; 13 | import { JsPsychIcon } from "./JsPsychIcon"; 14 | import NextLink from "next/link"; 15 | 16 | export default function Footer() { 17 | return ( 18 | 19 | 20 | 30 | 31 | Created by the developers of jsPsych 32 | 33 | 34 | 39 | Report an Issue 40 | 41 | 42 | 43 | 48 | GitHub 49 | 50 | 51 | 52 | 53 | Contact Us 54 | 55 | 56 | 57 | 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /components/JsPsychIcon.js: -------------------------------------------------------------------------------- 1 | import { createIcon } from "@chakra-ui/icons"; 2 | 3 | // using `path` 4 | export const JsPsychIcon = createIcon({ 5 | displayName: "jsPsychIcon", 6 | viewBox: "0 0 333 333", 7 | // path can also be an array of elements, if you have multiple paths, lines, shapes, etc. 8 | path: [ 9 | , 15 | , 21 | , 27 | , 33 | , 39 | , 45 | , 51 | , 57 | , 63 | , 69 | , 75 | , 81 | , 87 | , 93 | , 99 | , 105 | , 111 | , 117 | , 123 | , 129 | , 135 | , 141 | , 147 | , 153 | , 159 | , 165 | , 171 | , 177 | , 183 | , 189 | , 195 | , 201 | , 207 | , 213 | , 219 | , 225 | , 231 | , 237 | , 243 | , 249 | , 255 | , 261 | , 267 | , 273 | , 279 | , 285 | , 291 | , 297 | , 303 | , 309 | , 315 | , 321 | , 327 | , 333 | , 339 | , 345 | , 351 | , 357 | , 363 | , 369 | , 375 | , 381 | ], 382 | }); 383 | 384 | /* 385 | 392 | 397 | 401 | 402 | */ 403 | -------------------------------------------------------------------------------- /components/Loader.js: -------------------------------------------------------------------------------- 1 | export default function Loader({ show }) { 2 | return show ?
: null; 3 | } 4 | -------------------------------------------------------------------------------- /components/Navbar.js: -------------------------------------------------------------------------------- 1 | import NextLink from "next/link"; 2 | import { useContext } from "react"; 3 | import { UserContext } from "../lib/context"; 4 | import { 5 | Box, 6 | Button, 7 | Text, 8 | Flex, 9 | HStack, 10 | Link, 11 | MenuItem, 12 | Menu, 13 | MenuButton, 14 | MenuList, 15 | MenuDivider, 16 | Image, 17 | IconButton, 18 | } from "@chakra-ui/react"; 19 | import { AddIcon, HamburgerIcon } from "@chakra-ui/icons"; 20 | 21 | import { auth } from "../lib/firebase"; 22 | 23 | import { Rubik } from "@next/font/google"; 24 | 25 | const rubik = Rubik({ subsets: ["latin"] }); 26 | 27 | export default function Navbar() { 28 | const { user } = useContext(UserContext); 29 | 30 | return ( 31 | 32 | 39 | 40 | 41 | 48 | 49 | DataPipe Logo 55 | 56 | DataPipe 57 | 58 | 59 | 65 | 66 | Getting Started 67 | 68 | 69 | API Docs 70 | 71 | 72 | FAQ 73 | 74 | {user && ( 75 | 76 | My Experiments 77 | 78 | )} 79 | 80 | 81 | 82 | {!user && ( 83 | <> 84 | 85 | 93 | 94 | 95 | 98 | 99 | 100 | )} 101 | {user && ( 102 | <> 103 | 104 | 112 | 113 | 114 | 122 | Account 123 | 124 | 125 | 126 | Settings 127 | 128 | 129 | auth.signOut()}> 130 | Sign Out 131 | 132 | 133 | 134 | 135 | )} 136 | 137 | 138 | 139 | } 143 | cursor={"pointer"} 144 | minW={0} 145 | > 146 | 147 | 148 | Getting Started 149 | 150 | 151 | API Docs 152 | 153 | 154 | FAQ 155 | 156 | 157 | My Experiments 158 | 159 | 160 | New Experiment 161 | 162 | 163 | {!user && ( 164 | <> 165 | 166 | Sign Up 167 | 168 | 169 | Sign In 170 | 171 | 172 | )} 173 | {user && ( 174 | auth.signOut()}> 175 | Sign Out 176 | 177 | )} 178 | 179 | 180 | 181 | 182 | 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /components/OpenCollectiveIcon.js: -------------------------------------------------------------------------------- 1 | import { createIcon } from "@chakra-ui/icons"; 2 | 3 | // using `path` 4 | export const OpenCollectiveIcon = createIcon({ 5 | displayName: "OpenCollectiveIcon", 6 | viewBox: "0 0 24 24", 7 | // path can also be an array of elements, if you have multiple paths, lines, shapes, etc. 8 | path: [ 9 | , 15 | , 20 | ], 21 | }); 22 | 23 | /* 24 | 31 | 36 | 40 | 41 | */ 42 | -------------------------------------------------------------------------------- /components/SignInForm.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | CardBody, 5 | CardHeader, 6 | Input, 7 | Text, 8 | Link, 9 | Stack, 10 | Heading, 11 | FormControl, 12 | FormLabel, 13 | FormErrorMessage, 14 | } from "@chakra-ui/react"; 15 | import { auth } from "../lib/firebase"; 16 | import { signInWithEmailAndPassword } from "firebase/auth"; 17 | import { useState } from "react"; 18 | import { useRouter } from "next/router"; 19 | import NextLink from "next/link"; 20 | import { ERROR, getError } from "../lib/utils"; 21 | 22 | export default function SignInForm({ routeAfterSignIn }) { 23 | const router = useRouter(); 24 | const [email, setEmail] = useState(""); 25 | const [password, setPassword] = useState(""); 26 | const [errorEmail, setErrorEmail] = useState(""); 27 | const [errorPassword, setErrorPassword] = useState(""); 28 | const [isSubmitting, setIsSubmitting] = useState(false); 29 | 30 | const onSubmit = async () => { 31 | setIsSubmitting(true); 32 | try { 33 | const user = await signInWithEmailAndPassword(auth, email, password); 34 | router.push(routeAfterSignIn); 35 | } catch (error) { 36 | setIsSubmitting(false); 37 | const { code } = error; 38 | if (code == ERROR.PASSWORD_WRONG) { 39 | setErrorPassword(getError(code)); 40 | } else { 41 | setErrorEmail(getError(code)); 42 | } 43 | console.log("Sign in failed"); 44 | console.log(error); 45 | } 46 | }; 47 | 48 | return ( 49 | 50 | 51 | Sign In 52 | 53 | 54 | 55 | 56 | Email 57 | { 60 | setEmail(e.target.value); 61 | setErrorEmail(""); 62 | }} 63 | /> 64 | {errorEmail} 65 | 66 | 67 | Password 68 | { 71 | setPassword(e.target.value); 72 | setErrorPassword(""); 73 | }} 74 | /> 75 | {errorPassword} 76 | 77 | 78 | 79 | Forgot password? 80 | 81 | 82 | 89 | 90 | Need an account?{" "} 91 | 92 | Sign Up! 93 | 94 | 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /components/account/ChangePassword.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from "react"; 2 | import { UserContext } from "../../lib/context"; 3 | 4 | import { FormErrorMessage, useDisclosure } from "@chakra-ui/react"; 5 | import { 6 | HStack, 7 | VStack, 8 | Button, 9 | Text, 10 | Modal, 11 | ModalOverlay, 12 | ModalContent, 13 | ModalHeader, 14 | ModalFooter, 15 | ModalBody, 16 | ModalCloseButton, 17 | FormControl, 18 | FormLabel, 19 | Input, 20 | } from "@chakra-ui/react"; 21 | 22 | import { auth } from "../../lib/firebase"; 23 | import { updatePassword } from "firebase/auth"; 24 | 25 | export default function ChangePassword() { 26 | const { user } = useContext(UserContext); 27 | const [isSubmitting, setIsSubmitting] = useState(false); 28 | const { isOpen, onOpen, onClose } = useDisclosure(); 29 | const [password, setPassword] = useState(""); 30 | const [confirmPassword, setConfirmPassword] = useState(""); 31 | const [passwordMatch, setPasswordMatch] = useState(true); 32 | const [passwordLengthSatisfied, setPasswordLengthSatisfied] = useState(true); 33 | 34 | useEffect(() => { 35 | if (password !== confirmPassword) { 36 | setPasswordMatch(false); 37 | } else { 38 | setPasswordMatch(true); 39 | } 40 | }, [password, confirmPassword]); 41 | 42 | useEffect(() => { 43 | if (password.length < 6) { 44 | setPasswordLengthSatisfied(false); 45 | } else { 46 | setPasswordLengthSatisfied(true); 47 | } 48 | }, [password]); 49 | 50 | return ( 51 | 52 | Password 53 | 56 | 57 | 58 | 59 | Change Password 60 | 61 | 62 | 63 | 67 | New Password 68 | setPassword(e.target.value)} 72 | /> 73 | 74 | Password must be at least 6 characters 75 | 76 | 77 | 78 | Confirm Password 79 | setConfirmPassword(e.target.value)} 83 | /> 84 | Passwords do not match 85 | 86 | 87 | 88 | 89 | 100 | 101 | 102 | 103 | 104 | ); 105 | } 106 | 107 | async function handleChangePassword(newPassword, setIsSubmitting) { 108 | setIsSubmitting(true); 109 | const user = auth.currentUser; 110 | 111 | try { 112 | await updatePassword(user, newPassword); 113 | setIsSubmitting(false); 114 | } catch (error) { 115 | console.log(error); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /components/account/DeleteAccount.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from "react"; 2 | import { UserContext } from "../../lib/context"; 3 | 4 | import { useDisclosure } from "@chakra-ui/react"; 5 | import { 6 | HStack, 7 | VStack, 8 | Button, 9 | Text, 10 | AlertDialog, 11 | AlertDialogBody, 12 | AlertDialogFooter, 13 | AlertDialogHeader, 14 | AlertDialogContent, 15 | AlertDialogOverlay, 16 | } from "@chakra-ui/react"; 17 | 18 | import { auth } from "../../lib/firebase"; 19 | import { deleteUser } from "firebase/auth"; 20 | 21 | import { useRef } from "react"; 22 | 23 | import { useRouter } from "next/router"; 24 | 25 | export default function DeleteAccount({ setDeleting }) { 26 | const { user } = useContext(UserContext); 27 | const [isSubmitting, setIsSubmitting] = useState(false); 28 | const { isOpen, onOpen, onClose } = useDisclosure(); 29 | const cancelRef = useRef(); 30 | const router = useRouter(); 31 | 32 | const deleteAccount = async function () { 33 | try { 34 | await deleteUser(auth.currentUser); 35 | router.push("/admin/deleted-account"); 36 | } catch (error) { 37 | setDeleting(false); 38 | console.log(error); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | Delete Account 45 | 48 | 53 | 54 | 55 | 56 | Delete Account 57 | 58 | 59 | 60 | 61 | Are you sure? This action is final. We cannot recover any 62 | experiments that are associated with this account after 63 | deletion. 64 | 65 | 66 | Deleting your DataPipe account will not affect any data on the 67 | OSF. 68 | 69 | 70 | 71 | 72 | 75 | 86 | 87 | 88 | 89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /components/account/OSFToken.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from "react"; 2 | import { UserContext } from "../../lib/context"; 3 | 4 | import { FormErrorMessage, useDisclosure } from "@chakra-ui/react"; 5 | import { 6 | HStack, 7 | VStack, 8 | Button, 9 | Text, 10 | Modal, 11 | ModalOverlay, 12 | ModalContent, 13 | ModalHeader, 14 | ModalFooter, 15 | ModalBody, 16 | ModalCloseButton, 17 | FormControl, 18 | FormLabel, 19 | Input, 20 | Tooltip, 21 | Link, 22 | } from "@chakra-ui/react"; 23 | 24 | import { useDocumentData } from "react-firebase-hooks/firestore"; 25 | import { doc, setDoc } from "firebase/firestore"; 26 | 27 | import { db, auth } from "../../lib/firebase"; 28 | import { CheckCircleIcon, WarningIcon } from "@chakra-ui/icons"; 29 | 30 | export default function OSFToken() { 31 | const { user } = useContext(UserContext); 32 | const [isSubmitting, setIsSubmitting] = useState(false); 33 | const { isOpen, onOpen, onClose } = useDisclosure(); 34 | 35 | const [data, loading, error, snapshot, reload] = useDocumentData( 36 | doc(db, "users", user.uid) 37 | ); 38 | 39 | return ( 40 | 41 | 42 | OSF Token 43 | {data && data.osfTokenValid ? ( 44 | 45 | 46 | 47 | ) : ( 48 | 49 | 50 | 51 | )} 52 | 53 | 56 | 57 | 58 | 59 | Change OSF Token 60 | 61 | 62 | 63 | 64 | To generate an OSF token, go to{" "} 65 | 70 | https://osf.io/settings/tokens/ 71 | {" "} 72 | and click "Create Token". 73 | 74 | 75 | Select osf.full_write under scopes and click "Create 76 | token". Copy the token and paste it below. 77 | 78 | 79 | {data && ( 80 | 81 | 82 | OSF Token 83 | 84 | 85 | 86 | )} 87 | 88 | 89 | 90 | 102 | 103 | 104 | 105 | 106 | ); 107 | } 108 | 109 | async function handleSaveButton(setIsSubmitting, closeHandler) { 110 | const token = document.querySelector("#osf-token").value; 111 | setIsSubmitting(true); 112 | try { 113 | const isTokenValid = await checkOSFToken(token); 114 | const userDoc = doc(db, "users", auth.currentUser.uid); 115 | await setDoc( 116 | userDoc, 117 | { osfToken: token, osfTokenValid: isTokenValid }, 118 | { merge: true } 119 | ); 120 | setIsSubmitting(false); 121 | closeHandler(); 122 | } catch (error) { 123 | setIsSubmitting(false); 124 | console.log(error); 125 | } 126 | } 127 | 128 | async function checkOSFToken(token) { 129 | const data = await fetch("https://api.osf.io/v2/", { 130 | method: "GET", 131 | headers: { 132 | Authorization: `Bearer ${token}`, 133 | }, 134 | }); 135 | 136 | if (data.status === 200) { 137 | return true; 138 | } else { 139 | return false; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /components/dashboard/CodeHints.js: -------------------------------------------------------------------------------- 1 | import { 2 | Tab, 3 | TabList, 4 | TabPanel, 5 | TabPanels, 6 | Tabs, 7 | VStack, 8 | Text, 9 | Heading, 10 | Stack, 11 | Menu, 12 | MenuButton, 13 | MenuList, 14 | MenuItem, 15 | MenuDivider, 16 | Button, 17 | } from "@chakra-ui/react"; 18 | import { useState } from "react"; 19 | import { ChevronDownIcon } from "@chakra-ui/icons"; 20 | 21 | import CodeBlock from "../CodeBlock"; 22 | 23 | export default function CodeHints({ expId }) { 24 | const [language, setLanguage] = useState("jsPsych version 7"); 25 | 26 | return ( 27 | 35 | Code Samples 36 | 37 | Select language 38 | 39 | } 44 | > 45 | {language} 46 | 47 | 48 | setLanguage("jsPsych version 7")} 51 | > 52 | jsPsych version 7 53 | 54 | 55 | setLanguage("JavaScript")}> 56 | JavaScript 57 | 58 | 59 | 60 | 61 | {language === "jsPsych version 7" && ( 62 | 63 | 64 | Send data 65 | Send and decode base64 data 66 | Get condition assignment 67 | 68 | 69 | 70 | 71 | 72 | Load the pipe plugin: 73 | 74 | {``} 75 | 76 | Generate a unique filename: 77 | 78 | {` 79 | const subject_id = jsPsych.randomization.randomID(10); 80 | const filename = \`\${subject_id}.csv\`; 81 | `} 82 | 83 | 84 | To save data, add this trial to your timeline after all data 85 | is collected: 86 | 87 | 88 | {` 89 | const save_data = { 90 | type: jsPsychPipe, 91 | action: "save", 92 | experiment_id: "${expId}", 93 | filename: filename, 94 | data_string: ()=>jsPsych.data.get().csv() 95 | };`} 96 | 97 | 98 | Note that you can also save the data as JSON by changing the 99 | file name and using .json() instead of .csv() to get the 100 | jsPsych data. 101 | 102 | 103 | 104 | 105 | 106 | Load the pipe plugin: 107 | 108 | {``} 109 | 110 | 111 | This example will imagine that you are recording audio data 112 | from the html-audio-response plugin and sending the file at 113 | the end of the trial. There are other ways that you could use 114 | this, but this method will illustrate the key ideas. 115 | 116 | 117 | First, we will generate a unique subject ID so that we can 118 | label the file with the subject ID and the trial number. 119 | 120 | 121 | {` 122 | const subject_id = jsPsych.randomization.randomID(10); 123 | `} 124 | 125 | 126 | In the on_finish event, we can send the data using the static 127 | method of the pipe plugin. 128 | 129 | 130 | {` 131 | var trial = { 132 | type: jsPsychHtmlAudioResponse, 133 | stimulus: \` 134 |

Please record a few seconds of audio and click the button when you are done.

135 | \`, 136 | recording_duration: 15000, 137 | allow_playback: true, 138 | on_finish: function(data){ 139 | const filename = \`\${subject_id}_\${jsPsych.getProgress().current_trial_global}_audio.webm\`; 140 | jsPsychPipe.saveBase64Data("${expId}", filename, data.response); 141 | // delete the base64 data to save space. store the filename instead. 142 | data.response = filename; 143 | } 144 | }; 145 | `} 146 |
147 | 148 | The jsPsych.saveBase64Data method is asynchronous, so if you 149 | want to wait for confirmation that the file was saved before 150 | moving on you can use the plugin instead. If you are 151 | comfortable with asynchronous programming then async/await 152 | will work too. 153 | 154 | 155 | {` 156 | const save_data = { 157 | type: jsPsychPipe, 158 | action: "saveBase64", 159 | experiment_id: "${expId}", 160 | filename: ()=>{ 161 | return \`\${subject_id}_\${jsPsych.getProgress().current_trial_global}_audio.webm\`; 162 | }, 163 | data_string: ()=>{ 164 | // get the last trial's response (imagine that this is the audio data) 165 | return jsPsych.data.get().last(1).values()[0].response; 166 | } 167 | };`} 168 | 169 |
170 |
171 | 172 | 173 | Load the pipe plugin: 174 | 175 | {``} 176 | 177 | 178 | Use the static method of the pipe plugin to request the 179 | condition. This is an asynchronous request so we need to wait 180 | for the response before using the condition value. An easy 181 | wait to do this is to put your experiment creation code inside 182 | an async function. 183 | 184 | 185 | {` 186 | async function createExperiment(){ 187 | const condition = await jsPsychPipe.getCondition("${expId}"); 188 | if(condition == 0) { timeline = condition_1_timeline; } 189 | if(condition == 1) { timeline = condition_2_timeline; } 190 | jsPsych.run(timeline); 191 | } 192 | createExperiment(); 193 | `} 194 | 195 | 196 | 197 |
198 |
199 | )} 200 | {language === "JavaScript" && ( 201 | 202 | 203 | Send data 204 | Send and decode base64 data 205 | Get condition assignment 206 | 207 | 208 | 209 | 210 | 211 | Use fetch to send data. 212 | 213 | {` 214 | fetch("https://pipe.jspsych.org/api/data/", { 215 | method: "POST", 216 | headers: { 217 | "Content-Type": "application/json", 218 | Accept: "*/*", 219 | }, 220 | body: JSON.stringify({ 221 | experimentID: "${expId}", 222 | filename: "UNIQUE_FILENAME.csv", 223 | data: dataAsString, 224 | }), 225 | });`} 226 | 227 | 228 | 229 | 230 | 231 | 232 | Use fetch to send base64 data. The server will decode the 233 | base64 and send the decoded file to the OSF. Use the 234 | appropriate file extension in the file name. 235 | 236 | 237 | {` 238 | fetch("https://pipe.jspsych.org/api/base64/", { 239 | method: "POST", 240 | headers: { 241 | "Content-Type": "application/json", 242 | Accept: "*/*", 243 | }, 244 | body: JSON.stringify({ 245 | experimentID: "${expId}", 246 | filename: "UNIQUE_FILENAME.webm", 247 | data: base64DataString, 248 | }), 249 | });`} 250 | 251 | 252 | 253 | 254 | 255 | Use fetch to request the next condition number. 256 | 257 | {` 258 | const response = await fetch("https://pipe.jspsych.org/api/condition/", { 259 | method: "POST", 260 | headers: { 261 | "Content-Type": "application/json", 262 | Accept: "*/*", 263 | }, 264 | body: JSON.stringify({ 265 | experimentID: "${expId}", 266 | }), 267 | });`} 268 | 269 | 270 | This request is asynchronous, so you will need to wrap this in 271 | an async function. If the request is successful, the response 272 | will be a JSON object with a condition property. The value of 273 | this property will be the condition number. 274 | 275 | 276 | {` 277 | if(!response.error){ 278 | const condition = response.condition; 279 | } 280 | `} 281 | 282 | 283 | 284 | 285 | 286 | )} 287 |
288 | ); 289 | } 290 | -------------------------------------------------------------------------------- /components/dashboard/ExperimentActive.js: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormLabel, 4 | HStack, 5 | Switch, 6 | Stack, 7 | Heading, 8 | NumberInput, 9 | NumberInputField, 10 | NumberInputStepper, 11 | NumberIncrementStepper, 12 | NumberDecrementStepper, 13 | } from "@chakra-ui/react"; 14 | 15 | import { useState } from "react"; 16 | 17 | import { setDoc, doc } from "firebase/firestore"; 18 | 19 | import { db } from "../../lib/firebase"; 20 | 21 | export default function ExperimentActive({ data }) { 22 | const [sessionLimitActive, setSessionLimitActive] = useState( 23 | data.limitSessions 24 | ); 25 | const [experimentActive, setExperimentActive] = useState(data.active); 26 | const [base64Active, setBase64Active] = useState(data.activeBase64 || false); 27 | const [conditionActive, setConditionActive] = useState( 28 | "activeConditionAssignment" in data 29 | ? data.activeConditionAssignment 30 | : data.nConditions > 1 31 | ); 32 | const [maxSessions, setMaxSessions] = useState(data.maxSessions); 33 | const [nConditions, setNConditions] = useState(data.nConditions); 34 | 35 | return ( 36 | 44 | Status 45 | 46 | Enable data collection? 47 | { 52 | setExperimentActive(e.target.checked); 53 | toggleExperimentActive(data.id, e.target.checked); 54 | }} 55 | /> 56 | 57 | 58 | 59 | 60 | Enable base64 data collection? 61 | 62 | { 67 | setBase64Active(e.target.checked); 68 | toggleBase64Active(data.id, e.target.checked); 69 | }} 70 | /> 71 | 72 | 73 | 74 | 75 | Enable condition assignment? 76 | 77 | { 82 | setConditionActive(e.target.checked); 83 | updateConditionActive(data.id, e.target.checked); 84 | }} 85 | /> 86 | 87 | {conditionActive && ( 88 | 89 | How many conditions? 90 | { 94 | setNConditions(value); 95 | if (value !== "" && parseInt(value) >= 0) { 96 | updateNConditions(data.id, value); 97 | } 98 | }} 99 | onBlur={(e) => { 100 | if (e.target.value === "") { 101 | setNConditions(2); 102 | updateNConditions(data.id, 0); 103 | } 104 | }} 105 | > 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | )} 114 | 115 | 116 | Enable session limit? 117 | { 122 | setSessionLimitActive(e.target.checked); 123 | updateSessionLimitActive(data.id, e.target.checked); 124 | }} 125 | /> 126 | 127 | {sessionLimitActive && ( 128 | 129 | How many total sessions? 130 | { 134 | setMaxSessions(value); 135 | if (value !== "" && parseInt(value) >= 0) { 136 | updateMaxSessions(data.id, value); 137 | } 138 | }} 139 | onBlur={(e) => { 140 | if (e.target.value === "") { 141 | setMaxSessions(0); 142 | updateMaxSessions(data.id, 0); 143 | } 144 | }} 145 | > 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | )} 154 | 155 | ); 156 | } 157 | 158 | async function toggleExperimentActive(expId, active) { 159 | if (active) { 160 | activateExperiment(expId); 161 | } else { 162 | deactivateExperiment(expId); 163 | } 164 | } 165 | 166 | async function activateExperiment(expId) { 167 | try { 168 | await setDoc( 169 | doc(db, `experiments/${expId}`), 170 | { 171 | active: true, 172 | }, 173 | { merge: true } 174 | ); 175 | } catch (error) { 176 | console.error(error); 177 | } 178 | } 179 | 180 | async function deactivateExperiment(expId) { 181 | try { 182 | await setDoc( 183 | doc(db, `experiments/${expId}`), 184 | { 185 | active: false, 186 | }, 187 | { merge: true } 188 | ); 189 | } catch (error) { 190 | console.error(error); 191 | } 192 | } 193 | 194 | async function toggleBase64Active(expId, active) { 195 | if (active) { 196 | activateBase64(expId); 197 | } else { 198 | deactivateBase64(expId); 199 | } 200 | } 201 | 202 | async function activateBase64(expId) { 203 | try { 204 | await setDoc( 205 | doc(db, `experiments/${expId}`), 206 | { 207 | activeBase64: true, 208 | }, 209 | { merge: true } 210 | ); 211 | } catch (error) { 212 | console.error(error); 213 | } 214 | } 215 | 216 | async function deactivateBase64(expId) { 217 | try { 218 | await setDoc( 219 | doc(db, `experiments/${expId}`), 220 | { 221 | activeBase64: false, 222 | }, 223 | { merge: true } 224 | ); 225 | } catch (error) { 226 | console.error(error); 227 | } 228 | } 229 | 230 | async function updateSessionLimitActive(expId, active) { 231 | try { 232 | await setDoc( 233 | doc(db, `experiments/${expId}`), 234 | { 235 | limitSessions: active, 236 | }, 237 | { merge: true } 238 | ); 239 | } catch (error) { 240 | console.error(error); 241 | } 242 | } 243 | 244 | async function updateMaxSessions(expId, maxSessions) { 245 | try { 246 | await setDoc( 247 | doc(db, `experiments/${expId}`), 248 | { 249 | maxSessions: parseInt(maxSessions), 250 | }, 251 | { merge: true } 252 | ); 253 | } catch (error) { 254 | console.error(error); 255 | } 256 | } 257 | 258 | async function updateConditionActive(expId, active) { 259 | try { 260 | await setDoc( 261 | doc(db, `experiments/${expId}`), 262 | { 263 | activeConditionAssignment: active, 264 | }, 265 | { merge: true } 266 | ); 267 | } catch (error) { 268 | console.error(error); 269 | } 270 | } 271 | 272 | async function updateNConditions(expId, nConditions) { 273 | try { 274 | await setDoc( 275 | doc(db, `experiments/${expId}`), 276 | { 277 | nConditions: parseInt(nConditions), 278 | }, 279 | { merge: true } 280 | ); 281 | } catch (error) { 282 | console.error(error); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /components/dashboard/ExperimentInfo.js: -------------------------------------------------------------------------------- 1 | import { Stack, HStack, Text, Link, Heading } from "@chakra-ui/react"; 2 | 3 | import { ExternalLinkIcon } from "@chakra-ui/icons"; 4 | 5 | export default function ExperimentInfo({ data }) { 6 | return ( 7 | 15 | Info 16 | 17 | Experiment ID 18 | {data.id} 19 | 20 | 21 | OSF Project 22 | 23 | {`https://osf.io/${data.osfRepo}`} 24 | 25 | 26 | 27 | OSF Data Component 28 | 33 | {`https://osf.io/${data.osfComponent}`} 34 | 35 | 36 | 37 | Completed Sessions 38 | {data.sessions} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/dashboard/ExperimentValidation.js: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormLabel, 4 | HStack, 5 | Switch, 6 | Stack, 7 | Heading, 8 | Textarea, 9 | FormHelperText, 10 | Checkbox, 11 | CheckboxGroup, 12 | Button, 13 | requiredChakraThemeKeys, 14 | } from "@chakra-ui/react"; 15 | 16 | import { useEffect, useState } from "react"; 17 | 18 | import { doc, setDoc } from "firebase/firestore"; 19 | import { db } from "../../lib/firebase"; 20 | 21 | export default function ExperimentValidation({ data }) { 22 | const validationOptionsArray = []; 23 | if (data.allowCSV) validationOptionsArray.push("csv"); 24 | if (data.allowJSON) validationOptionsArray.push("json"); 25 | 26 | const requiredFieldsText = data.requiredFields.join(", "); 27 | 28 | const [validationSettings, setValidationSettings] = useState( 29 | validationOptionsArray 30 | ); 31 | const [requiredFields, setRequiredFields] = useState(requiredFieldsText); 32 | const [validationEnabled, setValidationEnabled] = useState( 33 | data.useValidation 34 | ); 35 | 36 | const [fieldsArray, setFieldsArray] = useState(data.requiredFields); 37 | 38 | useEffect(() => { 39 | async function handleSave() { 40 | // split array and remove all whitespace 41 | 42 | const settings = { 43 | useValidation: validationEnabled, 44 | allowJSON: validationSettings.includes("json"), 45 | allowCSV: validationSettings.includes("csv"), 46 | requiredFields: fieldsArray, 47 | }; 48 | 49 | try { 50 | await setDoc(doc(db, `experiments/${data.id}`), settings, { 51 | merge: true, 52 | }); 53 | } catch (error) { 54 | console.error(error); 55 | } 56 | } 57 | handleSave(); 58 | }, [validationEnabled, validationSettings, fieldsArray, data]); 59 | 60 | return ( 61 | 69 | Data Validation 70 | 76 | Enable data validation? 77 | { 82 | setValidationEnabled(e.target.checked); 83 | }} 84 | /> 85 | 86 | {validationEnabled && ( 87 | <> 88 | { 92 | setValidationSettings(e); 93 | }} 94 | colorScheme="brandTeal" 95 | > 96 | 97 | Allow JSON 98 | Allow CSV 99 | 100 | 101 | 102 | Required Fields 103 |