├── .eslintignore ├── Procfile ├── __mocks__ ├── mockStyle.js ├── mockFile.js ├── testInputObj.js └── mockServer.js ├── v2.0.0.png ├── periqles-logo.png ├── __tests__ ├── jest-teardown.js ├── jest-setup.js ├── PeriqlesForm.test.js ├── introspect.test.js └── Functions.test.js ├── index.js ├── babel.config.js ├── .npmignore ├── .babelrc ├── .gitignore ├── .eslintrc.js ├── src ├── PeriqlesField.tsx ├── PeriqlesForm.tsx └── functions.tsx ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── LICENSE ├── tsconfig.json ├── webpack.config.js ├── jest.config.js ├── periqles.css ├── types └── index.d.ts ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: cd ./demo && npm run start:prod -------------------------------------------------------------------------------- /__mocks__/mockStyle.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__mocks__/mockFile.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /v2.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/periqles/HEAD/v2.0.0.png -------------------------------------------------------------------------------- /periqles-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/periqles/HEAD/periqles-logo.png -------------------------------------------------------------------------------- /__tests__/jest-teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async (globalConfig) => { 2 | testServer.close(); 3 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * periqles 3 | * Copyright(c) 2021 OS Labs 4 | * MIT Licensed 5 | */ 6 | 'use strict'; 7 | 8 | module.exports = require('./_bundles/periqles.js'); -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [[ 3 | '@babel/preset-env', 4 | { 5 | targets:{ 6 | node: "current", 7 | } 8 | } 9 | ]] 10 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/webpack.config.js 4 | src/ 5 | server/ 6 | __tests__/ 7 | periqles-logo.png 8 | package-lock.json 9 | demo/** 10 | periqles-**.tgz 11 | Procfile 12 | .env 13 | .github/ -------------------------------------------------------------------------------- /__tests__/jest-setup.js: -------------------------------------------------------------------------------- 1 | const {Response, Request, Headers, fetch} = require('whatwg-fetch'); 2 | ; 3 | 4 | module.exports = async () => { 5 | global.testServer = await require('./__mocks__/mockServer.js'); 6 | global.Response = Response; 7 | global.Request = Request; 8 | global.Headers = Headers; 9 | global.fetch = fetch; 10 | 11 | }; 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-transform-runtime", 4 | "@babel/plugin-proposal-class-properties", 5 | "@babel/proposal-object-rest-spread", 6 | "@babel/plugin-transform-react-jsx", 7 | "relay" 8 | // "@babel/plugin-transform-typescript" 9 | ], 10 | "presets": ["@babel/preset-react", "@babel/preset-env", "@babel/preset-typescript"] 11 | } 12 | 13 | // optional alternative to ts-loader: "@babel/preset-typescript" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib-esm 3 | lib 4 | _bundles 5 | .eslintrc.js 6 | .eslintignore 7 | ./package-lock.json 8 | **/yarn.lock 9 | .DS_Store 10 | .npmignore 11 | ApolloPF.tsx 12 | periqles-**.tgz 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Optional npm cache directory 20 | .npm 21 | 22 | # Optional eslint cache 23 | .eslintcache 24 | 25 | # dotenv environment variables file 26 | .env 27 | .env.test 28 | 29 | # demo 30 | demo/ 31 | demo/dist/ 32 | todo/ 33 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:prettier/recommended', 4 | 'plugin:react/recommended', 5 | 'prettier/react', 6 | 'plugin:@typescript-eslint/eslint-recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | ], 9 | parser: '@babel/eslint-parser', 10 | plugins: [ "babel", 'prettier', 'react', "react-hooks", '@typescript-eslint'], 11 | rules: { 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "warn" 14 | }, 15 | settings: { 16 | react: { 17 | version: '17.0.1', 18 | }, 19 | }, 20 | } -------------------------------------------------------------------------------- /src/PeriqlesField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {generateDefaultElement, generateSpecifiedElement} from './functions'; 3 | 4 | const PeriqlesField = ({ 5 | field, 6 | specs, 7 | formState, 8 | setFormState, 9 | handleChange, 10 | }: PeriqlesFieldProps): JSX.Element => { 11 | return (specs 12 | ? generateSpecifiedElement({ 13 | field, 14 | specs, 15 | formState, 16 | setFormState, 17 | handleChange, 18 | }) 19 | : generateDefaultElement({ 20 | field, 21 | formState, 22 | handleChange})); 23 | }; 24 | 25 | export default PeriqlesField; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /__mocks__/testInputObj.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'AddUserInput', 3 | inputFields: [ 4 | { 5 | name: 'pizzaTopping', 6 | type: { 7 | name: null, 8 | kind: 'NON_NULL', 9 | ofType: { 10 | name: 'PizzaToppingEnum', 11 | kind: 'ENUM', 12 | enumValues: [ 13 | { 14 | name: 'BUFFALO_CHICKEN', 15 | }, 16 | { 17 | name: 'PEPPERONI', 18 | }, 19 | { 20 | name: 'MEATLOVERS', 21 | }, 22 | { 23 | name: 'EGGPLANT_PARM', 24 | }, 25 | { 26 | name: 'OLIVES', 27 | }, 28 | { 29 | name: 'HAWAIIAN', 30 | }, 31 | ], 32 | }, 33 | }, 34 | }, 35 | { 36 | name: 'clientMutationId', 37 | type: { 38 | name: 'String', 39 | kind: 'SCALAR', 40 | ofType: null, 41 | }, 42 | }, 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib/", // path to output directory 4 | "sourceMap": true, // allow sourcemap support 5 | "module": "es6", // syntax for generated modules 6 | "jsx": "react", // use typescript to transpile jsx to js 7 | "target": "es5", // specify ECMAScript target version 8 | "lib": ["es2015", "dom"], // include ambient type declarations for JS constructs common in runtimes and the DOM 9 | "allowJs": true, // infer types from .js files 10 | "moduleResolution": "node", // search under node_modules to resolve non-relative imports 11 | "allowSyntheticDefaultImports": true, 12 | // Ensure that Babel can safely transpile files in the TypeScript project 13 | "isolatedModules": true, 14 | // Import non-ES modules as default imports. 15 | "esModuleInterop": true, 16 | // specify where to look for .d.ts files 17 | "typeRoots": [ 18 | "types", 19 | "types/index.d.ts", 20 | "node_modules/@types" 21 | ] 22 | }, 23 | "files": [ 24 | "types/index.d.ts", 25 | "./src/functions.tsx", 26 | "./src/PeriqlesField.tsx", 27 | "src/PeriqlesForm.tsx", 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: './src/PeriqlesForm.tsx', 6 | // entry: { 7 | // 'periqles-lib': './src/PeriqlesForm.tsx', 8 | // 'periqles-lib.min': './src/PeriqlesForm.tsx' 9 | // }, 10 | output: { 11 | path: path.resolve(__dirname, '_bundles'), 12 | filename: 'periqles.js', 13 | library: 'periqles', // tell webpack we're bundling a library and provide the name 14 | libraryTarget: 'umd', // = universal module definition (universal compatibility) 15 | umdNamedDefine: true, 16 | }, 17 | resolve: { 18 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | use: ['babel-loader', 'ts-loader'], 25 | exclude: /node_modules/, 26 | }, 27 | { 28 | test: /\.jsx?$/, 29 | use: ['babel-loader'], 30 | exclude: /node_modules/, 31 | }, 32 | { 33 | test: /\.(css)$/, 34 | use: ['style-loader', 'css-loader'], 35 | }, 36 | ], 37 | }, 38 | devtool: 'source-map', 39 | externals: 'react', // enables periqles to use host project's copy of React 40 | }; 41 | 42 | // can add fork-ts-checker-webpack-plugin to ts-loader for faster build times: https://github.com/TypeStrong/ts-loader#faster-builds -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // import jestConfig from 'jest-config'; 2 | // const {defaults} = jestConfig; 3 | const {defaults} = require('jest-config'); 4 | 5 | module.exports = { 6 | roots: ['./__tests__'], 7 | globals: { 8 | 'ts-jest': { 9 | // Tell ts-jest about our typescript config. 10 | // You can specify a path to your tsconfig.json file, 11 | // but since we're compiling specifically for node here, 12 | // this works too. 13 | tsConfig: { 14 | target: './tsconfig.json', 15 | }, 16 | }, 17 | }, 18 | // Transforms tell jest how to process our non-javascript files. 19 | // Here we're using babel for .js and .jsx files, and ts-jest for 20 | // .ts and .tsx files. You *can* just use babel-jest for both, if 21 | // you already have babel set up to compile typescript files. 22 | transform: { 23 | // '^.+\\.tsx?$': 'babel-jest', 24 | // '^.+\\.tsx?$': 'ts-jest', 25 | // If you're using babel for both: 26 | '^.+\\.[jt]sx?$': 'babel-jest', 27 | }, 28 | // In webpack projects, we often allow importing things like css files or jpg 29 | // files, and let a webpack loader plugin take care of loading these resources. 30 | // In a unit test, though, we're running in node.js which doesn't know how 31 | // to import these, so this tells jest what to do for these. 32 | moduleNameMapper: { 33 | // Resolve .css and similar files to identity-obj-proxy instead. 34 | '\\.(css|less)$': "identity-obj-proxy" , 35 | // Resolve .jpg and similar files to __mocks__/file-mock.js 36 | '.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': `./__mocks__/mockFile.js`, 37 | }, 38 | // Tells Jest what folders to ignore for tests 39 | testPathIgnorePatterns: [`node_modules`, `\\.cache`], 40 | testURL: `http://localhost`, 41 | moduleFileExtensions: [ 42 | ...defaults.moduleFileExtensions, 43 | '.ts', 44 | '.tsx', 45 | '.js', 46 | '.jsx', 47 | ], 48 | setupFiles: ['./__tests__/jest-setup.js'], 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /periqles.css: -------------------------------------------------------------------------------- 1 | .PeriqlesForm { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | padding: 4%; 6 | text-align: left; 7 | } 8 | 9 | .PeriqlesForm, .PeriqlesForm * { 10 | box-sizing: border-box; 11 | } 12 | 13 | .PeriqlesForm label, .periqles-radio { 14 | font-weight: bold; 15 | font-family: inherit; 16 | display: flex; 17 | flex-direction: column; 18 | width: 100%; 19 | margin: 10px 0; 20 | } 21 | 22 | .PeriqlesForm input, .periqles-select, .periqles-textarea, .periqles-submit { 23 | margin: 10px 0; 24 | padding: .5em 1em .5em 1em; 25 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 26 | border: none; 27 | } 28 | 29 | .PeriqlesForm input:hover, .periqles-select:hover, .periqles-textarea:hover, .periqles-submit:hover { 30 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 8px; 31 | } 32 | 33 | .periqles-textarea { 34 | height: 100px; 35 | font-family: inherit; 36 | font-size: inherit; 37 | color: inherit; 38 | } 39 | 40 | .periqles-select { 41 | font-family: inherit; 42 | display: inline-block; 43 | width: 100%; 44 | } 45 | 46 | .periqles-radio:hover, .periqles-select:hover { 47 | cursor: pointer; 48 | } 49 | 50 | .periqles-radio-option { 51 | margin: 0 5px 0 0 !important; 52 | box-shadow: none !important; 53 | cursor: pointer; 54 | } 55 | 56 | .periqles-radio-option-label { 57 | display: inline-block !important; 58 | margin: 5px 0 5px 2% !important; 59 | } 60 | 61 | .periqles-radio-div-label { 62 | margin: 0px 0 5px 0 !important; 63 | } 64 | 65 | .periqles-checkbox { 66 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 67 | } 68 | 69 | .periqles-date { 70 | font-family: inherit; 71 | } 72 | 73 | .periqles-submit { 74 | display:inline-block; 75 | width: fit-content; 76 | box-sizing: border-box; 77 | text-decoration:none; 78 | font-family: inherit; 79 | font-weight: bold; 80 | text-align:center; 81 | cursor: pointer; 82 | align-self:flex-end; 83 | } 84 | 85 | .periqles-submit:hover { 86 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 8px; 87 | } 88 | 89 | .periqles-file::-webkit-file-upload-button { 90 | visibility: hidden; 91 | } 92 | .periqles-file::before { 93 | content: "Upload file"; 94 | display: inline-block; 95 | background: linear-gradient(top, #f9f9f9, #e3e3e3); 96 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 97 | /* border-radius: 1em; 98 | border: 1px solid black; */ 99 | padding: 5px 8px; 100 | white-space: nowrap; 101 | -webkit-user-select: none; 102 | font-family: inherit; 103 | cursor: pointer; 104 | text-shadow: 1px 1px #fff; 105 | font-weight: 700; 106 | font-size: 10pt; 107 | } 108 | 109 | .periqles-file:hover::before { 110 | border-color: black; 111 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 112 | } 113 | 114 | .periqles-file:active::before { 115 | background: whitesmoke; 116 | box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 117 | } 118 | 119 | .periqles-color { 120 | background-color: transparent; 121 | } 122 | 123 | .periqles-color::before { 124 | content: "Pick a color"; 125 | display: inline-block; 126 | background: linear-gradient(top, #f9f9f9, #e3e3e3); 127 | border: 1px solid #999; 128 | padding: 5px 8px; 129 | white-space: nowrap; 130 | -webkit-user-select: none; 131 | cursor: pointer; 132 | text-shadow: 1px 1px #fff; 133 | font-weight: 700; 134 | font-size: 10pt; 135 | font-family: inherit; 136 | } 137 | 138 | .periqles-datetime-local{ 139 | border: .5px solid black; 140 | font-family: inherit; 141 | font-weight: bold; 142 | padding: .12em; 143 | overflow: visible; 144 | } 145 | 146 | .periqles-range { 147 | color: gray; 148 | } 149 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for periqles v2.x 2 | 3 | // Externally available types 4 | type PeriqlesForm = (props: PeriqlesFormProps) => JSX.Element; 5 | 6 | interface PeriqlesFormProps { 7 | mutationName: string; 8 | environment?: RelayEnvironment; 9 | mutationGQL?: string | object; 10 | useMutation?: any, 11 | specifications?: PeriqlesSpecifications; 12 | args?: PeriqlesMutationArgs; 13 | callbacks?: PeriqlesCallbacks; 14 | } 15 | 16 | interface RelayEnvironment { 17 | store: any; 18 | networkLayer: any; 19 | handlerProvider?: any; 20 | } 21 | 22 | interface PeriqlesSpecifications { 23 | header?: string; 24 | fields?: Record; 25 | } 26 | 27 | type PeriqlesMutationArgs = Record; 28 | 29 | interface PeriqlesCallbacks { 30 | onSuccess?: (response: object) => any; 31 | onFailure?: (err: object) => any; 32 | } 33 | 34 | interface PeriqlesFieldSpecs { 35 | element?: string; 36 | label?: string; 37 | options?: PeriqlesOptionSpec[]; 38 | render?: (params: { 39 | formState: FormState, 40 | setFormState: React.Dispatch>, 41 | handleChange: (e) => void, 42 | }) => JSX.Element; 43 | src?: string; 44 | min?: number; 45 | max?: number; 46 | } 47 | 48 | interface PeriqlesOptionSpec { 49 | label: string; 50 | value: number | string | boolean; 51 | } 52 | 53 | type FormState = Record; 54 | 55 | 56 | // Types used internally by PeriqlesForm 57 | 58 | type PeriqlesField = (props: PeriqlesFieldProps) => JSX.Element; 59 | 60 | interface PeriqlesFieldProps { 61 | field: PeriqlesFieldInfo; 62 | formState: FormState; 63 | handleChange: (e) => void; 64 | specs?: PeriqlesFieldSpecs; 65 | setFormState: React.Dispatch>; 66 | } 67 | 68 | // input field info introspected from schema 69 | // used to build HTML for each PeriqlesField component 70 | interface PeriqlesFieldInfo { 71 | name: string; 72 | label?: string; 73 | type?: string; 74 | options?: PeriqlesFieldOption[]; 75 | required?: boolean; 76 | } 77 | 78 | // options prepared for dropdowns/radio buttons 79 | interface PeriqlesFieldOption { 80 | name: string; 81 | label: string; 82 | value: number | string | boolean; 83 | type: string; 84 | } 85 | 86 | 87 | // helper functions 88 | 89 | type FieldsArrayGenerator = ( 90 | inputType: InputType, 91 | args: PeriqlesMutationArgs, 92 | ) => PeriqlesFieldInfo[]; 93 | 94 | type GenerateDefaultElement = (params: { 95 | field: PeriqlesFieldInfo, 96 | formState: FormState, 97 | handleChange: (e) => void, 98 | }) => JSX.Element; 99 | 100 | type GenerateSpecifiedElement = (params: { 101 | field: PeriqlesFieldInfo, 102 | specs: PeriqlesFieldSpecs, 103 | formState: FormState, 104 | handleChange: (e) => void, 105 | setFormState: React.Dispatch>, 106 | }) => JSX.Element; 107 | 108 | 109 | // data expected from introspection query 110 | 111 | interface InputType { 112 | name: string; 113 | inputFields: InputField[]; 114 | } 115 | 116 | interface InputField { 117 | name: string; 118 | type: GraphQLType; 119 | } 120 | 121 | interface GraphQLType { 122 | name: string; 123 | kind: string; 124 | ofType?: GraphQLOfType; 125 | enumValues?: EnumValue[]; 126 | } 127 | 128 | interface GraphQLOfType { 129 | name: string; 130 | kind: string; 131 | enumValues?: EnumValue[]; 132 | } 133 | 134 | // Although EnumValue's one propety is called "name", it actuallly holds a value. 135 | interface EnumValue { 136 | name: number | string | boolean; 137 | } 138 | 139 | // commitMutation parameters 140 | type Input = Record; 141 | 142 | interface Variables { 143 | input: Input; 144 | } 145 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "periqles", 3 | "version": "2.1.2", 4 | "description": "React form library for GraphQL APIs using Apollo or Relay client", 5 | "main": "index.js", 6 | "types": "./types/index.d.ts", 7 | "engines": { 8 | "node": "*" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/oslabs-beta/periqles" 13 | }, 14 | "maintainers": [ 15 | { 16 | "name": "Joe Toledano" 17 | }, 18 | { 19 | "name": "Kelly Porter" 20 | }, 21 | { 22 | "name": "Ian Garrett" 23 | }, 24 | { 25 | "name": "Cameron Baumgartner" 26 | } 27 | ], 28 | "license": "MIT", 29 | "keywords": [ 30 | "react", 31 | "relay", 32 | "apollo", 33 | "graphql", 34 | "forms", 35 | "component" 36 | ], 37 | "scripts": { 38 | "tsc": "tsc", 39 | "clean": "shx rm -rf _bundles lib lib-esm", 40 | "build": "npm run clean && tsc && tsc -m es6 --outDir lib-esm && webpack", 41 | "publish:local": "npm run build && npm pack && cp periqles-2.1.0.tgz ~", 42 | "lint": "eslint ./src ./types", 43 | "test": "jest --verbose --testURL=http://localhost:3005 --detectOpenHandles", 44 | "prepare": "npm run build", 45 | "test-async": "jest --verbose --detectOpenHandles", 46 | "test-serverRunning": "node ./__mocks__/mockServer.js & jest --verbose" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.13.1", 50 | "@babel/eslint-parser": "^7.12.17", 51 | "@babel/plugin-proposal-class-properties": "^7.12.13", 52 | "@babel/plugin-proposal-object-rest-spread": "^7.12.13", 53 | "@babel/plugin-transform-runtime": "^7.12.17", 54 | "@babel/plugin-transform-typescript": "^7.12.17", 55 | "@babel/preset-env": "^7.13.5", 56 | "@babel/preset-react": "^7.12.7", 57 | "@babel/preset-typescript": "^7.12.17", 58 | "@testing-library/dom": "^7.29.6", 59 | "@testing-library/jest-dom": "^5.11.9", 60 | "@testing-library/react": "^11.2.5", 61 | "@types/react": "^17.0.2", 62 | "@types/react-dom": "^17.0.1", 63 | "@typescript-eslint/eslint-plugin": "^4.15.1", 64 | "@typescript-eslint/parser": "^4.15.1", 65 | "babel-jest": "^26.6.3", 66 | "babel-loader": "^8.2.2", 67 | "babel-plugin-relay": "^10.1.3", 68 | "cors": "^2.8.5", 69 | "css-loader": "^5.0.2", 70 | "eslint": "^7.14.0", 71 | "eslint-config-prettier": "^7.2.0", 72 | "eslint-plugin-babel": "^5.3.1", 73 | "eslint-plugin-prettier": "^3.3.1", 74 | "eslint-plugin-react": "^7.21.5", 75 | "eslint-plugin-react-hooks": "^4.2.0", 76 | "express": "^4.17.1", 77 | "express-graphql": "^0.12.0", 78 | "graphql": "^15.5.0", 79 | "identity-obj-proxy": "^3.0.0", 80 | "jest": "^26.6.3", 81 | "path": "^0.12.7", 82 | "prettier": "^2.2.1", 83 | "react": "^17.0.1", 84 | "react-dom": "^17.0.1", 85 | "relay-test-utils": "^10.1.3", 86 | "shx": "^0.3.3", 87 | "style-loader": "^2.0.0", 88 | "ts-loader": "^8.0.14", 89 | "typescript": "^4.1.3", 90 | "webpack": "^5.0.0", 91 | "webpack-cli": "^4.5.0", 92 | "whatwg-fetch": "^3.6.1", 93 | "supertest": "^6.1.3" 94 | }, 95 | "dependencies": { 96 | "react-relay": "^10.1.0" 97 | }, 98 | "peerDependencies": { 99 | "react": ">=16.8.0" 100 | }, 101 | "prettier": { 102 | "singleQuote": true, 103 | "trailingComma": "all", 104 | "bracketSpacing": true, 105 | "jsxBracketSameLine": true 106 | }, 107 | "jest": { 108 | "globalSetup": "./__tests__/jest-setup.js", 109 | "globalTeardown": "./__tests__/jest-teardown.js", 110 | "moduleNameMapper": { 111 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 112 | "\\.(css|less)$": "/__mocks__/styleMock.js" 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /__tests__/PeriqlesForm.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-lone-blocks */ 2 | import * as React from 'react'; 3 | import ReactDOMServer from 'react-dom/server'; 4 | import app from '../__mocks__/mockServer' 5 | import { 6 | render, 7 | screen, 8 | fireEvent, 9 | waitForElement, 10 | waitFor, 11 | findByRole, 12 | getByRole, 13 | getByText, 14 | getByLabelText, 15 | } from '@testing-library/react'; 16 | import '@testing-library/jest-dom/extend-expect'; 17 | 18 | //components to test 19 | import PeriqlesForm from '../src/PeriqlesForm.tsx'; 20 | 21 | // //React Component Tests 22 | 23 | const props = { 24 | environment: 'modernEnvironment', 25 | mutationName: 'AddUser', 26 | mutationGQL: "mutationGQL", 27 | }; 28 | 29 | const specifications = { 30 | header: 'Sign Up', 31 | fields: { 32 | gender: { 33 | element: 'radio', 34 | label: 'Gender', 35 | options: [ 36 | {label: 'Non-binary', value: 'NON_BINARY'}, 37 | {label: 'Male', value: 'MALE'}, 38 | {label: 'Female', value: 'FEMALE'}, 39 | ], 40 | }, 41 | }, 42 | }; 43 | 44 | //PeriqlesForm Tests 45 | describe('Periqles Test', () => { 46 | it('Should render a Form with the class of PeriqlesForm', () => { 47 | const {container} = render(); 48 | expect(container.querySelector('form')).toBeInTheDocument() 49 | expect(container.querySelector('form').getAttribute('class')).toBe('PeriqlesForm') 50 | }); 51 | 52 | xit('Should render "Loading form..." if introspection is pending/unsuccessful', async () => { 53 | const {getByText} = render() 54 | await waitFor(() => { 55 | expect(getByText('Loading form...')).toBeInTheDocument() 56 | }) 57 | }); 58 | 59 | it('Should render form inputs if introspection is successful', async () => { 60 | const {getByText, getAllByRole} = render() 61 | await waitFor(() => { 62 | expect(getByText('Pizza Topping')).toBeInTheDocument() 63 | expect(getAllByRole('combobox').length).toBeTruthy() 64 | }) 65 | }); 66 | 67 | it('Should render a form with a select tag and options if the passed in mutation has an ofType kind of Enum', async () => { 68 | const {getByText, getAllByRole} = render() 69 | await waitFor(() => { 70 | expect(getByText('Pizza Topping')).toBeInTheDocument() 71 | expect(getAllByRole('combobox').length).toBeTruthy() 72 | }) 73 | }) 74 | 75 | it('Should render a form with a text input if mutation input has a Type of String', async () => { 76 | const {getAllByRole} = render() 77 | await waitFor(() => { 78 | expect(getAllByRole('textbox').length).toBeTruthy() 79 | }) 80 | }) 81 | 82 | it('Should render a form with a number input if mutation input has a Type of Int', async () => { 83 | const {getByRole} = render() 84 | await waitFor(() => { 85 | expect(getByRole('spinbutton')).toBeInTheDocument() 86 | }) 87 | }) 88 | 89 | it('Should render a form with a string input if mutation input type is not handled', async () => { 90 | const {container} = render() 91 | await waitFor(() => { 92 | let match; 93 | container.querySelectorAll('input').forEach(input => { 94 | if (input.getAttribute('name') === 'username'){ 95 | match = input 96 | } 97 | }) 98 | expect(match.getAttribute('name')).toBe('username') 99 | expect(match.getAttribute('type')).toBe('text') 100 | }) 101 | }) 102 | 103 | it('Should render user provided header from specifications prop', async () => { 104 | const {getByText} = render() 105 | await waitFor(() => { 106 | expect(getByText('Sign Up')).toBeInTheDocument() 107 | }) 108 | }) 109 | 110 | it('Should render user provided inputs from specifications prop', async () => { 111 | const {getAllByRole} = render() 112 | await waitFor(() => { 113 | expect(getAllByRole('radio').length).toBeTruthy() 114 | }) 115 | }) 116 | }); 117 | -------------------------------------------------------------------------------- /__mocks__/mockServer.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const expressGraphql = require('express-graphql'); 3 | const path = require('path'); 4 | const cors = require('cors'); 5 | const {graphqlHTTP} = expressGraphql; 6 | const app = express(); 7 | 8 | app.use(express.json()); 9 | app.use(express.urlencoded()); 10 | 11 | // var corsOptions = { 12 | // origin: 'http://localhost:8080', // TODO 13 | // }; 14 | 15 | // app.use(cors(corsOptions)); 16 | app.use(cors()); 17 | 18 | // console.log server requests 19 | // app.use('*', (req, res, next) => { 20 | // console.log('Incoming request body:', req.body); 21 | // return next(); 22 | // }); 23 | 24 | // Serve static assets 25 | app.use(express.static(path.resolve(__dirname, '/', 'public'))); 26 | 27 | // only needed when in production mode 28 | if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === undefined) { 29 | app.use('/', express.static(path.join(__dirname, 'public/*'))); 30 | app.use('/dist/', express.static(path.join(__dirname, 'dist'))); 31 | } 32 | 33 | //MOCK INTROSPECTION RESPONSE 34 | const mockResponse = { 35 | data: { 36 | __type: { 37 | name: 'AddUserInput', 38 | inputFields: [ 39 | { 40 | name: 'pizzaTopping', 41 | type: { 42 | name: null, 43 | kind: 'NON_NULL', 44 | ofType: { 45 | name: 'PizzaToppingEnum', 46 | kind: 'ENUM', 47 | enumValues: [ 48 | { 49 | name: 'BUFFALO_CHICKEN', 50 | }, 51 | { 52 | name: 'PEPPERONI', 53 | }, 54 | { 55 | name: 'MEATLOVERS', 56 | }, 57 | { 58 | name: 'EGGPLANT_PARM', 59 | }, 60 | { 61 | name: 'OLIVES', 62 | }, 63 | { 64 | name: 'HAWAIIAN', 65 | }, 66 | ], 67 | }, 68 | }, 69 | }, 70 | { 71 | name: 'age', 72 | type: { 73 | name: 'Int', 74 | kind: 'SCALAR', 75 | ofType: null, 76 | }, 77 | }, 78 | { 79 | name: 'email', 80 | type: { 81 | name: 'String', 82 | kind: 'SCALAR', 83 | ofType: null, 84 | }, 85 | }, 86 | { 87 | name: 'username', 88 | type: { 89 | name: null, 90 | kind: 'SCALAR', 91 | ofType: null, 92 | }, 93 | }, 94 | { 95 | name: 'gender', 96 | type: { 97 | name: null, 98 | kind: 'NON_NULL', 99 | ofType: { 100 | name: 'GenderEnum', 101 | kind: 'ENUM', 102 | enumValues: [ 103 | { 104 | name: 'NON_BINARY', 105 | }, 106 | { 107 | name: 'FEMALE', 108 | }, 109 | { 110 | name: 'MALE', 111 | }, 112 | ], 113 | }, 114 | }, 115 | }, 116 | ], 117 | } 118 | } 119 | }; 120 | 121 | // Set up GraphQL endpoint for POSTs 122 | // app.post( 123 | // '/graphql', 124 | // graphqlHTTP({ 125 | // schema: schema, 126 | // pretty: true, 127 | // }), 128 | // ); 129 | 130 | //Mocking the server response 131 | app.use( 132 | '/graphql', (req, res) => { 133 | res.status(200).send(mockResponse) 134 | } 135 | ); 136 | 137 | // Send a GET to /graphql to use GraphiQL 138 | // app.get( 139 | // '/graphql', 140 | // graphqlHTTP({ 141 | // schema: schema, 142 | // pretty: true, 143 | // graphiql: true, 144 | // }), 145 | // ); 146 | 147 | app.use(express.json()); 148 | 149 | //ERROR HANDLING 150 | app.use((err, req, res, next) => { 151 | const error = { 152 | log: 'Express error handler caught unknown middleware error', 153 | status: 500, 154 | message: { 155 | err: 'A server error occured', 156 | }, 157 | }; 158 | error.message = err.message; 159 | if (err.status) error.status = err.status; 160 | 161 | console.log('SERVER ERROR: ', error.message); 162 | res.status(error.status).send(error.message); 163 | }); 164 | 165 | const PORT = 3005; 166 | app.listen(PORT, () => { 167 | console.log(`Backend mock server listening on port: ${PORT}`); 168 | }); 169 | module.exports = app; -------------------------------------------------------------------------------- /__tests__/introspect.test.js: -------------------------------------------------------------------------------- 1 | const app = require('../__mocks__/mockServer.js'); 2 | const supertest = require('supertest'); 3 | //const { introspect } = require('../../src/functions.tsx'); 4 | 5 | const request = supertest(app); 6 | 7 | // const PORT = process.env.PORT || 3000; 8 | // app.listen(PORT, () => { 9 | // console.log(`Backend server listening on port: ${PORT}`); 10 | // }); 11 | 12 | const args = { 13 | clientMutationId: '0000' 14 | }; 15 | 16 | const testQuery = { 17 | query: `query typeQuery($inputType: String!) 18 | { 19 | __type(name: $inputType) { 20 | name 21 | inputFields { 22 | name 23 | type { 24 | name 25 | kind 26 | ofType { 27 | name 28 | kind 29 | enumValues { 30 | name 31 | description 32 | } 33 | } 34 | } 35 | } 36 | } 37 | }`, 38 | variables: { 39 | inputType: 'AddUserInput', 40 | }, 41 | }; 42 | 43 | describe('Post request to graphql endpoint w/ introspection query should successfully return schema', () => { 44 | it('Should return a 200 status', async () => { 45 | const res = await request.post('/graphql') 46 | .send(testQuery) 47 | // console.log('this is the response body: ',res.body); 48 | expect(res.status).toBe(200) 49 | }) 50 | it('Should return a response body object that has a data key whose value is an object w a __type key', async () => { 51 | const res = await request.post('/graphql') 52 | .send(testQuery) 53 | expect(Array.isArray(res.body)).toBe(false) 54 | expect(typeof res.body).toBe('object') 55 | 56 | expect(!res.body.data).toBe(false) 57 | expect(Array.isArray(res.body.data)).toBe(false) 58 | expect(typeof res.body.data).toBe('object') 59 | 60 | expect(!res.body.data.__type).toBe(false) 61 | }) 62 | it('The value of the __type key s/b an object with a name property whose value is a string', async () => { 63 | const res = await request.post('/graphql') 64 | .send(testQuery) 65 | expect(Array.isArray(res.body.data.__type)).toBe(false) 66 | expect(typeof res.body.data.__type).toBe('object') 67 | 68 | expect(!res.body.data.__type.name).toBe(false) 69 | expect(typeof res.body.data.__type.name).toBe('string') 70 | }) 71 | it('The value of the __type key s/b an object with an inputFields property whose value is an array of objects', async () => { 72 | const res = await request.post('/graphql') 73 | .send(testQuery) 74 | expect(!res.body.data.__type.inputFields).toBe(false) 75 | expect(Array.isArray(res.body.data.__type.inputFields)).toBe(true) 76 | expect(Array.isArray(res.body.data.__type.inputFields[0])).toBe(false) 77 | expect(typeof res.body.data.__type.inputFields[0]).toBe('object') 78 | }) 79 | it('Objects in the inputFields array should have a name property, whose value is a string', async () => { 80 | const res = await request.post('/graphql') 81 | .send(testQuery) 82 | expect(!res.body.data.__type.inputFields[0].name).toBe(false) 83 | expect(typeof res.body.data.__type.inputFields[0].name).toBe('string') 84 | }) 85 | it('Objects in the inputFields array should have a type property, whose value is an object', async () => { 86 | const res = await request.post('/graphql') 87 | .send(testQuery) 88 | expect(!res.body.data.__type.inputFields[0].type).toBe(false) 89 | expect(Array.isArray(res.body.data.__type.inputFields[0].type)).toBe(false) 90 | expect(typeof res.body.data.__type.inputFields[0].type).toBe('object') 91 | }) 92 | }) 93 | 94 | xdescribe('Introspect Test', () => { 95 | const testMutationName = 'AddUser'; 96 | let testFields = []; 97 | const testSetFields = (array) => { 98 | testFields = array.map((x) => x); 99 | } 100 | 101 | // introspect(testMutationName, testSetFields, args); 102 | 103 | xit('Should make a fetch request to /graphql', () => { 104 | //how to see fetch request response? 105 | //if the inputType name isn't found, shouLd get pre-defined error msg 106 | }) 107 | it('Should update fields state to include input fields from schema ONLY if they are NOT in args', () => { 108 | console.log('testFields: ', testFields); 109 | 110 | const testInputFields = testSchema.inputFields; 111 | const testField = testFields[0]; 112 | const testFieldName = testField.name; 113 | 114 | expect (Array.isArray(testFields)).toBe(true); 115 | expect(testFields.length).toBe(testInputFields.length - args.keys.length); 116 | expect (Array.isArray(testField).toBe(false)); 117 | expect (typeof testField).toBe("object"); 118 | expect (testFieldName).toBe(testInputFields[0].name); 119 | expect (args.testFieldName).toBe(undefined); 120 | }) 121 | }); -------------------------------------------------------------------------------- /src/PeriqlesForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | import PeriqlesField from './PeriqlesField'; 4 | import {introspect} from './functions'; 5 | import {commitMutation} from 'react-relay'; 6 | import '../periqles.css' 7 | 8 | const {useState, useEffect} = React; 9 | 10 | const PeriqlesForm = ({ 11 | environment, 12 | mutationName, 13 | mutationGQL, 14 | useMutation, 15 | specifications, 16 | args = {}, 17 | callbacks, 18 | }: PeriqlesFormProps): JSX.Element => { 19 | const [formState, setFormState] = useState({}); 20 | const [fields, setFields] = useState([]); 21 | 22 | // introspect this project's GraphQL schema on initial render 23 | useEffect(() => { 24 | introspect(mutationName, setFields, args); 25 | }, [mutationName]); 26 | 27 | // HANDLERS 28 | const initializeForm = (fields: PeriqlesFieldInfo[]) => { 29 | const initialValues = {}; 30 | fields.forEach((field: PeriqlesFieldInfo) => { 31 | let initialValue; 32 | switch (field.type) { 33 | case 'Enum': 34 | case 'String': 35 | initialValue = ''; 36 | break; 37 | case 'Int': 38 | initialValue = 0; 39 | break; 40 | case 'Boolean': 41 | initialValue = false; 42 | break; 43 | default: 44 | initialValue = ''; 45 | } 46 | initialValues[field.name] = initialValue; 47 | }); 48 | 49 | setFormState(initialValues); 50 | }; 51 | 52 | const handleSubmit = (e, fields): void => { 53 | if (e.key === 'Enter' || e.type === 'click') { 54 | e.preventDefault(); // prevent page refesh 55 | } 56 | 57 | // validate non-null fields 58 | const missing: Array = []; 59 | for (const key in formState) { 60 | const fieldInfo = fields.filter( 61 | (field) => field.name === key, 62 | )[0]; 63 | 64 | if (fieldInfo.required && formState[key] === '' 65 | || fieldInfo.required && formState[key] === undefined) { 66 | missing.push(fieldInfo.label); 67 | } 68 | } 69 | 70 | if (missing.length) { 71 | window.alert(`The following fields are required: ${missing.join(', ')}`); 72 | return; 73 | } 74 | 75 | const input: Input = {...formState, ...args}; 76 | const variables: Variables = { 77 | input, 78 | }; 79 | 80 | if (environment) { 81 | // relay commit method 82 | commitMutation(environment, { 83 | mutation: mutationGQL, 84 | variables, 85 | onCompleted: (response, errors): void => { 86 | if (callbacks?.onSuccess) callbacks.onSuccess(response); 87 | initializeForm(fields); 88 | }, 89 | onError: (err): void => { 90 | if (callbacks?.onFailure) callbacks.onFailure(err); 91 | }, 92 | }); 93 | } else { 94 | // apollo commit method 95 | useMutation({ variables }) 96 | .then(response => { 97 | if (callbacks?.onSuccess) callbacks.onSuccess(response); 98 | initializeForm(fields); 99 | }) 100 | .catch(err => { 101 | if (callbacks?.onFailure) callbacks.onFailure(err); 102 | }) 103 | } 104 | }; 105 | 106 | const handleChange = (e): void => { 107 | const {name, value, type} = e.target; 108 | let useValue = value; 109 | // type-coerce values from number input elements before storing in state 110 | if (type === 'number' && typeof value !== 'number') { 111 | useValue -= 0; 112 | } 113 | 114 | const newState = Object.assign({}, formState); 115 | newState[name] = useValue; 116 | setFormState(newState); 117 | }; 118 | 119 | useEffect(() => initializeForm(fields), [fields.length, setFormState]); 120 | 121 | return ( 122 |
handleSubmit(e, fields)}> 126 | {specifications && specifications.header &&

{specifications.header}

} 127 | {fields.length 128 | ? (fields.map((field: PeriqlesFieldInfo, index: number) => { 129 | const specs = specifications 130 | ? specifications.fields[field.name] 131 | : undefined; 132 | return ( 133 | 141 | ); 142 | })) 143 | :

Loading form...

144 | } 145 | 150 | 151 | ); 152 | }; 153 | 154 | export default PeriqlesForm; -------------------------------------------------------------------------------- /__tests__/Functions.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {graphql} from 'react-relay'; 3 | import testInputObj from '../__mocks__/testInputObj'; 4 | import { 5 | render, 6 | screen, 7 | fireEvent, 8 | } from '@testing-library/react'; 9 | import '@testing-library/jest-dom/extend-expect'; 10 | import { introspect, fieldsArrayGenerator, generateDefaultElement, generateSpecifiedElement} from '../src/functions.tsx' 11 | import ReactDOMServer from 'react-dom/server'; 12 | 13 | const args = { 14 | clientMutationId: '0000' 15 | }; 16 | 17 | const periqlesFieldArray = [{ 18 | name: 'email', 19 | label: 'email', 20 | value: '', 21 | type: 'text' 22 | }]; 23 | 24 | const emptyFieldsArray = []; 25 | const fieldsArrayWithFieldsObj = [ 26 | { 27 | name: 'email', 28 | type: 'string' 29 | } 30 | ]; 31 | 32 | const fieldObj = { 33 | name: "pizzaTopping" 34 | } 35 | const emptyOptionsArr = [] 36 | const fieldTypeEnumOptionsArr = [ 37 | { 38 | name: 'string', 39 | option: 'testOption' 40 | } 41 | ] 42 | const fieldTypeOfTypeEnumOptionsArr = [{ 43 | name: 'boolean', 44 | option: 'testOption' 45 | }]; 46 | 47 | const mappedOption = { 48 | name: 'boolean', 49 | label: 'boolean', 50 | value: false, 51 | type: "boolean" 52 | }; 53 | 54 | 55 | describe('fieldsArrayGenerator Test', () => { 56 | const testReturn = fieldsArrayGenerator(testInputObj, args) 57 | it('It returns an array', () => { 58 | expect (Array.isArray(testReturn)).toBe(true); 59 | }) 60 | 61 | it('Returned array length matches input field array length', () => { 62 | expect(testReturn.length).toBe(testInputObj.inputFields.length - 1); 63 | }) 64 | 65 | it('Returns objects in an array that match fieldObj', () => { 66 | const testReturnObj = testReturn[0] 67 | const testInputField = testInputObj.inputFields[0] 68 | expect (Array.isArray(testReturnObj)).toBe(false); 69 | expect (typeof testReturnObj).toBe("object"); 70 | expect (testReturnObj.name).toBe(testInputField.name); 71 | }) 72 | 73 | it('The name value in the fieldObj should not be a property of args', () => { 74 | const fieldObjName = testReturn[0].name 75 | expect (args.fieldObjName).toBe(undefined); 76 | }) 77 | }) 78 | 79 | // generateDefaultElement 80 | describe('generateDefaultElement Test', () => { 81 | it('Returns an HTML element with a label tag and input tag', () => { 82 | const field = { 83 | name: "email", 84 | label: "email", 85 | type: "string", 86 | } 87 | const formState = { 88 | email: '' 89 | } 90 | const handleChange = () => console.log('testing') 91 | 92 | const returnElement = generateDefaultElement({field, formState, handleChange}) 93 | const {container} = render(returnElement) 94 | expect(container.querySelector('Label')).toBeInTheDocument() 95 | expect(container.querySelector('input')).toBeInTheDocument() 96 | }) 97 | 98 | it('Returns an input tag with a type of number if the field type is Int', () => { 99 | const field = { 100 | name: "number", 101 | label: "number", 102 | type: "Int", 103 | } 104 | const formState = { 105 | number: '' 106 | } 107 | const handleChange = () => console.log('testing') 108 | const returnElement = generateDefaultElement({field, formState, handleChange}) 109 | const {container} = render(returnElement) 110 | expect(container.querySelector('input').getAttribute('type')).toBe('number') 111 | }) 112 | 113 | it('Returns an input tag with a type of checkbox if the field type is Boolean', () => { 114 | const field = { 115 | name: "number", 116 | label: "number", 117 | type: "Boolean", 118 | } 119 | const formState = { 120 | number: '' 121 | } 122 | const handleChange = () => console.log('testing') 123 | const returnElement = generateDefaultElement({field, formState, handleChange}) 124 | const {container} = render(returnElement) 125 | expect(container.querySelector('input').getAttribute('type')).toBe('checkbox') 126 | 127 | }) 128 | 129 | it('Returns an select tag if the field type is Enum', () => { 130 | const field = { 131 | name: "Pizza Topping", 132 | label: "Pizza Topping", 133 | type: "Enum", 134 | options: [{ 135 | name: 'buffalo chicken', 136 | label: 'buffalo chicken', 137 | value: 'buffalo chicken', 138 | type: 'string' 139 | }, 140 | { 141 | name: 'buffalo chicken', 142 | label: 'buffalo chicken', 143 | value: 'buffalo chicken', 144 | type: 'string' 145 | }, 146 | { 147 | name: 'buffalo chicken', 148 | label: 'buffalo chicken', 149 | value: 'buffalo chicken', 150 | type: 'string' 151 | }] 152 | } 153 | const formState = { 154 | number: 0 155 | } 156 | const handleChange = () => console.log('testing') 157 | const returnElement = generateDefaultElement({field, formState, handleChange}) 158 | const {container} = render(returnElement) 159 | expect(container.querySelector('select')).toBeInTheDocument() 160 | }) 161 | 162 | it('Returns a input tag with the corresponding type from the element lookup object if the type property on field is not a Int, Boolean, or Enum', () => { 163 | const field = { 164 | name: "email", 165 | label: "email", 166 | type: "string", 167 | } 168 | const formState = { 169 | email: '' 170 | } 171 | const handleChange = () => console.log('testing') 172 | 173 | //expect generateDefaultElement(fields, formState, handleChange) to be 174 | const returnElement = generateDefaultElement({field, formState, handleChange}) 175 | const {container} = render(returnElement) 176 | expect(container.querySelector('input').getAttribute('type')).toBe('text') 177 | }) 178 | }); 179 | 180 | describe('generateSpecifiedElement', () => { 181 | let field = { 182 | name: '', 183 | }; 184 | let specs = { 185 | element: '', 186 | }; 187 | const formState = {field: 'value'}; 188 | const handleChange = () => console.log('Handling change'); 189 | const setFormState = ({field, value}) => formState[field] = value; 190 | 191 | const params = { 192 | field, 193 | specs, 194 | formState, 195 | handleChange, 196 | setFormState 197 | }; 198 | 199 | it('returns the result of invoking specs.render() if that method is present', () => { 200 | params.specs.render = () => 'custom element'; 201 | const result = generateSpecifiedElement(params); 202 | expect(result).toBe('custom element'); 203 | }); 204 | 205 | it('generates a label if none is provided in specs', () => { 206 | params.field = { 207 | name: 'firstName', 208 | type: 'string', 209 | required: false, 210 | }; 211 | params.specs = { 212 | element: 'text', 213 | }; 214 | 215 | const element = generateSpecifiedElement(params); 216 | expect(element.type).toBe('label'); 217 | expect(element.props.children[0]).toBe('First Name'); 218 | }); 219 | 220 | describe('Returns the appropriate input element type based on specs.element', () => { 221 | it('returns a range input element if specs.element is range', () => { 222 | params.field = { 223 | name: 'age', 224 | type: 'Int', 225 | required: false, 226 | }; 227 | params.specs = { 228 | element: 'range', 229 | }; 230 | 231 | const element = generateSpecifiedElement(params); 232 | expect(element.props.children[0]).toBe('Age'); 233 | expect(element.props.children[1].props.type).toBe('range'); 234 | }); 235 | 236 | it('if no range min/max specified, set from 0-Infinity', () => { 237 | params.field = { 238 | name: 'age', 239 | type: 'Int', 240 | required: false, 241 | }; 242 | params.specs = { 243 | element: 'range', 244 | }; 245 | 246 | const element = generateSpecifiedElement(params); 247 | expect(element.props.children[1].props.min).toBe(0); 248 | expect(element.props.children[1].props.max).toBe(Infinity); 249 | }); 250 | 251 | it('range min/max should equal provided values', () => { 252 | params.field = { 253 | name: 'age', 254 | type: 'Int', 255 | required: false, 256 | }; 257 | params.specs = { 258 | element: 'range', 259 | min: 1, 260 | max: 100 261 | }; 262 | 263 | const element = generateSpecifiedElement(params); 264 | expect(element.props.children[1].props.min).toBe(1); 265 | expect(element.props.children[1].props.max).toBe(100); 266 | }); 267 | 268 | it('returns an image input element if specs.element is image', () => { 269 | params.field = { 270 | name: 'profile photo', 271 | required: false, 272 | }; 273 | params.specs = { 274 | element: 'image', 275 | }; 276 | 277 | const element = generateSpecifiedElement(params); 278 | expect(element.props.children[0]).toBe('Profile photo'); 279 | expect(element.props.children[1].props.type).toBe('image'); 280 | }); 281 | 282 | it('image source should equal provided value', () => { 283 | params.field = { 284 | name: 'profile photo', 285 | required: false, 286 | }; 287 | params.specs = { 288 | element: 'image', 289 | src: 'https://cdn.jpegmini.com/user/images/slider_puffin_before_mobile.jpg' 290 | }; 291 | 292 | const element = generateSpecifiedElement(params); 293 | expect(element.props.children[1].props.src).toBe('https://cdn.jpegmini.com/user/images/slider_puffin_before_mobile.jpg'); 294 | }); 295 | 296 | it('returns a textarea input element if specs.element is textarea', () => { 297 | params.field = { 298 | name: 'review', 299 | required: false, 300 | }; 301 | params.specs = { 302 | element: 'textarea', 303 | }; 304 | 305 | const element = generateSpecifiedElement(params); 306 | expect(element.props.children[0]).toBe('Review'); 307 | expect(element.props.children[1].type).toBe('textarea'); 308 | }); 309 | 310 | it('returns a text input element as default', () => { 311 | params.field = { 312 | name: 'username', 313 | required: false, 314 | }; 315 | params.specs = { 316 | element: null, 317 | }; 318 | 319 | const element = generateSpecifiedElement(params); 320 | expect(element.props.children[0]).toBe('Username'); 321 | expect(element.props.children[1].props.type).toBe('text'); 322 | }); 323 | }); 324 | 325 | describe('Handles enumerated fields', () => { 326 | 327 | it('returns default text input if field is not enumerated on schema and options are not provided in specs', () => { 328 | params.field = { 329 | name: 'gender', 330 | type: 'String', 331 | }; 332 | params.specs = { 333 | element: 'radio', 334 | }; 335 | 336 | let element = generateSpecifiedElement(params); 337 | expect(element).toBeDefined(); 338 | expect(element.props.children[1].props.type).toBe('text'); 339 | 340 | params.specs = { 341 | element: 'select', 342 | }; 343 | 344 | element = generateSpecifiedElement(params); 345 | expect(element).toBeDefined(); 346 | expect(element.props.children[1].props.type).toBe('text'); 347 | }); 348 | 349 | it('returns radio buttons if specs.element is radio and options are provided on the schema', () => { 350 | params.field = { 351 | name: 'gender', 352 | type: 'Enum', 353 | options: [ 354 | { 355 | name: 'NONBINARY', 356 | label: 'NONBINARY', 357 | value: 'NONBINARY', 358 | type: 'String', 359 | }, 360 | { 361 | name: 'MALE', 362 | label: 'MALE', 363 | value: 'MALE', 364 | type: 'String', 365 | }, 366 | { 367 | name: 'FEMALE', 368 | label: 'FEMALE', 369 | value: 'FEMALE', 370 | type: 'String', 371 | }, 372 | ] 373 | }; 374 | params.specs = { 375 | element: 'radio', 376 | }; 377 | 378 | const element = generateSpecifiedElement(params); 379 | expect(element.type).toBe('div'); 380 | expect(element.props.children[0].type).toBe('label'); 381 | 382 | const options = element.props.children[1]; 383 | expect(options).toHaveLength(3); 384 | const oneOption = options[0].props.children[0]; 385 | expect(oneOption.props.type).toBe('radio'); 386 | }); 387 | 388 | it('returns radio buttons with the labels provided in specs', () => { 389 | params.field = { 390 | name: 'gender', 391 | type: 'Enum', 392 | options: [ 393 | { 394 | name: 'NONBINARY', 395 | label: 'NONBINARY', 396 | value: 'NONBINARY', 397 | type: 'String', 398 | }, 399 | { 400 | name: 'MALE', 401 | label: 'MALE', 402 | value: 'MALE', 403 | type: 'String', 404 | }, 405 | { 406 | name: 'FEMALE', 407 | label: 'FEMALE', 408 | value: 'FEMALE', 409 | type: 'String', 410 | }, 411 | ], 412 | }; 413 | params.specs = { 414 | element: 'radio', 415 | options: [ 416 | { 417 | label: 'nonbinary', 418 | value: 'NONBINARY', 419 | }, 420 | { 421 | label: 'male', 422 | value: 'MALE', 423 | }, 424 | { 425 | label: 'female', 426 | value: 'FEMALE', 427 | }, 428 | ] 429 | }; 430 | 431 | const element = generateSpecifiedElement(params); 432 | expect(element.type).toBe('div'); 433 | expect(element.props.children[0].type).toBe('label'); 434 | 435 | const options = element.props.children[1]; 436 | // contains an array of 3 children 437 | expect(options).toHaveLength(3); 438 | // children are radio buttons 439 | expect(options[0].props.children[0].props.type).toBe('radio'); 440 | // radio button has correct label 441 | expect(options[0].props.children[1]).toBe('nonbinary'); 442 | }); 443 | 444 | it('returns radio buttons even if field is not marked as enumerated on the schema', () => { 445 | params.field = { 446 | name: 'gender', 447 | type: 'String', 448 | }; 449 | params.specs = { 450 | element: 'radio', 451 | options: [ 452 | { 453 | label: 'nonbinary', 454 | value: 'NONBINARY', 455 | }, 456 | { 457 | label: 'male', 458 | value: 'MALE', 459 | }, 460 | { 461 | label: 'female', 462 | value: 'FEMALE', 463 | }, 464 | ] 465 | }; 466 | 467 | const element = generateSpecifiedElement(params); 468 | expect(element.type).toBe('div'); 469 | expect(element.props.children[0].type).toBe('label'); 470 | 471 | const options = element.props.children[1]; 472 | // contains an array of 3 children 473 | expect(options).toHaveLength(3); 474 | // children are radio buttons 475 | expect(options[0].props.children[0].props.type).toBe('radio'); 476 | }); 477 | 478 | it('returns a select input element if specs.element is select', () => { 479 | params.field = { 480 | name: 'gender', 481 | type: 'Enum', 482 | options: [ 483 | { 484 | name: 'NONBINARY', 485 | label: 'NONBINARY', 486 | value: 'NONBINARY', 487 | type: 'String', 488 | }, 489 | { 490 | name: 'MALE', 491 | label: 'MALE', 492 | value: 'MALE', 493 | type: 'String', 494 | }, 495 | { 496 | name: 'FEMALE', 497 | label: 'FEMALE', 498 | value: 'FEMALE', 499 | type: 'String', 500 | }, 501 | ], 502 | }; 503 | params.specs = { 504 | element: 'select', 505 | options: [ 506 | { 507 | label: 'nonbinary', 508 | value: 'NONBINARY', 509 | }, 510 | { 511 | label: 'male', 512 | value: 'MALE', 513 | }, 514 | { 515 | label: 'female', 516 | value: 'FEMALE', 517 | }, 518 | ] 519 | }; 520 | 521 | const element = generateSpecifiedElement(params); 522 | expect(element.type).toBe('label'); 523 | expect(element.props.children[1].type).toBe('select'); 524 | }); 525 | }); 526 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 |
4 | 5 | 6 | GitHub Repo stars 7 | 8 | 9 | npm 10 | 11 | 12 | GitHub contributors 13 | 14 | NPM 15 | 16 |

17 | 18 |

periqles

19 |

20 | Painless forms for GraphQL. 21 |
22 | Demo → 23 |

24 | 25 | 26 | 27 | 35 | 38 | 39 |
28 |

Periqles is a React component library for Relay and Apollo that makes it easy to collect user input.

29 | 30 |

Periqles abstracts away the dirty work of form creation — with override switches built in for the design-conscious developer — so you can be free to focus on business logic. Given the name of a GraphQL mutation, periqles introspects the project's schema and intuits a form to match it. No need to waste time debugging React state management or fussing with flexbox — just drop in a tag and go on with your life.

31 | 32 | >*“Having knowledge but lacking the power to express it clearly is no better than never having any ideas at all.” 33 | -- Pericles* 34 |
36 | screenshot of periqles form 37 |
40 | 41 |
42 | Table of Contents 43 |
    44 |
  1. 45 | Getting Started 46 | 52 |
  2. 53 |
  3. 54 | Usage 55 | 61 |
  4. 62 | 63 |
  5. Contributing
  6. 64 |
  7. License
  8. 65 |
  9. Maintainers
  10. 66 |
  11. Built with:
  12. 67 |
68 |
69 | 70 | --- 71 | 72 | ## Getting Started 73 | 74 | To add a `` to your Apollo or Relay client, follow these steps. 75 | 76 | ### Prerequisites 77 | 78 | * React (v. 16.8.0 and up) 79 | ```sh 80 | npm install react 81 | ``` 82 | 83 | ### Installation 84 | 85 | 1. Install periqles from the terminal. 86 | ``` 87 | npm install periqles 88 | ``` 89 | 2. Import PeriqlesForm into your frontend. 90 | ``` 91 | // MyReactComponent.jsx 92 | import PeriqlesForm from 'periqles'; 93 | ``` 94 | 95 | ### Server 96 | 97 | Periqles relies on introspection queries to intuit the optimal form UI from your project's GraphQL schema. These queries will hit your server in the form of POST requests to `/graphql`. To use periqles, you must expose your schema at that `/graphql` endpoint. 98 | 99 | In our [demo](https://github.com/oslabs-beta/periqles-demo), we use the client-agnostic `express-graphql` package to spin up a server in Node for our GraphQL API. See the documentation [here](https://graphql.org/graphql-js/express-graphql/) and our code [here](https://github.com/oslabs-beta/periqles-demo/blob/main/server.js). Apollo projects may use the Apollo Server without problems. 100 | 101 | ``` 102 | //server.js 103 | 104 | const express = require('express'); 105 | const {graphqlHTTP} = require('express-graphql'); 106 | const app = express(); 107 | const {schema} = require('./data/schema/index.js'); 108 | 109 | app.post( 110 | '/graphql', 111 | graphqlHTTP({ 112 | schema: schema, 113 | pretty: true, // pretty-print JSON responses 114 | }), 115 | ); 116 | ``` 117 | 118 | If you are not using the `/graphql` endpoint to serve your API, options include configuring your server to redirect requests to `/graphql` to the correct endpoint or using a build tool like Webpack to proxy requests to `/graphql` to the correct address. 119 | 120 | ### Schema 121 | 122 | Currently, the introspection query used by periqles expects to find named input types on the schema. I.e., if you tell a `` to generate a UI for your `AddUser` mutation, it will query your schema for a type called `AddUserInput`. Then it will render an input element for each input field listed on the `AddUserInput` type. 123 | 124 | This means that periqles can successfully introspect this mutation: 125 | 126 | ``` 127 | #schema.graphql 128 | 129 | type Mutation { 130 | addUser(input: AddUserInput!): AddUserPayload 131 | } 132 | 133 | # The mutation input is named and defined separately from the mutation. 134 | input AddUserInput { 135 | username: String! 136 | password: String! 137 | email: String! 138 | gender: GenderEnum! 139 | pizzaTopping: PizzaToppingEnum! 140 | age: Int! 141 | } 142 | ``` 143 | 144 | ... but trying to introspect this mutation will cause your GraphQL server to throw back a `400 Bad Request` error: 145 | 146 | ``` 147 | #schema.graphql 148 | 149 | # The mutation input is not named and is defined in-line. 150 | type Mutation { 151 | addUser(input: { 152 | username: String! 153 | password: String! 154 | email: String! 155 | gender: GenderEnum! 156 | pizzaTopping: PizzaToppingEnum! 157 | age: Int! 158 | }!): AddUserPayload 159 | } 160 | ``` 161 | 162 | This is a high-priority area of improvement for us. We welcome PRs and other contributions. 163 | 164 | --- 165 | 166 | ## Usage 167 | 168 | `` takes a number of props, including optional props to override its default logic for more fine-grained control over the apperance and composition of the form, the data sent to the API on submit, and state-management behavior. 169 | 170 | ### PeriqlesForm Props 171 | 172 | These are the props available to all clients. See below for more usage information specific to your client. 173 | 174 | - `mutationName`: string _(required)_ — The name of a mutation as it appears on your GraphQL schema, e.g. 'AddUser' or 'AddUserMutation'. 175 | - If this is the only prop provided, periqles will render a form with default HTML intuited based on the name and scalar data type of each input field. E.g., an input field of type 'String' will result in ``. If the name of the input field appears in periqles' dictionary of common input fields, it will render a more specifically appropriate element. For example, a string-type field with the name 'password' will result in ``, and a field named 'mobile' will result in ``. 176 | 177 | - `specifications`: object _(optional)_ — If you wish to control the HTML or state management of a particular field, provide the instructions here. Periqles will fall back to its default logic for any fields or details not specified here. 178 | - `header`: string _(optional)_ — If you wish to put a header on your form, e.g. "Sign up!", pass it here. 179 | - `fields`: object _(optional)_ — Each key on `fields` should correspond to the name of an input field exactly as it appears on the schema. (E.g., based on the schema example above, 'pizzaTopping' is a valid key to use.) You can override defaults for as many or as few fields as you wish. 180 | - `element`: string _(optional)_ — The HTML element you wish to use for this field, e.g. 'textarea', 'radio', 'datetime', etc. 181 | - `label`: string or element _(optional)_ — The text, HTML, or JSX you wish to appear as a label for this field. 182 | - `options`: array _(optional)_ — Whether or not this field is listed as an enumerated type on the schema, you may constrain valid user input on the frontend by using 'select' or 'radio' for the `element` field and providing a list of options here. 183 | - `option`: object _(optional)_ — Specifies an option for this dropdown or group of radio buttons. 184 | - `label`: string or element _(required)_ — The label you wish to appear for this option. 185 | - `value`: string or number _(required)_ — The value to be submitted to the API. 186 | - `render`: function(params: {formState, setFormState, handleChange}) _(optional)_ — If you wish to completely circumvent periqles' logic for rendering input fields, you may provide your own functional component here. The component you specify will completely replace the field `` would have otherwise rendered. Parameters: 187 | - `formState`: object _(optional)_ — The name and current value of each input field as key-value pairs. 188 | - `setFormState`: function(newFormState) _(optional)_ — A React setState [hook](https://reactjs.org/docs/hooks-reference.html#usestate). Overwrites the entirety of formState with whatever is passed in. 189 | - `handleChange`: function(Event) _(optional)_ — Destructures the input field's name and value off event.target to pass them as arguments to setFormState. 190 | - `src`: string _(optional)_ — When `element` is 'img', the URL to use for the `src` attribute. 191 | - `min`: number _(optional)_ — When `element` is 'range,' the number to use for the `min` attribute. 192 | - `max`: number _(optional)_ — When `element` is 'range,' the number to use for the `max` attribute. 193 | 194 | - `args`: object _(optional)_ — If there are any variables that you want to submit as input for the mutation but don't want to render as elements on the form, pass them here as key-value pairs. Example use cases include client-side authentication information or the `clientMutationId` in Relay. E.g.: `const args = {userId: '001', clientMutationId: ${mutationId++}}`. Fields listed here will be excluded from the rendered form but included on the mutation when the form is submitted. 195 | 196 | - `callbacks`: object _(optional)_ — Developer-defined functions to be invoked when the form is submitted. 197 | - `onSuccess`: function(response) _(optional)_ — Invoked if the mutation is successfully submitted to the API. In our demo ([Relay](https://github.com/oslabs-beta/periqles-demo/blob/main/ts/components/relay/UserProfile.tsx), [Apollo](https://github.com/oslabs-beta/periqles-demo/blob/main/ts/components/ApolloUserProfile.tsx)), we use onSuccess to trigger a very simple re-fetch and re-render of a component which displays ``'s output. 198 | - `onFailure`: function(error) _(optional)_ — Invoked if the mutation fails to fire or the API sends back an error message. Use this to display meaningful error messages to the user. 199 | 200 | ### Validation 201 | 202 | Currently, periqles is able to validate input fields listed as non-null and of type `string` on the GraphQL schema. It will prevent the user from submitting the form if required text fields are left blank, including enumerated fields (represented by dropdowns or radio buttons) that are of type `string`. 203 | 204 | This is high-priority area of improvement for us. If you have specific needs around validation, please open an [issue](https://github.com/oslabs-beta/periqles/issues) or submit a PR. 205 | 206 | --- 207 | 208 | ## Relay 209 | 210 | In addition to the optional and required props listed above, `` requires the following props when used in a Relay client: 211 | 212 | - `environment`: RelayEnvironment _(required)_ — Your client's RelayEnvironment instance; necessary to send a mutation. 213 | - `mutationGQL`: GraphQLTaggedNode _(required)_ — Your mutation, formatted as a tagged template literal using the `graphql` tag imported from `react-relay`. (NOT the version provided by `graphql-tag`.) 214 | 215 | `` uses the commitMutation function imported from Relay to fire off mutations when the form is submitted. If you pass an onSuccess and/or an onFailure callback on the `callbacks` prop, they will be invoked by commitMutation's onCompleted and onError callbacks, respectively. 216 | 217 | CommitMutation takes additional callback parameters that are not currently included on ``'s `callbacks` prop, namely `updater`, `optimisticResponse`, `optimisticUpdater`, `configs`, and `cacheConfigs`. We plan to support these callbacks soon. If this is a high priority for your use case, please let us know by opening an [issue](https://github.com/oslabs-beta/periqles/issues), or submit a PR. 218 | 219 | Here is a basic example of how to use `` in Relay: 220 | 221 | ``` 222 | // MyComponent.jsx 223 | 224 | import React, {useState} from 'react'; 225 | import {graphql} from 'react-relay'; 226 | import PeriqlesForm from 'periqles'; 227 | 228 | const ADD_USER = graphql` 229 | mutation UserProfile_AddUserMutation($input: AddUserInput!) { 230 | addUser(input: $input) { 231 | username 232 | password 233 | email 234 | gender 235 | pizzaTopping 236 | age 237 | } 238 | }`; 239 | 240 | const MyComponent = ({relay}) => { 241 | return (
242 |

Sign Up

243 | 248 |
); 249 | }; 250 | ``` 251 | 252 | **[Full Code Sample](https://github.com/oslabs-beta/periqles-demo/blob/main/ts/components/relay/UserProfile.tsx)** 253 | 254 | --- 255 | 256 | ## Apollo 257 | 258 | In addition to the optional and required props listed above, `` requires one additional prop when used in an Apollo client: 259 | 260 | - `useMutation`: function _(required)_ — Your custom mutation hook, built using the useMutation hook imported from `@apollo/client`. 261 | 262 | Here is a basic example of how to use `` in Apollo: 263 | 264 | ``` 265 | // MyComponent.jsx 266 | 267 | import React from 'react'; 268 | import { gql, useMutation } from '@apollo/client'; 269 | import PeriqlesForm from 'periqles'; 270 | 271 | const Signup = () => { 272 | const ADD_USER = gql` 273 | mutation AddUser($input: AddUserInput!){ 274 | addUser(input: $input){ 275 | username 276 | password 277 | email 278 | gender 279 | pizzaTopping 280 | age 281 | } 282 | }`; 283 | 284 | const [addUser, response] = useMutation(ADD_USER); 285 | 286 | return (
287 |

Sign Up

288 | 292 |
); 293 | }; 294 | ``` 295 | 296 | **[Full Code Sample](https://github.com/oslabs-beta/periqles-demo/blob/main/ts/components/ApolloUserProfile.tsx)** 297 | 298 | --- 299 | 300 | ## Styles 301 | 302 | Periqles comes with its own basic [stylesheet](https://github.com/oslabs-beta/periqles/blob/main/periqles.css), but it also attaches class names to each of its HTML elements that you can target in CSS for additional styling. We've tried to keep our default styles in that sweet spot between "enough to be presentable" and "adaptable to any design scheme." If you think we should provide more or less CSS, give us a shout in the [issues](https://github.com/oslabs-beta/periqles/issues). 303 | 304 | Each element has two class names which follow this format: 305 | 306 | - "periqles-[element type]": e.g., 'periqles-textarea', 'periqles-radio-option' 307 | - "[field name]-[element type]": e.g., 'biography-textarea', 'gender-radio-option' 308 | 309 | 310 | 311 | 312 | 313 | --- 314 | 315 | ## Contributing 316 | 317 | If you would like to contribute to periqles, please [fork this repo](https://github.com/oslabs-beta/periqles). Commit your changes to a well-named feature branch then open a pull request. We appreciate your contributions to this open-source project! 318 | 319 | 320 | ## License 321 | 322 | Distributed under the MIT License. See `LICENSE` for more information. 323 | 324 | 325 | ## Maintainers 326 | 327 | - [Cameron Baumgartner](https://github.com/cameronbaumgartner) 328 | - [Ian Garrett](https://github.com/eeeeean) 329 | - [Joe Toledano](https://github.com/JosephToledano) 330 | - [Kelly Porter](https://github.com/kporter101) 331 | 332 | ## Built with: 333 | 334 | * [React (Hooks)](https://reactjs.org/) 335 | * [GraphQL](https://graphql.org/) 336 | * [Relay](https://relay.dev/) 337 | * [Apollo](https://www.apollographql.com/) 338 | * the support of [OSLabs](https://github.com/open-source-labs) 339 | -------------------------------------------------------------------------------- /src/functions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const introspect = (mutationName, setFields, args) => { 4 | const inputTypeName: string = mutationName + 'Input'; 5 | 6 | fetch('/graphql', { 7 | method: 'POST', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | body: JSON.stringify({ 12 | query: `query typeQuery($inputType: String!) 13 | { 14 | __type(name: $inputType) { 15 | name 16 | inputFields { 17 | name 18 | type { 19 | name 20 | kind 21 | ofType { 22 | name 23 | kind 24 | enumValues { 25 | name 26 | description 27 | } 28 | } 29 | } 30 | } 31 | } 32 | }`, 33 | variables: { 34 | inputType: inputTypeName, 35 | }, 36 | }), 37 | }) 38 | .then((res) => res.json()) 39 | .then(({data}) => { 40 | if (!data) { 41 | return console.error( 42 | 'ERROR at periqles: Failed to introspect. No data received.', 43 | ); 44 | } 45 | if (!data.__type) { 46 | return console.error( 47 | 'ERROR at periqles: Failed to introspect. No __type property on received data.', 48 | ); 49 | } 50 | const typeSchema = data.__type; 51 | // intuit fields off the schema 52 | const fieldsArr: PeriqlesFieldInfo[] = fieldsArrayGenerator(typeSchema, args); 53 | setFields(fieldsArr); 54 | }) 55 | .catch((err) => { 56 | console.error('ERROR at periqles: Failed to introspect.', err); 57 | }); 58 | }; 59 | 60 | export const fieldsArrayGenerator: FieldsArrayGenerator = (inputType, args = {}) => { 61 | if (!inputType || !inputType.inputFields) { 62 | console.error('ERROR at PeriqlesForm: mutation input type is undefined.'); 63 | return []; 64 | } 65 | 66 | const fieldsArray: Array = []; 67 | 68 | inputType.inputFields.forEach((field) => { 69 | // exclude from the form any inputs accounted for by args 70 | if (args[field.name]) return; 71 | 72 | const fieldObj: PeriqlesFieldInfo = { 73 | name: field.name, 74 | }; 75 | 76 | //check the field.type.kind to see if the field is NON_NULL (required) 77 | //if so, set fieldObj.required to true 78 | fieldObj.required = field.type.kind === 'NON_NULL'; 79 | 80 | // the input field is a scalar, nullable type 81 | if (field.type.name && field.type.kind === 'SCALAR') { 82 | fieldObj.type = field.type.name; 83 | } 84 | 85 | // the input field is an enumerated type (whether or not wrapped in a NON_NULL type) 86 | else if (field.type.kind === 'ENUM' || field.type.ofType?.kind === 'ENUM') { 87 | fieldObj.type = 'Enum'; 88 | try { 89 | const optionsArr = 90 | field.type.enumValues || field.type.ofType?.enumValues || []; 91 | // provide each option a type property 92 | fieldObj.options = optionsArr.map((option: EnumValue) => { 93 | let value, type; 94 | 95 | switch (typeof option.name) { 96 | case 'number': 97 | case 'bigint': 98 | value = option.name; 99 | type = 'Int'; 100 | break; 101 | case 'boolean': 102 | // stringify booleans b/c HTML typing doesn't allow for boolean value attributes 103 | value = `${option.name}`; 104 | type = 'Boolean'; 105 | break; 106 | default: 107 | value = option.name; 108 | type = 'String'; 109 | } 110 | 111 | const mappedOption: PeriqlesFieldOption = { 112 | name: `${option.name}`, 113 | label: `${option.name}`, 114 | value, 115 | type, 116 | }; 117 | 118 | return mappedOption; 119 | }); 120 | } catch (err) { 121 | console.error( 122 | 'ERROR at PeriqlesForm: Failure to assign enumerated field.', 123 | err, 124 | ); 125 | } 126 | } 127 | // the input field is a scalar wrapped in a NON_NULL type 128 | else if (field.type.ofType?.name && field.type.ofType?.kind === 'SCALAR') { 129 | // TODO 130 | fieldObj.type = field.type.ofType.name; 131 | } 132 | // TODO: the input field is not a scalar or enum type 133 | else { 134 | console.warn( 135 | `The '${field.name}' input field is of a complex type not currently supported by PeriqlesForm. It will default to a 'String'. Type:`, 136 | field, 137 | ); 138 | fieldObj.type = 'String'; 139 | } 140 | 141 | fieldsArray.push(fieldObj); 142 | }); 143 | 144 | return fieldsArray; 145 | }; 146 | 147 | /* eslint-disable flowtype/no-types-missing-file-annotation */ 148 | /** 149 | * Builds an HTML element to collect user input for a GraphQL mutation based on user-provided instructions. 150 | * @param {Object} field An object representing an input field for a GraphQL mutation. Example: {name: "name", type: "String"} 151 | * @param {Object} specs An object representing developer-specified information to use for an HTML element representing this field. 152 | * @param {Function} handleChange 153 | * @param {Object} formState 154 | * @param {Function} setFormState 155 | * @return Returns the specified HTML input element with the specified label and specified sub-options, if any. 156 | */ 157 | 158 | export const generateSpecifiedElement: GenerateSpecifiedElement = ({ 159 | field, 160 | specs, 161 | formState, 162 | handleChange, 163 | setFormState, 164 | }) => { 165 | if (specs.render) { 166 | return specs.render({formState, setFormState, handleChange}); 167 | } 168 | //If label isn't given, set it as field.name w/ spaces & 1st letter capitalized 169 | if (!specs.label) { 170 | specs.label = field.name.replace(/([a-z])([A-Z])/g, '$1 $2'); // put spaces before capital letters 171 | specs.label = specs.label[0].toUpperCase() + specs.label.slice(1); // capitalize first letter 172 | } 173 | else field.label = specs.label; 174 | 175 | switch (specs.element) { 176 | case 'range': 177 | return ( 178 | 190 | ); 191 | 192 | case 'image': 193 | return ( 194 | 206 | ); 207 | 208 | case 'radio': 209 | if (!specs.options && !field.options) { 210 | return (); 219 | } 220 | 221 | let radioOptions: Array = []; 222 | if (specs.options && field.options) { 223 | specs.options.forEach((spec) => { 224 | field.options?.forEach((option) => { 225 | if (option.value === spec.value) { 226 | const newOption: PeriqlesFieldOption = { 227 | name: option.name, 228 | label: spec.label, 229 | value: option.value, 230 | type: option.type, 231 | }; 232 | return radioOptions.push(newOption); 233 | } 234 | }); 235 | }); 236 | } else if (specs.options && !field.options) { 237 | // dev can constrain possible inputs on the frontend for fields that are not enumerated on the schema 238 | specs.options.forEach((spec) => { 239 | const newOption: PeriqlesFieldOption = { 240 | name: spec.label, 241 | label: spec.label, 242 | value: spec.value, 243 | type: typeof spec.value, 244 | }; 245 | radioOptions.push(newOption); 246 | }); 247 | } else { 248 | // specs didn't provide options 249 | radioOptions = field.options; 250 | } 251 | 252 | return ( 253 |
254 | 255 | {radioOptions.map((option, index) => { 256 | return ( 257 | 272 | ); 273 | })} 274 |
275 | ); 276 | 277 | case 'select': 278 | if (!specs.options && !field.options) { 279 | return (); 288 | } 289 | 290 | let selectOptions: Array = []; 291 | if (specs.options && field.options) { 292 | specs.options.forEach((spec) => { 293 | field.options?.forEach((option) => { 294 | if (option.value === spec.value) { 295 | const newOption: PeriqlesFieldOption = { 296 | name: option.name, 297 | label: spec.label, 298 | value: option.value, 299 | type: option.type, 300 | }; 301 | return selectOptions.push(newOption); 302 | } 303 | }); 304 | }); 305 | } else if (specs.options && !field.options) { 306 | // dev can constrain possible inputs on the frontend for fields that are not enumerated on the schema 307 | specs.options.forEach((spec) => { 308 | const newOption: PeriqlesFieldOption = { 309 | name: spec.label, 310 | label: spec.label, 311 | value: spec.value, 312 | type: typeof spec.value, 313 | }; 314 | selectOptions.push(newOption); 315 | }); 316 | } else { 317 | // specs didn't provide options 318 | selectOptions = field.options; 319 | } 320 | 321 | return ( 322 | 352 | ); 353 | 354 | case 'textarea': 355 | return ( 356 |