├── .babelrc ├── .browserslistrc ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .huskyrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── demo ├── .eslintrc ├── app.jsx ├── index.css ├── index.jsx └── profile.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── ExpressionEvaluator.js ├── UpdateContext.js ├── UpdateTracker.js ├── components │ ├── ActivityButton.jsx │ ├── AuthButton.jsx │ ├── DislikeButton.jsx │ ├── FollowButton.jsx │ ├── Image.jsx │ ├── Label.jsx │ ├── LikeButton.jsx │ ├── Link.jsx │ ├── List.jsx │ ├── LiveUpdate.jsx │ ├── LoggedIn.jsx │ ├── LoggedOut.jsx │ ├── LoginButton.jsx │ ├── LogoutButton.jsx │ ├── Name.jsx │ ├── Value.jsx │ ├── evaluateExpressions.jsx │ ├── evaluateList.jsx │ └── withWebId.jsx ├── hooks │ ├── useLDflex.js │ ├── useLDflexList.js │ ├── useLDflexValue.js │ ├── useLatestUpdate.js │ ├── useLiveUpdate.js │ ├── useLoggedIn.js │ ├── useLoggedOut.js │ └── useWebId.js ├── index.js ├── prop-types.js └── util.js ├── test ├── .eslintrc ├── ExpressionEvaluator-test.js ├── UpdateTracker-test.js ├── __mocks__ │ ├── @solid │ │ └── query-ldflex.js │ ├── solid-auth-client.js │ ├── useLDflex.js │ └── useState.js ├── components │ ├── ActivityButton-test.jsx │ ├── AuthButton-test.jsx │ ├── DislikeButton-test.jsx │ ├── FollowButton-test.jsx │ ├── Image-test.jsx │ ├── Label-test.jsx │ ├── LikeButton-test.jsx │ ├── Link-test.jsx │ ├── List-test.jsx │ ├── LiveUpdate-test.jsx │ ├── LoggedIn-test.jsx │ ├── LoggedOut-test.jsx │ ├── LoginButton-test.jsx │ ├── LogoutButton-test.jsx │ ├── Name-test.jsx │ ├── Value-test.jsx │ ├── evaluateExpressions-test.jsx │ ├── evaluateList-test.jsx │ └── withWebId-test.jsx ├── hooks │ ├── useLDflex-test.js │ ├── useLDflexList-test.js │ ├── useLDflexValue-test.js │ ├── useLatestUpdate-test.js │ ├── useLiveUpdate-test.js │ ├── useLoggedIn-test.js │ ├── useLoggedOut-test.js │ └── useWebId-test.js ├── index-test.js ├── setup.js └── util.js └── webpack ├── .eslintrc ├── webpack.bundle.config.js ├── webpack.common.config.js ├── webpack.demo.config.js └── webpack.lib.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: [ 3 | ["@babel/react", { loose: true }], 4 | ["@babel/env", { loose: true }], 5 | ], 6 | plugins: [ 7 | ["@babel/plugin-proposal-class-properties"], 8 | ["@babel/plugin-transform-runtime"], 9 | ], 10 | 11 | env: { 12 | module: { 13 | presets: [ 14 | ["@babel/env", { modules: false }], 15 | ], 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Build for Node 2 | node 8 3 | # Build for most browsers 4 | not dead 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | env: { 3 | es6: true, 4 | browser: true, 5 | }, 6 | extends: eslint:recommended, 7 | plugins: [ 8 | react, 9 | react-hooks, 10 | ], 11 | parser: babel-eslint, 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | sourceType: module, 15 | jsx: true, 16 | }, 17 | rules: { 18 | // Best Practices 19 | accessor-pairs: error, 20 | array-callback-return: error, 21 | block-scoped-var: error, 22 | class-methods-use-this: off, 23 | complexity: error, 24 | consistent-return: error, 25 | curly: [ error, multi-or-nest, consistent ], 26 | default-case: error, 27 | dot-location: [ error, property ], 28 | dot-notation: error, 29 | eqeqeq: error, 30 | guard-for-in: off, 31 | no-alert: error, 32 | no-caller: error, 33 | no-console: [ error, { allow: [ warn ] } ], 34 | no-case-declarations: error, 35 | no-div-regex: error, 36 | no-else-return: off, 37 | no-empty-function: error, 38 | no-empty-pattern: error, 39 | no-eq-null: error, 40 | no-eval: error, 41 | no-extend-native: error, 42 | no-extra-bind: error, 43 | no-extra-label: error, 44 | no-fallthrough: error, 45 | no-floating-decimal: error, 46 | no-global-assign: error, 47 | no-implicit-coercion: off, 48 | no-implicit-globals: error, 49 | no-implied-eval: error, 50 | no-invalid-this: off, 51 | no-iterator: error, 52 | no-labels: error, 53 | no-lone-blocks: error, 54 | no-loop-func: error, 55 | no-magic-numbers: off, 56 | no-multi-spaces: error, 57 | no-multi-str: error, 58 | no-new: error, 59 | no-new-func: error, 60 | no-new-wrappers: error, 61 | no-octal: error, 62 | no-octal-escape: error, 63 | no-param-reassign: off, 64 | no-proto: error, 65 | no-redeclare: error, 66 | no-restricted-properties: error, 67 | no-return-assign: error, 68 | no-return-await: error, 69 | no-script-url: error, 70 | no-self-assign: error, 71 | no-self-compare: error, 72 | no-sequences: error, 73 | no-throw-literal: error, 74 | no-unmodified-loop-condition: error, 75 | no-unused-expressions: error, 76 | no-unused-labels: error, 77 | no-useless-call: error, 78 | no-useless-concat: error, 79 | no-useless-escape: error, 80 | no-useless-return: error, 81 | no-void: error, 82 | no-warning-comments: error, 83 | no-with: error, 84 | prefer-promise-reject-errors: error, 85 | radix: error, 86 | require-await: off, 87 | vars-on-top: off, 88 | wrap-iife: error, 89 | 90 | // Strict Mode 91 | strict: [ error, never ], 92 | 93 | // Variables 94 | init-declarations: off, 95 | no-catch-shadow: error, 96 | no-delete-var: error, 97 | no-label-var: error, 98 | no-restricted-globals: error, 99 | no-shadow: error, 100 | no-shadow-restricted-names: error, 101 | no-undef: error, 102 | no-undef-init: off, 103 | no-undefined: off, 104 | no-unused-vars: error, 105 | no-use-before-define: [ error, { functions: false } ], 106 | 107 | // Node.js 108 | callback-return: error, 109 | global-require: error, 110 | handle-callback-err: error, 111 | no-buffer-constructor: error, 112 | no-mixed-requires: error, 113 | no-new-require: error, 114 | no-path-concat: error, 115 | no-process-env: error, 116 | no-process-exit: error, 117 | no-restricted-modules: error, 118 | no-sync: error, 119 | 120 | // Stylistic Issues 121 | array-bracket-newline: [ error, consistent ], 122 | array-bracket-spacing: error, 123 | array-element-newline: off, 124 | block-spacing: error, 125 | brace-style: [ error, stroustrup, { allowSingleLine: true } ], 126 | camelcase: error, 127 | capitalized-comments: off, 128 | comma-dangle: [ error, always-multiline ], 129 | comma-spacing: error, 130 | comma-style: error, 131 | computed-property-spacing: error, 132 | consistent-this: [ error, self ], 133 | eol-last: error, 134 | func-call-spacing: error, 135 | func-name-matching: error, 136 | func-names: off, 137 | func-style: [ error, declaration, { allowArrowFunctions: true } ], 138 | function-paren-newline: off, 139 | id-blacklist: error, 140 | id-length: off, 141 | id-match: error, 142 | implicit-arrow-linebreak: off, 143 | indent: [ error, 2 ], 144 | jsx-quotes: error, 145 | key-spacing: [ error, { mode: minimum } ], 146 | keyword-spacing: error, 147 | line-comment-position: off, 148 | linebreak-style: error, 149 | lines-around-comment: [ error, { allowClassStart: true, allowObjectStart: true } ], 150 | lines-between-class-members: [ error, always, { exceptAfterSingleLine: true } ], 151 | max-depth: error, 152 | max-len: off, 153 | max-lines: off, 154 | max-nested-callbacks: error, 155 | max-params: [ error, { max: 5 } ], 156 | max-statements: off, 157 | max-statements-per-line: error, 158 | multiline-comment-style: off, 159 | multiline-ternary: off, 160 | new-cap: error, 161 | new-parens: error, 162 | newline-per-chained-call: off, 163 | no-array-constructor: error, 164 | no-bitwise: error, 165 | no-continue: off, 166 | no-inline-comments: off, 167 | no-lonely-if: error, 168 | no-mixed-operators: off, 169 | no-mixed-spaces-and-tabs: error, 170 | no-multi-assign: off, 171 | no-multiple-empty-lines: error, 172 | no-negated-condition: off, 173 | no-nested-ternary: off, 174 | no-new-object: error, 175 | no-plusplus: off, 176 | no-restricted-syntax: error, 177 | no-tabs: error, 178 | no-ternary: off, 179 | no-trailing-spaces: error, 180 | no-underscore-dangle: off, 181 | no-unneeded-ternary: error, 182 | no-whitespace-before-property: error, 183 | nonblock-statement-body-position: [ error, below ], 184 | object-curly-newline: off, 185 | object-curly-spacing: [ error, always ], 186 | object-property-newline: off, 187 | one-var: off, 188 | one-var-declaration-per-line: off, 189 | operator-assignment: error, 190 | operator-linebreak: [ error, after, { overrides: { ":": after } } ], 191 | padded-blocks: [ error, never ], 192 | padding-line-between-statements: error, 193 | quote-props: [ error, consistent-as-needed ], 194 | quotes: [ error, single, { avoidEscape: true } ], 195 | require-jsdoc: off, 196 | semi: error, 197 | semi-spacing: error, 198 | semi-style: error, 199 | sort-keys: off, 200 | sort-vars: off, 201 | space-before-blocks: error, 202 | space-before-function-paren: [ error, { anonymous: always, named: never } ], 203 | space-in-parens: error, 204 | space-infix-ops: error, 205 | space-unary-ops: error, 206 | spaced-comment: error, 207 | switch-colon-spacing: error, 208 | template-tag-spacing: error, 209 | unicode-bom: error, 210 | wrap-regex: off, 211 | 212 | // React 213 | react/jsx-uses-react: error, 214 | react/jsx-uses-vars: error, 215 | react-hooks/rules-of-hooks: error, 216 | react-hooks/exhaustive-deps: off, 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Tell us about something that is broken 4 | 5 | --- 6 | 7 | 8 | A clear and concise description of what the bug is, 9 | focused on the difference between expected and observed behavior. 10 | 11 | ### How to reproduce 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | 21 | 22 | ### Version information 23 | - Node.js Version: `node --version` 24 | - npm Version: `npm --version` 25 | - OS: [e.g., iOS] 26 | - Browser: [e.g., Chrome, Safari] 27 | - Browser Version: [e.g., 22] 28 | 29 | ### Additional info 30 | 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | 8 | ### Motivation 9 | A clear and concise description of the problem context. 10 | E.g., _It frustrates me when [...]_ 11 | 12 | ### Proposal 13 | A clear and concise description of what you want to happen. 14 | 15 | ### Additional info 16 | 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Kind of change 4 | 5 | - [ ] Bugfix 6 | - [ ] Feature 7 | - [ ] Code style update 8 | - [ ] Refactor 9 | - [ ] Build-related changes 10 | - [ ] Other, please describe: 11 | 12 | ### Proposed changes 13 | Describe what the goal of this PR, how it works, 14 | and the problem or use-case it solves. 15 | 16 | ### Relevant issues 17 | 20 | 21 | ### Breaking change? 22 | 23 | - [ ] Yes 24 | - [ ] No 25 | 26 | 27 | 28 | ### Requirements check 29 | 30 | - [ ] All tests are passing 31 | - [ ] New/updated tests are included 32 | 33 | ### Additional info 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | lib 4 | module 5 | node_modules 6 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm test" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 12 5 | - 14 6 | - lts/* 7 | - node 8 | env: 9 | - DEFAULT_NODE_VERSION=12 10 | 11 | before_script: 12 | - npm run build 13 | 14 | after_success: 15 | - if [ "$TRAVIS_NODE_VERSION" == "$DEFAULT_NODE_VERSION" ]; then 16 | npm install coveralls; 17 | node_modules/.bin/jest --coverage --coverageReporters=text-lcov | 18 | node_modules/.bin/coveralls; 19 | fi 20 | 21 | cache: 22 | apt: true 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright ©2018–present Ruben Verborgh 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 13 | in all 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 LIABILITY 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 20 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Core React Components for Solid 2 | A core set of [React](https://reactjs.org/) components and hooks 3 | for building your own [Solid](https://solid.inrupt.com/) components and apps. 4 | 5 | [![npm version](https://img.shields.io/npm/v/@solid/react.svg)](https://www.npmjs.com/package/@solid/react) 6 | [![Build Status](https://travis-ci.com/solid/react-components.svg?branch=master)](https://travis-ci.com/solid/react-components) 7 | [![Coverage Status](https://coveralls.io/repos/github/solid/react-components/badge.svg?branch=master)](https://coveralls.io/github/solid/react-components?branch=master) 8 | [![Dependency Status](https://david-dm.org/solid/react-components.svg)](https://david-dm.org/solid/react-components) 9 | 10 | Follow this [live tutorial](https://trav.page/log/write-solid/) 11 | to get you started! 12 | 13 | ### Purpose 14 | ✨ [Solid](https://solid.inrupt.com/) is an ecosystem for people, data, and apps 15 | in which people can store their data where they want, 16 | independently of the apps they use. 17 | 18 | ⚛️ This library aims to: 19 | 1. provide React developers with components to develop fun Solid apps 👨🏿‍💻 20 | 2. enable React developers to build their own components for Solid 👷🏾‍♀️ 21 | 22 | Solid uses 🔗 [Linked Data](https://solid.inrupt.com/docs/intro-to-linked-data), 23 | so people's data from all over the Web can be connected together 24 | instead of needing to be stored in one giant space. 25 | This library makes working with Linked Data easier, too. 26 | 27 | ### Example apps 28 | These apps have already been built with React for Solid: 29 | - [Profile viewer](https://github.com/solid/profile-viewer-react) 👤 30 | - [LDflex playground](https://solid.github.io/ldflex-playground/) ⚾ 31 | - [Demo app](https://github.com/solid/react-components/blob/master/demo/app.jsx) 🔍 32 | - [Another profile viewer](https://gitlab.com/angelo-v/solid-profile-viewer) 👤 33 | - […add yours!](https://github.com/solid/react-components/edit/master/README.md) 34 | 35 | ### Install and go 36 | First add the package: 37 | ```bash 38 | yarn add @solid/react # or 39 | npm install @solid/react 40 | ``` 41 | 42 | Then you can import components like this: 43 | ```JavaScript 44 | import { LoginButton, Value } from '@solid/react'; 45 | ``` 46 | 47 | ## Build Solid apps from React components 48 | The [demo app](https://github.com/solid/react-components/tree/master/demo) 49 | will inspire you on how to use the components listed below. 50 | 51 | ### 👮🏻‍♀️ Authentication 52 | #### Log the user in and out 53 | You will need a copy of [popup.html](https://solid.github.io/solid-auth-client/dist/popup.html) in your application folder. 54 | ```jsx 55 | 56 | Log me out 57 | // Shows LoginButton or LogoutButton depending on the user's status 58 | 59 | ``` 60 | 61 | #### Display different content to logged in users 62 | ```jsx 63 | 64 |

You are not logged in, and this is a members-only area!

65 |
66 | 67 |

You are logged in and can see the special content.

68 |
69 | ``` 70 | 71 | ### 👍🏾 Social interactions 72 | With Solid, people can _like_ any page or thing on the Web: 73 | ```jsx 74 | // the current page 75 | GitHub 76 | Ruben's website 77 | poverty 78 | Ruben 79 | ``` 80 | 81 | Your social interactions are stored in your own data pod. 82 | Build your own interactions with an ``. 83 | 84 | ### 🖥️ Get data from Solid 85 | #### Load data from the user and from the Web 86 | ```jsx 87 | 88 |

Welcome back,

89 | 90 |
    91 |
  • Your inbox
  • 92 |
  • Your homepage
  • 93 |
94 |
95 | 96 |

Random friend of

97 | 98 | 99 |

All friends

100 | 101 | 102 |

Random blog post

103 | 104 | 105 |

All blog posts

106 | 107 | 108 | ``` 109 | 110 | #### Create data expressions with LDflex 111 | Solid React data components 112 | use the [LDFlex](https://github.com/solid/query-ldflex/) language 113 | to build paths to the data you want. 114 | 115 | For example: 116 | - `"user.firstName"` will resolve to the logged in user's first name 117 | - `"user.friends.firstName"` will resolve to the first name of the user's friends 118 | - `"[https://ruben.verborgh.org/profile/#me].homepage"` will resolve to Ruben's homepage 119 | - `"[https://ruben.verborgh.org/profile/#me].friends.firstName"` will resolve to Ruben's friends' names 120 | 121 | Learn how to [create your own LDflex expressions](https://github.com/solid/query-ldflex/#creating-data-paths). 122 | 123 | #### Automatically refresh when data is updated 124 | Different Solid apps can write to the same documents at the same time. 125 | If you put components inside of ``, 126 | they will refresh when data is updated externally. 127 | In the `subscribe` attribute, list the documents that should be tracked for updates; 128 | set it to `*` (default) if you want to listen to all documents accessed by your app. 129 | Use live updates sparingly, 130 | since change monitoring consumes additional resources, 131 | especially when monitoring documents on different data pods. 132 | 133 | 134 | ## 💪🏾 Create your own components 135 | The Solid React library makes it easy 136 | to create your own components 137 | that interact with the current user 138 | and fetch Linked Data from the Web. 139 | This is easy thanks to [hooks](https://reactjs.org/docs/hooks-intro.html), 140 | introduced in React 16.8. 141 | A good way to get started is by looking at the [implementation](https://github.com/solid/react-components/tree/master/src/components) 142 | of built-in components like 143 | [AuthButton](https://github.com/solid/react-components/blob/master/src/components/AuthButton.jsx), 144 | [Name](https://github.com/solid/react-components/blob/master/src/components/Name.jsx), 145 | and 146 | [List](https://github.com/solid/react-components/blob/master/src/components/List.jsx). 147 | 148 | Not a hooks user yet, 149 | or prefer writing components with functions instead of classes? 150 | Our [higher-order components](https://github.com/solid/react-components/blob/v1.3.1/README.md#-building-your-own-components) 151 | will help you out. 152 | 153 | #### Identify the user 154 | In Solid, people are identified by a WebID, 155 | a URL that points to them and leads to their data. 156 | 157 | The `useWebID` hook gives you the WebID 158 | of the currently logged in user as a string, 159 | which changes automatically whenever someone logs in or out. 160 | The `useLoggedIn` and `useLoggedOut` hooks 161 | provide similar functionality, but return a boolean value. 162 | 163 | ```jsx 164 | import { useWebId, useLoggedIn, useLoggedOut } from '@solid/react'; 165 | 166 | function WebIdStatus() { 167 | const webId = useWebId(); 168 | return Your WebID is {webId}.; 169 | } 170 | 171 | function Greeting() { 172 | const loggedOut = useLoggedOut(); 173 | return You are {loggedOut ? 'anonymous' : 'logged in' }.; 174 | } 175 | ``` 176 | 177 | #### Load data from the user or the Web 178 | The `useLDflexValue` and `useLDflexList` hooks 179 | let you load a single result or multiple results 180 | of an LDflex expression. 181 | 182 | ```jsx 183 | import { useLDflexValue, useLDflexList } from '@solid/react'; 184 | 185 | function ConnectionCount() { 186 | const name = useLDflexValue('user.firstName') || 'unknown'; 187 | const friends = useLDflexList('user.friends'); 188 | return {`${name}`} is connected to {friends.length} people.; 189 | } 190 | ``` 191 | Note how we convert `name` into a string through `` `${name}` ``. 192 | Alternatively, we could also use `name.value`. 193 | We do this because LDflex values 194 | are [terms](https://rdf.js.org/data-model-spec/#term-interface) 195 | rather than strings, 196 | so they can have other properties like `language` and `termType`. 197 | Also, an LDflex value can be used as a path again, 198 | so you can keep on adding properties. 199 | 200 | Finally, the `useLDflex` hook also returns status information about the expression. 201 | When its optional second argument is `true`, it returns a list. 202 | 203 | ```jsx 204 | import { List, useLDflex } from '@solid/react'; 205 | 206 | function BlogPosts({ author = 'https://ruben.verborgh.org/profile/#me' }) { 207 | const expression = `[${author}].blog[schema:blogPost].label`; 208 | const [posts, pending, error] = useLDflex(expression, true); 209 | 210 | if (pending) 211 | return loading ({posts.length} posts so far); 212 | if (error) 213 | return loading failed: {error.message}; 214 | 215 | return
    {posts.map((label, index) => 216 |
  • {`${label}`}
  • )} 217 |
; 218 | } 219 | ``` 220 | 221 | If your components should automatically refresh on updates 222 | when placed inside of a `` component, 223 | use the `useLiveUpdate` hook (already included in all 3 `useLDflex` hooks). 224 | It returns the latest update as `{ timestamp, url }`. 225 | 226 | ## License 227 | ©2018–present [Ruben Verborgh](https://ruben.verborgh.org/), 228 | [MIT License](https://github.com/solid/react-components/blob/master/LICENSE.md). 229 | -------------------------------------------------------------------------------- /demo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | env: { 3 | browser: true, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /demo/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AuthButton, LoggedIn, LoggedOut, 4 | Value, Image, List, Link, Label, 5 | Like, 6 | } from '../src/'; 7 | 8 | export default function App() { 9 | return ( 10 |
11 |
12 |

Solid App

13 |

14 |

15 | 16 | the Solid React components 17 |

18 |
19 |
20 | 21 | 22 |

Welcome back, .

23 |

Friends

24 | 25 |
26 | 27 |

You are logged out.

28 |
29 |
30 |
31 |

32 | Solid React demo app 33 | by

36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 11pt; 4 | max-width: 600px; 5 | margin: 0 auto; 6 | } 7 | 8 | .solid.value.pending:before { 9 | content: "…"; 10 | color: #999; 11 | } 12 | 13 | .solid.value.empty:before { 14 | content: "(none)"; 15 | color: #999; 16 | } 17 | 18 | .solid.value.error:before { 19 | content: '[' attr(data-error) ']'; 20 | font-style: italic; 21 | color: red; 22 | } 23 | 24 | .solid.activity { 25 | border: none; 26 | background: none; 27 | cursor: pointer; 28 | } 29 | .solid.activity:hover { 30 | text-decoration: underline; 31 | } 32 | .solid.activity::before { 33 | display: inline-block; 34 | } 35 | .solid.like::before { 36 | content: '👍 '; 37 | } 38 | .solid.dislike::before { 39 | content: '👎 '; 40 | } 41 | 42 | img.profile { 43 | float: right; 44 | max-width: 120px; 45 | max-height: 120px; 46 | } 47 | 48 | footer { 49 | font-size: small; 50 | margin-top: 2em; 51 | } 52 | -------------------------------------------------------------------------------- /demo/index.jsx: -------------------------------------------------------------------------------- 1 | import App from './app'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | const container = document.createElement('div'); 6 | document.body.appendChild(container); 7 | ReactDOM.render(, container); 8 | -------------------------------------------------------------------------------- /demo/profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | "/src/**/*.(js|jsx)", 4 | ], 5 | setupFilesAfterEnv: [ 6 | "/test/setup.js", 7 | ], 8 | testMatch: [ 9 | "/test/**/*-test.(js|jsx)", 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solid/react", 3 | "version": "1.10.0", 4 | "description": "Efficiently build Solid apps and components with React", 5 | "author": "Ruben Verborgh (https://ruben.verborgh.org/)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/solid/react-components" 10 | }, 11 | "main": "lib/index.js", 12 | "module": "module/index.js", 13 | "sideEffects": false, 14 | "files": [ 15 | "src", 16 | "lib", 17 | "module", 18 | "dist", 19 | "!dist/demo", 20 | ".babelrc", 21 | "webpack" 22 | ], 23 | "dependencies": { 24 | "@babel/runtime": "^7.1.2", 25 | "@solid/query-ldflex": "^2.5.1", 26 | "prop-types": "^15.6.2", 27 | "solid-auth-client": "^2.3.0" 28 | }, 29 | "peerDependencies": { 30 | "react": "^16.8.4" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.0.0", 34 | "@babel/core": "^7.0.1", 35 | "@babel/plugin-proposal-class-properties": "^7.1.0", 36 | "@babel/plugin-transform-runtime": "^7.1.0", 37 | "@babel/preset-env": "^7.0.0", 38 | "@babel/preset-react": "^7.0.0", 39 | "babel-eslint": "^10.0.1", 40 | "babel-loader": "^8.0.2", 41 | "copy-webpack-plugin": "^5.0.0", 42 | "eslint": "^5.6.0", 43 | "eslint-plugin-jest": "^22.3.0", 44 | "eslint-plugin-react": "^7.11.1", 45 | "eslint-plugin-react-hooks": "^1.2.0", 46 | "html-webpack-include-assets-plugin": "^1.0.5", 47 | "html-webpack-plugin": "^3.2.0", 48 | "husky": "^1.1.2", 49 | "jest": "^24.3.0", 50 | "jest-dom": "3.1.2", 51 | "jest-mock-promise": "^1.0.23", 52 | "react": "^16.8.4", 53 | "react-dom": "^16.8.4", 54 | "react-hooks-testing-library": "0.3.4", 55 | "react-testing-library": "6.0.0", 56 | "webpack": "^4.19.1", 57 | "webpack-cli": "^3.1.0", 58 | "webpack-dev-server": "^3.11.0" 59 | }, 60 | "scripts": { 61 | "build": "npm run build:clean && npm run build:lib && npm run build:module && npm run build:dist && npm run build:bundle && npm run build:demo", 62 | "build:clean": "rm -rf lib dist", 63 | "build:lib": "babel src --out-dir lib", 64 | "build:module": "BABEL_ENV=module babel src --out-dir module", 65 | "build:dist": "webpack -p --config=./webpack/webpack.lib.config.js", 66 | "build:bundle": "webpack -p --config=./webpack/webpack.bundle.config.js", 67 | "build:demo": "webpack --config=./webpack/webpack.demo.config.js", 68 | "jest": "jest", 69 | "lint": "eslint --ext .js,.jsx src test demo webpack", 70 | "prepublishOnly": "npm run build", 71 | "start": "npm run start:demo", 72 | "start:demo": "webpack-dev-server --config=./webpack/webpack.demo.config.js", 73 | "test": "npm run lint && npm run jest -- --collectCoverage", 74 | "test:dev": "npm run jest -- --watch" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/ExpressionEvaluator.js: -------------------------------------------------------------------------------- 1 | import { createTaskQueue } from './util'; 2 | import data from '@solid/query-ldflex'; 3 | 4 | const evaluatorQueue = createTaskQueue(); 5 | 6 | /** 7 | * Evaluates a map of LDflex expressions into a singular value or a list. 8 | * Expressions can be changed and/or re-evaluated. 9 | */ 10 | export default class ExpressionEvaluator { 11 | pending = {}; 12 | cancel = false; 13 | 14 | /** Stops all pending and future evaluations */ 15 | destroy() { 16 | this.pending = {}; 17 | this.cancel = true; 18 | evaluatorQueue.clear(this); 19 | } 20 | 21 | /** Evaluates the given singular value and list expressions. */ 22 | async evaluate(values, lists, updateCallback) { 23 | // Reset the pending status and clear any errors 24 | updateCallback({ pending: true, error: undefined }); 25 | 26 | // Create evaluators for each expression, and mark them as pending 27 | const evaluators = evaluatorQueue.schedule([ 28 | ...Object.entries(values).map(([key, expr]) => 29 | () => this.evaluateAsValue(key, expr, updateCallback)), 30 | ...Object.entries(lists).map(([key, expr]) => 31 | () => this.evaluateAsList(key, expr, updateCallback)), 32 | ], this); 33 | 34 | // Wait until all evaluators are done (or one of them errors) 35 | try { 36 | await Promise.all(evaluators); 37 | } 38 | catch (error) { 39 | updateCallback({ error }); 40 | } 41 | 42 | // Update the pending flag if all evaluators wrote their value or errored, 43 | // and if no new evaluators are pending 44 | const statuses = await Promise.all(evaluators.map(e => e.catch(error => { 45 | console.warn('@solid/react-components', 'Expression evaluation failed.', error); 46 | return true; 47 | }))); 48 | // Stop if results are no longer needed 49 | if (this.cancel) 50 | return; 51 | // Reset the pending flag if all are done and no others are pending 52 | if (!statuses.some(done => !done) && Object.keys(this.pending).length === 0) 53 | updateCallback({ pending: false }); 54 | } 55 | 56 | /** Evaluates the property expression as a singular value. */ 57 | async evaluateAsValue(key, expr, updateCallback) { 58 | // Obtain and await the promise 59 | const promise = this.pending[key] = this.resolveExpression(expr); 60 | let value; 61 | try { 62 | value = await promise; 63 | // Stop if another evaluator took over in the meantime (component update) 64 | if (this.pending[key] !== promise) 65 | return false; 66 | } 67 | // Update the result and remove the evaluator, even in case of errors 68 | finally { 69 | if (this.pending[key] === promise) { 70 | delete this.pending[key]; 71 | updateCallback({ [key]: value }); 72 | } 73 | } 74 | return true; 75 | } 76 | 77 | /** Evaluates the property expression as a list. */ 78 | async evaluateAsList(key, expr, updateCallback) { 79 | // Read the iterable's items, throttling updates to avoid congestion 80 | let empty = true; 81 | const items = []; 82 | const iterable = this.pending[key] = this.resolveExpression(expr); 83 | const update = () => !this.cancel && updateCallback({ [key]: [...items] }); 84 | const updateQueue = createTaskQueue({ timeBetween: 100, drop: true }); 85 | try { 86 | for await (const item of iterable) { 87 | // Stop if another evaluator took over in the meantime (component update) 88 | if (this.pending[key] !== iterable) { 89 | updateQueue.clear(); 90 | return false; 91 | } 92 | // Add the item and schedule an update 93 | empty = false; 94 | items.push(item); 95 | updateQueue.schedule(update); 96 | } 97 | } 98 | // Ensure pending updates are applied immediately, and the evaluator is removed 99 | finally { 100 | const needsUpdate = empty || updateQueue.clear(); 101 | if (this.pending[key] === iterable) { 102 | delete this.pending[key]; 103 | if (needsUpdate) 104 | update(); 105 | } 106 | } 107 | return true; 108 | } 109 | 110 | /** Resolves the expression into an LDflex path. */ 111 | resolveExpression(expr) { 112 | // Ignore an empty expression 113 | if (!expr) 114 | return ''; 115 | // Resolve an LDflex string expression 116 | else if (typeof expr === 'string') 117 | return data.resolve(expr); 118 | // Return a resolved LDflex path (and any other object) as-is 119 | else 120 | return expr; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/UpdateContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Context that tracks the latest updates to resources 5 | * and has its value set to `{ timestamp, url }`. 6 | */ 7 | export default React.createContext({}); 8 | -------------------------------------------------------------------------------- /src/UpdateTracker.js: -------------------------------------------------------------------------------- 1 | import auth from 'solid-auth-client'; 2 | import ldflex from '@solid/query-ldflex'; 3 | 4 | // Wildcard for tracking all resources 5 | const ALL = '*'; 6 | // Subscribers per resource URL 7 | const subscribers = {}; 8 | // WebSockets per host 9 | const webSockets = {}; 10 | // All fetched URLs 11 | const fetchedUrls = new Set(); 12 | 13 | /** 14 | * Notifies a subscriber of updates to resources on a Solid server, 15 | * by listening to its WebSockets. 16 | */ 17 | export default class UpdateTracker { 18 | /** Create a tracker that sends updates to the given subscriber function. */ 19 | constructor(subscriber) { 20 | this.subscriber = subscriber; 21 | } 22 | 23 | /** Subscribes to changes in the given resources */ 24 | async subscribe(...urls) { 25 | for (let url of urls) { 26 | // Create a new subscription to the resource if none existed 27 | url = url.replace(/#.*/, ''); 28 | if (!(url in subscribers)) { 29 | subscribers[url] = new Set(); 30 | const tracked = url !== ALL ? [url] : [...fetchedUrls]; 31 | await Promise.all(tracked.map(trackResource)); 32 | } 33 | // Add the new subscriber 34 | subscribers[url].add(this.subscriber); 35 | } 36 | } 37 | 38 | /** Unsubscribes to changes in the given resources */ 39 | async unsubscribe(...urls) { 40 | for (let url of urls) { 41 | url = url.replace(/#.*/, ''); 42 | if (url in subscribers) 43 | subscribers[url].delete(this.subscriber); 44 | } 45 | } 46 | } 47 | 48 | /** Tracks updates to the given resource */ 49 | async function trackResource(url, options) { 50 | // Obtain a WebSocket for the given host 51 | const { host } = new URL(url); 52 | if (!(host in webSockets)) { 53 | webSockets[host] = Promise.resolve(null).then(() => 54 | createWebSocket(url, { host, ...options })); 55 | } 56 | const webSocket = await webSockets[host]; 57 | 58 | // Track subscribed resources to resubscribe later if needed 59 | webSocket.resources.add(url); 60 | // Subscribe to updates on the resource 61 | webSocket.enqueue(`sub ${url}`); 62 | } 63 | 64 | /** Creates a WebSocket for the given URL. */ 65 | async function createWebSocket(resourceUrl, options) { 66 | const webSocketUrl = await getWebSocketUrl(resourceUrl); 67 | const webSocket = new WebSocket(webSocketUrl); 68 | return Object.assign(webSocket, { 69 | resources: new Set(), 70 | reconnectionAttempts: 0, 71 | reconnectionDelay: 1000, 72 | enqueue, 73 | onmessage: processMessage, 74 | onclose: reconnect, 75 | ready: new Promise(resolve => { 76 | webSocket.onopen = () => { 77 | webSocket.reconnectionAttempts = 0; 78 | webSocket.reconnectionDelay = 1000; 79 | resolve(); 80 | }; 81 | }), 82 | }, options); 83 | } 84 | 85 | /** Retrieves the WebSocket URL for the given resource. */ 86 | async function getWebSocketUrl(resourceUrl) { 87 | const response = await auth.fetch(resourceUrl); 88 | const webSocketUrl = response.headers.get('Updates-Via'); 89 | if (!webSocketUrl) 90 | throw new Error(`No WebSocket found for ${resourceUrl}`); 91 | return webSocketUrl; 92 | } 93 | 94 | /** Enqueues data on the WebSocket */ 95 | async function enqueue(data) { 96 | await this.ready; 97 | this.send(data); 98 | } 99 | 100 | /** Processes an update message from the WebSocket */ 101 | function processMessage({ data }) { 102 | // Verify the message is an update notification 103 | const match = /^pub +(.+)/.exec(data); 104 | if (!match) 105 | return; 106 | 107 | // Invalidate the cache for the resource 108 | const url = match[1]; 109 | ldflex.clearCache(url); 110 | 111 | // Notify the subscribers 112 | const update = { timestamp: new Date(), url }; 113 | for (const subscriber of subscribers[url] || []) 114 | subscriber(update); 115 | for (const subscriber of subscribers[ALL] || []) 116 | subscriber(update); 117 | } 118 | 119 | /** Reconnects a socket after a backoff delay */ 120 | async function reconnect() { 121 | // Ensure this socket is no longer marked as active 122 | delete webSockets[this.host]; 123 | 124 | // Try setting up a new socket 125 | if (this.reconnectionAttempts < 6) { 126 | // Wait a given backoff period before reconnecting 127 | await new Promise(done => (setTimeout(done, this.reconnectionDelay))); 128 | // Try reconnecting, and back off exponentially 129 | await Promise.all([...this.resources].map(url => 130 | trackResource(url, { 131 | reconnectionAttempts: this.reconnectionAttempts + 1, 132 | reconnectionDelay: this.reconnectionDelay * 2, 133 | }) 134 | )); 135 | } 136 | } 137 | 138 | /** Closes all sockets */ 139 | export async function resetWebSockets() { 140 | for (const url in subscribers) 141 | delete subscribers[url]; 142 | for (const host in webSockets) { 143 | let socket = webSockets[host]; 144 | delete webSockets[host]; 145 | try { 146 | socket = await socket; 147 | delete socket.onclose; 148 | socket.close(); 149 | } 150 | catch { /**/ } 151 | } 152 | fetchedUrls.clear(); 153 | } 154 | 155 | // Keep track of all fetched resources 156 | auth.on('request', url => { 157 | if (!fetchedUrls.has(url)) { 158 | if (ALL in subscribers) 159 | trackResource(url); 160 | fetchedUrls.add(url); 161 | } 162 | }); 163 | -------------------------------------------------------------------------------- /src/components/ActivityButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import data from '@solid/query-ldflex'; 3 | import { srcToLDflex } from '../util'; 4 | import useLDflexValue from '../hooks/useLDflexValue'; 5 | 6 | const { as } = data.context; 7 | 8 | /** 9 | * Button that displays whether the user has performed an activity; 10 | * when clicked, performs the activity. 11 | */ 12 | export default function ActivityButton({ 13 | activityType = `${as}Like`, 14 | object = `[${window.location.href}]`, 15 | children, 16 | shortName = /\w*$/.exec(activityType)[0], 17 | className = `solid activity ${shortName.toLowerCase()}`, 18 | activateText = shortName, 19 | deactivateText = activateText, 20 | activateLabel = children ? [activateText, ' ', children] : activateText, 21 | deactivateLabel = children ? [deactivateText, ' ', children] : deactivateText, 22 | ...props 23 | }) { 24 | // Look up a possibly existing activity 25 | object = srcToLDflex(object); 26 | const [exists, setExists] = useState(); 27 | const activity = useLDflexValue(`${object}.findActivity("${activityType}")`); 28 | if (exists === undefined && activity) 29 | setExists(true); 30 | 31 | // Creates a new activity (if none already exists) 32 | async function toggleActivity() { 33 | // Optimistically display the result 34 | setExists(!exists); 35 | try { 36 | // Try performing the action 37 | const action = !exists ? 'create' : 'delete'; 38 | await data.resolve(`${object}.${action}Activity("${activityType}")`); 39 | // Confirm the result (in case a concurrent action was pending) 40 | setExists(!exists); 41 | } 42 | catch (error) { 43 | // Revert to the previous state 44 | setExists(exists); 45 | console.warn('@solid/react-components', error); 46 | } 47 | } 48 | 49 | // Return the activity button 50 | className = `${className} ${exists ? 'performed' : ''}`; 51 | return ( 52 | 55 | ); 56 | } 57 | 58 | // Internal helper for creating custom activity buttons 59 | export function customActivityButton(type, activate, deactivate, deactivateNoChildren) { 60 | const activityType = `${as}${type}`; 61 | return ({ 62 | object, 63 | children = object ? null : 'this page', 64 | activateText = activate, 65 | deactivateText = children ? deactivate : deactivateNoChildren, 66 | ...props 67 | }) => 68 | ; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/AuthButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoginButton from './LoginButton'; 3 | import LogoutButton from './LogoutButton'; 4 | import useLoggedIn from '../hooks/useLoggedIn'; 5 | 6 | /** Button that lets the user log in or out with Solid. */ 7 | export default function AuthButton({ login, logout, ...props }) { 8 | return useLoggedIn() ? 9 | {logout} : 10 | {login}; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/DislikeButton.jsx: -------------------------------------------------------------------------------- 1 | import { customActivityButton } from './ActivityButton'; 2 | 3 | /** Button to view and perform a "Dislike" action on an item. */ 4 | export default customActivityButton('Dislike', 'Dislike', 'You disliked', 'Disliked'); 5 | -------------------------------------------------------------------------------- /src/components/FollowButton.jsx: -------------------------------------------------------------------------------- 1 | import { customActivityButton } from './ActivityButton'; 2 | 3 | /** Button to view and perform a "Follow" action on an item. */ 4 | export default customActivityButton('Follow', 'Follow', 'You follow', 'Following'); 5 | -------------------------------------------------------------------------------- /src/components/Image.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useLDflexValue from '../hooks/useLDflexValue'; 3 | 4 | /** Displays an image whose source is a Solid LDflex expression. */ 5 | export default function Image({ src, defaultSrc, children = null, ...props }) { 6 | src = useLDflexValue(src) || defaultSrc; 7 | return src ? : children; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Label.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Value from './Value'; 3 | import { srcToLDflex } from '../util'; 4 | 5 | /** Displays the label of a Solid LDflex subject. */ 6 | export default function Label({ src, children = null }) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/LikeButton.jsx: -------------------------------------------------------------------------------- 1 | import { customActivityButton } from './ActivityButton'; 2 | 3 | /** Button to view and perform a "Like" action on an item. */ 4 | export default customActivityButton('Like', 'Like', 'You liked', 'Liked'); 5 | -------------------------------------------------------------------------------- /src/components/Link.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Label from './Label'; 3 | import useLDflexValue from '../hooks/useLDflexValue'; 4 | 5 | /** Creates a link to the value of the Solid LDflex expression. */ 6 | export default function Link({ href, children, ...props }) { 7 | href = useLDflexValue(href) || ''; 8 | children = children || ; 9 | return href ? {children} : children; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/List.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useLDflexList from '../hooks/useLDflexList'; 3 | 4 | /** Displays a list of items matching a Solid LDflex expression. */ 5 | export default function List({ 6 | src, offset = 0, limit = Infinity, filter = () => true, 7 | container = items =>
    {items}
, 8 | children = (item, index) =>
  • {`${item}`}
  • , 9 | }) { 10 | const items = useLDflexList(src) 11 | .filter(filter) 12 | .slice(offset, +offset + +limit) 13 | .map(children); 14 | return container ? container(items) : items; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/LiveUpdate.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import UpdateContext from '../UpdateContext'; 3 | import useLatestUpdate from '../hooks/useLatestUpdate'; 4 | 5 | const { Provider } = UpdateContext; 6 | 7 | /** 8 | * Component that creates an UpdateContext by subscribing 9 | * to updates of an array (or whitespace-separated string) of resources. 10 | * 11 | * Children or descendants that use UpdateContext as a context 12 | * will be rerendered if any of those resources are updated. 13 | */ 14 | export default function LiveUpdate({ subscribe = '*', children = null }) { 15 | const urls = typeof subscribe !== 'string' ? subscribe : 16 | (/\S/.test(subscribe) ? subscribe.trim().split(/\s+/) : []); 17 | const latestUpdate = useLatestUpdate(...urls); 18 | return {children}; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/LoggedIn.jsx: -------------------------------------------------------------------------------- 1 | import useLoggedIn from '../hooks/useLoggedIn'; 2 | 3 | /** Pane that only shows its contents when the user is logged in. */ 4 | export default function LoggedIn({ children = null }) { 5 | const loggedIn = useLoggedIn(); 6 | return loggedIn ? children : null; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/LoggedOut.jsx: -------------------------------------------------------------------------------- 1 | import useLoggedOut from '../hooks/useLoggedOut'; 2 | 3 | /** Pane that only shows its contents when the user is logged out. */ 4 | export default function LoggedOut({ children = null }) { 5 | const loggedOut = useLoggedOut(); 6 | return loggedOut ? children : null; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/LoginButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import auth from 'solid-auth-client'; 3 | 4 | /** Button that lets the user log in with Solid. */ 5 | export default function LoginButton({ 6 | popup, 7 | children = 'Log in', 8 | className = 'solid auth login', 9 | }) { 10 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/LogoutButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import auth from 'solid-auth-client'; 3 | 4 | /** Button that lets the user log out with Solid. */ 5 | export default function LogoutButton({ 6 | children = 'Log out', 7 | className = 'solid auth logout', 8 | }) { 9 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Name.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Value from './Value'; 3 | import { srcToLDflex } from '../util'; 4 | 5 | /** Displays the name of a Solid LDflex subject. */ 6 | export default function Name({ src, children = null }) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Value.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useLDflex from '../hooks/useLDflex'; 3 | 4 | /** Displays the value of a Solid LDflex expression. */ 5 | export default function Value({ src, children }) { 6 | const [value, pending, error] = useLDflex(src); 7 | // Render stringified value 8 | if (value !== undefined && value !== null) 9 | return `${value}`; 10 | // Render pending state 11 | else if (pending) 12 | return children || ; 13 | // Render error state 14 | else if (error) 15 | return children || ; 16 | // Render empty value 17 | else 18 | return children || ; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/evaluateExpressions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import withWebId from './withWebId'; 3 | import ExpressionEvaluator from '../ExpressionEvaluator'; 4 | import { pick, getDisplayName } from '../util'; 5 | 6 | /** 7 | * Higher-order component that evaluates LDflex expressions in properties 8 | * and passes their results to the wrapped component. 9 | */ 10 | export default function evaluateExpressions(valueProps, listProps, Component) { 11 | // Shift the optional listProps parameter when not specified 12 | if (!Component) 13 | [Component, listProps] = [listProps, []]; 14 | valueProps = valueProps ? [...valueProps] : []; 15 | listProps = listProps ? [...listProps] : []; 16 | 17 | // Create the initial state for all Component instances 18 | const initialState = { pending: true }; 19 | for (const name of valueProps) 20 | initialState[name] = undefined; 21 | for (const name of listProps) 22 | initialState[name] = []; 23 | 24 | // Create a higher-order component that wraps the given Component 25 | class EvaluateExpressions extends React.Component { 26 | static displayName = `EvaluateExpressions(${getDisplayName(Component)})`; 27 | 28 | state = initialState; 29 | 30 | componentDidMount() { 31 | this.evaluator = new ExpressionEvaluator(); 32 | this.update = state => this.setState(state); 33 | this.evaluate(valueProps, listProps); 34 | } 35 | 36 | componentDidUpdate(prevProps) { 37 | // A property needs to be re-evaluated if it changed 38 | // or, if it is a string expression, when the user has changed 39 | // (which might influence the expression's evaluation). 40 | const newUser = this.props.webId !== prevProps.webId; 41 | const changed = name => this.props[name] !== prevProps[name] || 42 | newUser && typeof this.props[name] === 'string'; 43 | this.evaluate(valueProps.filter(changed), listProps.filter(changed)); 44 | } 45 | 46 | componentWillUnmount() { 47 | this.evaluator.destroy(); 48 | } 49 | 50 | render() { 51 | return ; 52 | } 53 | 54 | evaluate(values, lists) { 55 | const { props, evaluator } = this; 56 | if (values.length > 0 || lists.length > 0) 57 | evaluator.evaluate(pick(props, values), pick(props, lists), this.update); 58 | } 59 | } 60 | return withWebId(EvaluateExpressions); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/evaluateList.jsx: -------------------------------------------------------------------------------- 1 | import evaluateExpressions from './evaluateExpressions'; 2 | 3 | /** 4 | * Higher-order component that evaluates an LDflex list expression in a property 5 | * and passes its items to the wrapped component. 6 | */ 7 | export default function EvaluateList(propName, WrappedComponent) { 8 | return evaluateExpressions([], [propName], WrappedComponent); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/withWebId.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useWebId from '../hooks/useWebId'; 3 | import { higherOrderComponent } from '../util'; 4 | 5 | /** 6 | * Higher-order component that passes the WebID of the logged-in user 7 | * to the webId property of the wrapped component. 8 | */ 9 | export default higherOrderComponent('WithWebId', Component => 10 | props => ); 11 | -------------------------------------------------------------------------------- /src/hooks/useLDflex.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useDebugValue } from 'react'; 2 | import useWebId from './useWebId'; 3 | import useLiveUpdate from './useLiveUpdate'; 4 | import ExpressionEvaluator from '../ExpressionEvaluator'; 5 | 6 | const value = { result: undefined, pending: true, error: undefined }; 7 | const list = { result: [], pending: true, error: undefined }; 8 | const none = {}; 9 | 10 | /** 11 | * Evaluates the Solid LDflex expression. 12 | * Returns an array of [result, pending, error]. 13 | */ 14 | export default function useLDflex(expression, listMode = false) { 15 | // The user's WebID and recent updates might influence the evaluation 16 | const webId = useWebId(); 17 | const latestUpdate = useLiveUpdate(); 18 | 19 | // Obtain the latest expression result from the state 20 | const [{ result, pending, error }, update] = useState(listMode ? list : value); 21 | useDebugValue(error || result, toString); 22 | 23 | // Set up the expression evaluator 24 | useEffect(() => { 25 | const evaluator = new ExpressionEvaluator(); 26 | const query = { result: expression }; 27 | evaluator.evaluate(!listMode ? query : none, listMode ? query : none, 28 | changed => update(current => ({ ...current, ...changed }))); 29 | return () => evaluator.destroy(); 30 | }, [expression, latestUpdate, webId && typeof expression === 'string']); 31 | 32 | // Return the state components 33 | return [result, pending, error]; 34 | } 35 | 36 | export function toString(object) { 37 | return Array.isArray(object) ? object.map(toString) : `${object}`; 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useLDflexList.js: -------------------------------------------------------------------------------- 1 | import { useDebugValue } from 'react'; 2 | import useLDflex, { toString } from './useLDflex'; 3 | 4 | /** 5 | * Evaluates the Solid LDflex expression into a list. 6 | */ 7 | export default function useLDflexList(expression) { 8 | const [items] = useLDflex(expression, true); 9 | useDebugValue(items, toString); 10 | return items; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useLDflexValue.js: -------------------------------------------------------------------------------- 1 | import { useDebugValue } from 'react'; 2 | import useLDflex, { toString } from './useLDflex'; 3 | 4 | /** 5 | * Evaluates the Solid LDflex expression into a single value. 6 | */ 7 | export default function useLDflexValue(expression) { 8 | const [value] = useLDflex(expression, false); 9 | useDebugValue(value, toString); 10 | return value; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useLatestUpdate.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useDebugValue } from 'react'; 2 | import UpdateTracker from '../UpdateTracker'; 3 | 4 | /** 5 | * Hook that subscribes to updates on the given resources, 6 | * returning the latest update as `{ timestamp, url }`. 7 | */ 8 | export default function useLatestUpdate(...urls) { 9 | const [latestUpdate, setLatestUpdate] = useState({}); 10 | useDebugValue(latestUpdate.timestamp || null); 11 | useEffect(() => { 12 | const tracker = new UpdateTracker(setLatestUpdate); 13 | tracker.subscribe(...urls); 14 | return () => tracker.unsubscribe(...urls); 15 | }, urls); 16 | return latestUpdate; 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useLiveUpdate.js: -------------------------------------------------------------------------------- 1 | import { useContext, useDebugValue } from 'react'; 2 | import UpdateContext from '../UpdateContext'; 3 | 4 | /** 5 | * Hook that rerenders components inside of a container 6 | * whenever an update happens to one of the subscribed resources. 7 | * 8 | * This is a shortcut for using UpdateContext as a context, 9 | * and returns the latest update as `{ timestamp, url }`. 10 | */ 11 | export default function useLiveUpdate() { 12 | const latestUpdate = useContext(UpdateContext); 13 | useDebugValue(latestUpdate.timestamp || null); 14 | return latestUpdate; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useLoggedIn.js: -------------------------------------------------------------------------------- 1 | import { useDebugValue } from 'react'; 2 | import useWebId from './useWebId'; 3 | 4 | const isNotNull = (_, id) => id === undefined ? undefined : id !== null; 5 | 6 | /** 7 | * Returns whether the user is logged in, 8 | * or `undefined` if the user state is pending. 9 | */ 10 | export default function useLoggedIn() { 11 | const loggedIn = useWebId(isNotNull); 12 | useDebugValue(loggedIn); 13 | return loggedIn; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useLoggedOut.js: -------------------------------------------------------------------------------- 1 | import { useDebugValue } from 'react'; 2 | import useWebId from './useWebId'; 3 | 4 | const isNull = (_, id) => id === undefined ? undefined : id === null; 5 | 6 | /** 7 | * Returns whether the user is logged out, 8 | * or `undefined` if the user state is pending. 9 | */ 10 | export default function useLoggedOut() { 11 | const loggedOut = useWebId(isNull); 12 | useDebugValue(loggedOut); 13 | return loggedOut; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useWebId.js: -------------------------------------------------------------------------------- 1 | import { useReducer, useEffect, useDebugValue } from 'react'; 2 | import auth from 'solid-auth-client'; 3 | 4 | // Keep track of the WebID and the state setters tracking it 5 | let webId = undefined; 6 | const subscribers = new Set(); 7 | const getWebId = (_, id) => id; 8 | 9 | /** 10 | * Returns the WebID (string) of the active user, 11 | * `null` if there is no user, 12 | * or `undefined` if the user state is pending. 13 | */ 14 | export default function useWebId(reducer = getWebId) { 15 | const [result, updateWebId] = useReducer(reducer, webId, reducer); 16 | useDebugValue(webId); 17 | 18 | useEffect(() => { 19 | updateWebId(webId); 20 | subscribers.add(updateWebId); 21 | return () => subscribers.delete(updateWebId); 22 | }, []); 23 | 24 | return result; 25 | } 26 | 27 | // Inform subscribers when the WebID changes 28 | auth.trackSession(session => { 29 | webId = session ? session.webId : null; 30 | for (const subscriber of subscribers) 31 | subscriber(webId); 32 | }); 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import useWebId from './hooks/useWebId'; 2 | import useLoggedIn from './hooks/useLoggedIn'; 3 | import useLoggedOut from './hooks/useLoggedOut'; 4 | import useLDflex from './hooks/useLDflex'; 5 | import useLDflexValue from './hooks/useLDflexValue'; 6 | import useLDflexList from './hooks/useLDflexList'; 7 | import useLiveUpdate from './hooks/useLiveUpdate'; 8 | 9 | import withWebId from './components/withWebId'; 10 | import evaluateExpressions from './components/evaluateExpressions'; 11 | import evaluateList from './components/evaluateList'; 12 | 13 | import LoggedIn from './components/LoggedIn'; 14 | import LoggedOut from './components/LoggedOut'; 15 | import LoginButton from './components/LoginButton'; 16 | import LogoutButton from './components/LogoutButton'; 17 | import AuthButton from './components/AuthButton'; 18 | import Value from './components/Value'; 19 | import Image from './components/Image'; 20 | import Link from './components/Link'; 21 | import Label from './components/Label'; 22 | import Name from './components/Name'; 23 | import List from './components/List'; 24 | import LiveUpdate from './components/LiveUpdate'; 25 | import ActivityButton from './components/ActivityButton'; 26 | import LikeButton from './components/LikeButton'; 27 | import DislikeButton from './components/DislikeButton'; 28 | import FollowButton from './components/FollowButton'; 29 | 30 | import UpdateContext from './UpdateContext'; 31 | 32 | import './prop-types'; 33 | 34 | export { 35 | useWebId, 36 | useLoggedIn, 37 | useLoggedOut, 38 | useLDflex, 39 | useLDflexValue, 40 | useLDflexList, 41 | useLiveUpdate, 42 | 43 | withWebId, 44 | evaluateExpressions, 45 | evaluateList, 46 | 47 | LoggedIn, 48 | LoggedOut, 49 | LoginButton, 50 | LogoutButton, 51 | AuthButton, 52 | Value, 53 | Image, 54 | Link, 55 | Label, 56 | Name, 57 | List, 58 | LiveUpdate, 59 | ActivityButton, 60 | LikeButton, 61 | LikeButton as Like, 62 | DislikeButton, 63 | DislikeButton as Dislike, 64 | FollowButton, 65 | FollowButton as Follow, 66 | 67 | UpdateContext, 68 | }; 69 | -------------------------------------------------------------------------------- /src/prop-types.js: -------------------------------------------------------------------------------- 1 | import { 2 | array, element, func, number, object, string, 3 | oneOfType, 4 | } from 'prop-types'; 5 | 6 | const ldflexExpression = oneOfType([string, object]); 7 | const numberString = oneOfType([number, string]); 8 | 9 | const children = oneOfType([array, string, element]); 10 | const needsChildren = { children: children.isRequired }; 11 | const srcAndChildren = { src: ldflexExpression.isRequired, children }; 12 | 13 | function setPropTypes(Component, ...propTypes) { 14 | Component.propTypes = Object.assign({}, ...propTypes); 15 | } 16 | 17 | import LoggedIn from './components/LoggedIn'; 18 | setPropTypes(LoggedIn, needsChildren); 19 | 20 | import LoggedOut from './components/LoggedIn'; 21 | setPropTypes(LoggedOut, needsChildren); 22 | 23 | import LoginButton from './components/LoginButton'; 24 | setPropTypes(LoginButton, { popup: string }); 25 | 26 | import AuthButton from './components/AuthButton'; 27 | setPropTypes(AuthButton, LoginButton.propTypes); 28 | 29 | import Value from './components/Value'; 30 | setPropTypes(Value, srcAndChildren); 31 | 32 | import Image from './components/Image'; 33 | setPropTypes(Image, srcAndChildren, { defaultSrc: string }); 34 | 35 | import Link from './components/Link'; 36 | setPropTypes(Link, { href: ldflexExpression.isRequired, children }); 37 | 38 | import Label from './components/Label'; 39 | setPropTypes(Label, srcAndChildren); 40 | 41 | import Name from './components/Name'; 42 | setPropTypes(Name, srcAndChildren); 43 | 44 | import List from './components/List'; 45 | setPropTypes(List, { 46 | src: ldflexExpression.isRequired, 47 | container: func, 48 | children: func, 49 | limit: numberString, 50 | offset: numberString, 51 | filter: func, 52 | }); 53 | 54 | import LiveUpdate from './components/LiveUpdate'; 55 | setPropTypes(LiveUpdate, { subscribe: oneOfType([array, string]) }); 56 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A src property should be a valid LDflex expression, 3 | * but it can also be specified as a URL. 4 | * This function turns URLs into valid LDflex expressions, 5 | * which is useful if you want to manipulate expressions directly. 6 | */ 7 | export function srcToLDflex(src) { 8 | if (/^https?:[^"]+$/.test(src)) 9 | src = `["${src}"]`; 10 | return src; 11 | } 12 | 13 | /** 14 | * Returns an object with only the given keys from the source. 15 | */ 16 | export function pick(source, keys) { 17 | const destination = {}; 18 | for (const key of keys) 19 | destination[key] = source[key]; 20 | return destination; 21 | } 22 | 23 | /** 24 | * Determines the display name of a component 25 | * https://reactjs.org/docs/higher-order-components.html 26 | */ 27 | export function getDisplayName(Component) { 28 | return Component.displayName || Component.name || 'Component'; 29 | } 30 | 31 | /** 32 | * Creates a higher-order component with the given name. 33 | */ 34 | export function higherOrderComponent(name, createWrapper) { 35 | return Component => { 36 | const Wrapper = createWrapper(Component); 37 | Wrapper.displayName = `${name}(${getDisplayName(Component)})`; 38 | return Wrapper; 39 | }; 40 | } 41 | 42 | /** 43 | * Creates a task queue that enforces a minimum time between tasks. 44 | * Optionally, new tasks can cause any old ones to be dropped. 45 | */ 46 | export function createTaskQueue({ 47 | drop = false, 48 | timeBetween = 10, 49 | concurrent = drop ? 1 : 4, 50 | } = {}) { 51 | let queue = [], scheduler = 0; 52 | 53 | // Runs all queued tasks, with the required minimum time in between 54 | function runQueuedTasks() { 55 | scheduler = queue.length && setTimeout(runQueuedTasks, timeBetween); 56 | queue.splice(0, concurrent).forEach(async ({ run, resolve, reject }) => { 57 | try { 58 | resolve(await run()); 59 | } 60 | catch (error) { 61 | reject(error); 62 | } 63 | }); 64 | } 65 | 66 | return { 67 | /** Schedules the given task(s) */ 68 | schedule: function schedule(functions, group = null) { 69 | // Schedule a single task 70 | if (!Array.isArray(functions)) 71 | return schedule([functions])[0]; 72 | 73 | // Create the tasks and their result promises 74 | const tasks = []; 75 | const results = functions.map(run => 76 | new Promise((resolve, reject) => 77 | tasks.push({ run, resolve, reject, group }))); 78 | 79 | // Schedule the tasks 80 | if (drop) 81 | queue = tasks; 82 | else 83 | queue.push(...tasks); 84 | if (!scheduler) 85 | runQueuedTasks(); 86 | return results; 87 | }, 88 | 89 | /** Forgets pending tasks (optionally only those in a given group). 90 | Returns a boolean indicating whether there were any. */ 91 | clear: function (group) { 92 | const hadPendingTasks = queue.length > 0; 93 | queue = queue.filter(task => group !== undefined && task.group !== group); 94 | return hadPendingTasks; 95 | }, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | env: { 3 | node: true, 4 | }, 5 | extends: plugin:jest/recommended, 6 | rules: { 7 | callback-return: off, 8 | global-require: off, 9 | func-style: off, 10 | new-cap: off, 11 | no-console: [ error, { allow: [ mute, unmute ] } ], 12 | no-empty-function: off, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /test/ExpressionEvaluator-test.js: -------------------------------------------------------------------------------- 1 | import ExpressionEvaluator from '../src/ExpressionEvaluator'; 2 | import MockPromise from 'jest-mock-promise'; 3 | import data from '@solid/query-ldflex'; 4 | 5 | jest.useFakeTimers(); 6 | 7 | describe('An ExpressionEvaluator', () => { 8 | let result; 9 | beforeEach(() => { 10 | result = new MockPromise(); 11 | data.resolve.mockReturnValueOnce(result); 12 | }); 13 | 14 | it('evaluates a single value', async () => { 15 | const evaluator = new ExpressionEvaluator(); 16 | const callback = jest.fn(); 17 | 18 | // Start evaluating the expression 19 | const done = evaluator.evaluate({ foo: 'foo.expression' }, {}, callback); 20 | expect(callback).toHaveBeenCalledTimes(1); 21 | expect(callback).toHaveBeenLastCalledWith( 22 | { error: undefined, foo: undefined, pending: true }); 23 | expect(data.resolve).toHaveBeenCalledTimes(1); 24 | expect(data.resolve).toHaveBeenLastCalledWith('foo.expression'); 25 | jest.runAllTimers(); 26 | 27 | // Resolve the expression 28 | await result.resolve('foo-value'); 29 | await new Promise(resolve => resolve()); 30 | 31 | // Answer is served 32 | expect(callback).toHaveBeenCalledTimes(2); 33 | expect(callback).toHaveBeenLastCalledWith({ foo: 'foo-value' }); 34 | await done; 35 | 36 | // No results are pending 37 | expect(callback).toHaveBeenCalledTimes(3); 38 | expect(callback).toHaveBeenCalledWith({ pending: false }); 39 | }); 40 | 41 | it('stops the evaluation when destroyed', async () => { 42 | const evaluator = new ExpressionEvaluator(); 43 | const callback = jest.fn(); 44 | 45 | // Start evaluating the expression 46 | const done = evaluator.evaluate({ foo: 'foo.expression' }, {}, callback); 47 | expect(callback).toHaveBeenCalledTimes(1); 48 | expect(callback).toHaveBeenLastCalledWith({ pending: true }); 49 | jest.runAllTimers(); 50 | 51 | // Resolve the expression 52 | result.resolve('foo-value'); 53 | evaluator.destroy(); 54 | await new Promise(resolve => resolve()); 55 | 56 | // No answer is served 57 | expect(callback).toHaveBeenCalledTimes(1); 58 | expect(callback).toHaveBeenLastCalledWith({ pending: true }); 59 | await done; 60 | expect(callback).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | it('sets a value to undefined upon error', async () => { 64 | const evaluator = new ExpressionEvaluator(); 65 | const callback = jest.fn(); 66 | 67 | // Start evaluating the expression 68 | const done = evaluator.evaluate({ foo: 'foo.expression' }, {}, callback); 69 | expect(callback).toHaveBeenCalledTimes(1); 70 | expect(callback).toHaveBeenLastCalledWith({ pending: true }); 71 | expect(data.resolve).toHaveBeenLastCalledWith('foo.expression'); 72 | jest.runAllTimers(); 73 | 74 | // Resolve the expression 75 | const error = new Error('error message'); 76 | await result.reject(error); 77 | await new Promise(resolve => resolve()); 78 | 79 | // Answer is served 80 | expect(callback).toHaveBeenCalledTimes(2); 81 | expect(callback).toHaveBeenCalledWith({}); 82 | await done; 83 | 84 | // Error is passed, no results are pending 85 | expect(callback).toHaveBeenCalledTimes(4); 86 | expect(callback).toHaveBeenCalledWith({ error }); 87 | expect(callback).toHaveBeenCalledWith({ pending: false }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/UpdateTracker-test.js: -------------------------------------------------------------------------------- 1 | import UpdateTracker, { resetWebSockets } from '../src/UpdateTracker'; 2 | import auth from 'solid-auth-client'; 3 | import ldflex from '@solid/query-ldflex'; 4 | 5 | const WebSocket = global.WebSocket = jest.fn(() => ({ 6 | send: jest.fn(), 7 | close: jest.fn(), 8 | })); 9 | 10 | describe('An UpdateTracker', () => { 11 | const callback = jest.fn(); 12 | let updateTracker, webSockets; 13 | 14 | jest.useFakeTimers(); 15 | 16 | beforeAll(() => { 17 | updateTracker = new UpdateTracker(callback); 18 | }); 19 | 20 | function retrieveCreatedWebSockets() { 21 | webSockets = WebSocket.mock.results.map(s => s.value); 22 | return webSockets; 23 | } 24 | 25 | describe('subscribing to 3 resources', () => { 26 | const resources = [ 27 | 'http://a.com/docs/1', 28 | 'http://a.com/docs/2', 29 | 'http://b.com/docs/3', 30 | 'http://b.com/docs/3#thing', 31 | ]; 32 | beforeAll(async () => { 33 | WebSocket.mockClear(); 34 | await updateTracker.subscribe(...resources); 35 | retrieveCreatedWebSockets(); 36 | }); 37 | 38 | it('opens WebSockets to the servers of those resources', () => { 39 | expect(WebSocket).toHaveBeenCalledTimes(2); 40 | expect(WebSocket).toHaveBeenCalledWith('ws://a.com/'); 41 | expect(WebSocket).toHaveBeenCalledWith('ws://b.com/'); 42 | }); 43 | 44 | describe('after the WebSockets have opened', () => { 45 | beforeAll(() => { 46 | webSockets.forEach(s => s.onopen()); 47 | }); 48 | 49 | it('subscribes to the resources on the different sockets', () => { 50 | expect(webSockets[0].send).toHaveBeenCalledTimes(2); 51 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://a.com/docs/1'); 52 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://a.com/docs/2'); 53 | expect(webSockets[1].send).toHaveBeenCalledTimes(1); 54 | expect(webSockets[1].send).toHaveBeenCalledWith('sub http://b.com/docs/3'); 55 | }); 56 | }); 57 | 58 | describe('after subscribing to different resources', () => { 59 | const otherResources = [ 60 | 'http://b.com/docs/3', 61 | 'http://b.com/docs/4', 62 | 'http://c.com/docs/5', 63 | ]; 64 | beforeAll(async () => { 65 | WebSocket.mockClear(); 66 | webSockets.forEach(s => s.send.mockClear()); 67 | await updateTracker.subscribe(...otherResources); 68 | }); 69 | 70 | it('only opens WebSockets to new servers', () => { 71 | expect(WebSocket).toHaveBeenCalledTimes(1); 72 | expect(WebSocket).toHaveBeenCalledWith('ws://c.com/'); 73 | }); 74 | 75 | it('only subscribes to new resources', () => { 76 | expect(webSockets[0].send).toHaveBeenCalledTimes(0); 77 | expect(webSockets[1].send).toHaveBeenCalledTimes(1); 78 | expect(webSockets[1].send).toHaveBeenCalledWith('sub http://b.com/docs/4'); 79 | }); 80 | }); 81 | 82 | describe('after an untracked resource changes', () => { 83 | beforeAll(() => { 84 | callback.mockClear(); 85 | ldflex.clearCache.mockClear(); 86 | webSockets[0].onmessage({ data: 'pub http://a.com/other' }); 87 | }); 88 | 89 | it('does not call the subscriber', () => { 90 | expect(callback).toHaveBeenCalledTimes(0); 91 | }); 92 | 93 | it('clears the LDflex cache', () => { 94 | expect(ldflex.clearCache).toHaveBeenCalledTimes(1); 95 | expect(ldflex.clearCache).toHaveBeenCalledWith('http://a.com/other'); 96 | }); 97 | }); 98 | 99 | describe('after a tracked resource changes', () => { 100 | beforeAll(() => { 101 | callback.mockClear(); 102 | ldflex.clearCache.mockClear(); 103 | webSockets[0].onmessage({ data: 'pub http://a.com/docs/1' }); 104 | }); 105 | 106 | it('calls the subscriber with a timestamp and the URL of the resource', () => { 107 | expect(callback).toHaveBeenCalledTimes(1); 108 | const args = callback.mock.calls[0]; 109 | expect(args).toHaveLength(1); 110 | expect(args[0]).toHaveProperty('timestamp'); 111 | expect(args[0].timestamp).toBeInstanceOf(Date); 112 | expect(args[0]).toHaveProperty('url', 'http://a.com/docs/1'); 113 | }); 114 | 115 | it('clears the LDflex cache', () => { 116 | expect(ldflex.clearCache).toHaveBeenCalledTimes(1); 117 | expect(ldflex.clearCache).toHaveBeenCalledWith('http://a.com/docs/1'); 118 | }); 119 | }); 120 | 121 | describe('after an unknown message arrives for a tracked resource ', () => { 122 | beforeAll(() => { 123 | callback.mockClear(); 124 | ldflex.clearCache.mockClear(); 125 | webSockets[0].onmessage({ data: 'ack http://a.com/docs/1' }); 126 | }); 127 | 128 | it('does not call the subscriber', () => { 129 | expect(callback).toHaveBeenCalledTimes(0); 130 | }); 131 | 132 | it('does not clear the LDflex cache', () => { 133 | expect(ldflex.clearCache).toHaveBeenCalledTimes(0); 134 | }); 135 | }); 136 | 137 | describe('after unsubscribing from a resource', () => { 138 | beforeAll(async () => { 139 | callback.mockClear(); 140 | await updateTracker.unsubscribe( 141 | 'http://a.com/docs/1#235', 142 | 'http://a.com/other', 143 | ); 144 | }); 145 | 146 | it('does not call the callback when the resource changes', () => { 147 | webSockets[0].onmessage({ data: 'pub http://a.com/docs/1' }); 148 | expect(callback).toHaveBeenCalledTimes(0); 149 | }); 150 | }); 151 | }); 152 | 153 | describe('subscribing to *', () => { 154 | describe('before subscribing', () => { 155 | beforeAll(() => { 156 | WebSocket.mockClear(); 157 | auth.emit('request', 'http://x.com/1'); 158 | auth.emit('request', 'http://x.com/1'); 159 | auth.emit('request', 'http://x.com/2'); 160 | auth.emit('request', 'https://y.com/3'); 161 | }); 162 | 163 | it('does not subscribe to fetched resources', () => { 164 | expect(WebSocket).toHaveBeenCalledTimes(0); 165 | }); 166 | }); 167 | 168 | describe('after subscribing', () => { 169 | beforeAll(async () => { 170 | await updateTracker.subscribe('*'); 171 | retrieveCreatedWebSockets().forEach(s => s.onopen()); 172 | }); 173 | 174 | it('subscribes to all previously fetched resources', () => { 175 | expect(WebSocket).toHaveBeenCalledTimes(2); 176 | expect(WebSocket).toHaveBeenCalledWith('ws://x.com/'); 177 | expect(WebSocket).toHaveBeenCalledWith('wss://y.com/'); 178 | 179 | expect(webSockets[0].send).toHaveBeenCalledTimes(2); 180 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://x.com/1'); 181 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://x.com/2'); 182 | expect(webSockets[1].send).toHaveBeenCalledTimes(1); 183 | expect(webSockets[1].send).toHaveBeenCalledWith('sub https://y.com/3'); 184 | }); 185 | 186 | it('notifies the subscriber when any resource changes', () => { 187 | callback.mockClear(); 188 | webSockets[0].onmessage({ data: 'pub http://whatever.com/9' }); 189 | expect(callback).toHaveBeenCalledTimes(1); 190 | webSockets[1].onmessage({ data: 'pub https://other.com/3' }); 191 | expect(callback).toHaveBeenCalledTimes(2); 192 | }); 193 | }); 194 | 195 | describe('when new resources are fetched', () => { 196 | beforeAll(async () => { 197 | WebSocket.mockClear(); 198 | auth.emit('request', 'https://z.com/1'); 199 | auth.emit('request', 'https://z.com/2'); 200 | await waitForPromises(); 201 | retrieveCreatedWebSockets().forEach(s => s.onopen()); 202 | }); 203 | 204 | it('subscribes to the new resources', () => { 205 | expect(WebSocket).toHaveBeenCalledTimes(1); 206 | expect(WebSocket).toHaveBeenCalledWith('wss://z.com/'); 207 | 208 | expect(webSockets[0].send).toHaveBeenCalledTimes(2); 209 | expect(webSockets[0].send).toHaveBeenCalledWith('sub https://z.com/1'); 210 | expect(webSockets[0].send).toHaveBeenCalledWith('sub https://z.com/2'); 211 | }); 212 | }); 213 | }); 214 | 215 | describe('subscribing to a resource without a WebSocket', () => { 216 | let origFetch; 217 | beforeEach(() => { 218 | origFetch = auth.fetch; 219 | auth.fetch = async () => ({ headers: { get() {} } }); 220 | }); 221 | afterEach(() => { 222 | auth.fetch = origFetch; 223 | }); 224 | 225 | it('throws an error', async () => { 226 | await expect(updateTracker.subscribe('http://other.com/docs/1')) 227 | .rejects.toThrow(new Error('No WebSocket found for http://other.com/docs/1')); 228 | }); 229 | }); 230 | 231 | describe('when a socket is closed', () => { 232 | // Ensure clean slate between tests 233 | beforeEach(resetWebSockets); 234 | beforeEach(WebSocket.mockClear); 235 | 236 | // Subscribe to resources 237 | beforeEach(async () => { 238 | await updateTracker.subscribe('http://retry.com/docs/1', 'http://retry.com/docs/2'); 239 | }); 240 | 241 | // Simulate socket closure 242 | beforeEach(async () => { 243 | retrieveCreatedWebSockets()[0].onclose(); 244 | WebSocket.mockClear(); 245 | }); 246 | 247 | it('resubscribes after 1s backoff time', async () => { 248 | await waitSeconds(0.5); // backoff time not exceeded yet 249 | expect(WebSocket).toHaveBeenCalledTimes(0); 250 | 251 | await waitSeconds(0.5); // backoff time exceeded 252 | expect(WebSocket).toHaveBeenCalledTimes(1); 253 | expect(WebSocket).toHaveBeenCalledWith('ws://retry.com/'); 254 | 255 | retrieveCreatedWebSockets()[0].onopen(); 256 | await webSockets[0].ready; 257 | expect(webSockets[0].send).toHaveBeenCalledTimes(2); 258 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://retry.com/docs/1'); 259 | expect(webSockets[0].send).toHaveBeenCalledWith('sub http://retry.com/docs/2'); 260 | }); 261 | 262 | it('makes six attempts to resubscribe with doubling backoff times', async () => { 263 | await waitSeconds(1); 264 | expect(WebSocket).toHaveBeenCalledTimes(1); 265 | 266 | retrieveCreatedWebSockets()[0].onclose(); 267 | await waitSeconds(2); 268 | expect(WebSocket).toHaveBeenCalledTimes(2); 269 | 270 | retrieveCreatedWebSockets()[1].onclose(); 271 | await waitSeconds(4); 272 | expect(WebSocket).toHaveBeenCalledTimes(3); 273 | 274 | retrieveCreatedWebSockets()[2].onclose(); 275 | await waitSeconds(8); 276 | expect(WebSocket).toHaveBeenCalledTimes(4); 277 | 278 | retrieveCreatedWebSockets()[3].onclose(); 279 | await waitSeconds(16); 280 | expect(WebSocket).toHaveBeenCalledTimes(5); 281 | 282 | retrieveCreatedWebSockets()[4].onclose(); 283 | await waitSeconds(32); 284 | expect(WebSocket).toHaveBeenCalledTimes(6); 285 | 286 | retrieveCreatedWebSockets()[5].onopen(); 287 | await webSockets[5].ready; 288 | 289 | // First five attempts failed to connect so there ere no subscribe calls 290 | expect(webSockets[0].send).toHaveBeenCalledTimes(0); 291 | expect(webSockets[1].send).toHaveBeenCalledTimes(0); 292 | expect(webSockets[2].send).toHaveBeenCalledTimes(0); 293 | expect(webSockets[3].send).toHaveBeenCalledTimes(0); 294 | expect(webSockets[4].send).toHaveBeenCalledTimes(0); 295 | 296 | // The sixth attempts succeeded to connect so there was a subscribe call 297 | expect(webSockets[5].send).toHaveBeenCalledTimes(2); 298 | expect(webSockets[5].send).toHaveBeenCalledWith('sub http://retry.com/docs/1'); 299 | expect(webSockets[5].send).toHaveBeenCalledWith('sub http://retry.com/docs/2'); 300 | }); 301 | 302 | it('does not retry after the sixth attempt', async () => { 303 | await waitSeconds(1); 304 | expect(WebSocket).toHaveBeenCalledTimes(1); 305 | 306 | retrieveCreatedWebSockets()[0].onclose(); 307 | await waitSeconds(2); 308 | expect(WebSocket).toHaveBeenCalledTimes(2); 309 | 310 | retrieveCreatedWebSockets()[1].onclose(); 311 | await waitSeconds(4); 312 | expect(WebSocket).toHaveBeenCalledTimes(3); 313 | 314 | retrieveCreatedWebSockets()[2].onclose(); 315 | await waitSeconds(8); 316 | expect(WebSocket).toHaveBeenCalledTimes(4); 317 | 318 | retrieveCreatedWebSockets()[3].onclose(); 319 | await waitSeconds(16); 320 | expect(WebSocket).toHaveBeenCalledTimes(5); 321 | 322 | retrieveCreatedWebSockets()[4].onclose(); 323 | await waitSeconds(32); 324 | expect(WebSocket).toHaveBeenCalledTimes(6); 325 | 326 | retrieveCreatedWebSockets()[5].onclose(); 327 | await waitSeconds(64); 328 | expect(WebSocket).toHaveBeenCalledTimes(6); 329 | 330 | // All five attempts failed to connect so there was no subscribe calls 331 | expect(webSockets[0].send).toHaveBeenCalledTimes(0); 332 | expect(webSockets[1].send).toHaveBeenCalledTimes(0); 333 | expect(webSockets[2].send).toHaveBeenCalledTimes(0); 334 | expect(webSockets[3].send).toHaveBeenCalledTimes(0); 335 | expect(webSockets[4].send).toHaveBeenCalledTimes(0); 336 | expect(webSockets[5].send).toHaveBeenCalledTimes(0); 337 | }); 338 | 339 | it('resets backoff if the connection is dropping and coming back up', async () => { 340 | await waitSeconds(1); 341 | expect(WebSocket).toHaveBeenCalledTimes(1); 342 | 343 | retrieveCreatedWebSockets()[0].onclose(); 344 | await waitSeconds(2); 345 | expect(WebSocket).toHaveBeenCalledTimes(2); 346 | 347 | // Connection succeeded which should reset backoff times 348 | retrieveCreatedWebSockets()[1].onopen(); 349 | await webSockets[1].ready; 350 | expect(WebSocket).toHaveBeenCalledTimes(2); 351 | 352 | // Backoff timeouts have been reset back to original times 353 | retrieveCreatedWebSockets()[1].onclose(); 354 | await waitSeconds(1); 355 | expect(WebSocket).toHaveBeenCalledTimes(3); 356 | 357 | retrieveCreatedWebSockets()[2].onclose(); 358 | await waitSeconds(2); 359 | expect(WebSocket).toHaveBeenCalledTimes(4); 360 | 361 | retrieveCreatedWebSockets()[3].onclose(); 362 | await waitSeconds(4); 363 | expect(WebSocket).toHaveBeenCalledTimes(5); 364 | 365 | retrieveCreatedWebSockets()[4].onopen(); 366 | await webSockets[4].ready; 367 | expect(WebSocket).toHaveBeenCalledTimes(5); 368 | 369 | // First attempt failed to connect so there was no subscribe call 370 | expect(webSockets[0].send).toHaveBeenCalledTimes(0); 371 | 372 | // Second attempt succeed to connect so there were two subscribe calls 373 | expect(webSockets[1].send).toHaveBeenCalledTimes(2); 374 | expect(webSockets[1].send).toHaveBeenCalledWith('sub http://retry.com/docs/1'); 375 | expect(webSockets[1].send).toHaveBeenCalledWith('sub http://retry.com/docs/2'); 376 | 377 | // After a close event the third and forth attempts failed to connect 378 | expect(webSockets[2].send).toHaveBeenCalledTimes(0); 379 | expect(webSockets[3].send).toHaveBeenCalledTimes(0); 380 | 381 | // The Fifth attempt succeed to connect so there were two subscribe calls 382 | expect(webSockets[4].send).toHaveBeenCalledTimes(2); 383 | expect(webSockets[4].send).toHaveBeenCalledWith('sub http://retry.com/docs/1'); 384 | expect(webSockets[4].send).toHaveBeenCalledWith('sub http://retry.com/docs/2'); 385 | }); 386 | }); 387 | }); 388 | 389 | async function waitForPromises() { 390 | return new Promise(resolve => setImmediate(resolve)); 391 | } 392 | 393 | async function waitSeconds(seconds) { 394 | jest.advanceTimersByTime(seconds * 1000); 395 | return waitForPromises(); 396 | } 397 | -------------------------------------------------------------------------------- /test/__mocks__/@solid/query-ldflex.js: -------------------------------------------------------------------------------- 1 | const { context } = jest.requireActual('@solid/query-ldflex').default; 2 | 3 | export default { 4 | context, 5 | resolve: jest.fn(), 6 | clearCache: jest.fn(), 7 | }; 8 | -------------------------------------------------------------------------------- /test/__mocks__/solid-auth-client.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import { act } from 'react-testing-library'; 3 | 4 | class SolidAuthClient extends EventEmitter { 5 | constructor() { 6 | super(); 7 | this.session = undefined; 8 | } 9 | 10 | async fetch(url) { 11 | return { 12 | headers: { 13 | get(headerName) { 14 | if (headerName === 'Updates-Via') { 15 | const { protocol, host } = new URL(url); 16 | return `${protocol.replace('http', 'ws')}//${host}/`; 17 | } 18 | return null; 19 | }, 20 | }, 21 | }; 22 | } 23 | 24 | popupLogin() {} 25 | 26 | logout() {} 27 | 28 | trackSession(callback) { 29 | if (this.session !== undefined) 30 | callback(this.session); 31 | this.on('session', callback); 32 | } 33 | 34 | mockWebId(webId) { 35 | this.session = webId ? { webId } : null; 36 | act(() => { 37 | this.emit('session', this.session); 38 | }); 39 | } 40 | } 41 | 42 | const instance = new SolidAuthClient(); 43 | jest.spyOn(instance, 'fetch'); 44 | jest.spyOn(instance, 'popupLogin'); 45 | jest.spyOn(instance, 'logout'); 46 | jest.spyOn(instance, 'removeListener'); 47 | 48 | export default instance; 49 | -------------------------------------------------------------------------------- /test/__mocks__/useLDflex.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { act } from 'react-testing-library'; 3 | 4 | const pending = {}; 5 | 6 | const useLDflexMock = jest.fn((expression, listMode = false) => { 7 | const [result, setResult] = useState([listMode ? [] : undefined, true, null]); 8 | pending[expression] = setResult; 9 | return result; 10 | }); 11 | useLDflexMock.resolve = (expression, value) => act(() => 12 | pending[expression]([value]) 13 | ); 14 | useLDflexMock.reject = (expression, error) => act(() => 15 | pending[expression]([undefined, false, error]) 16 | ); 17 | 18 | export default useLDflexMock; 19 | -------------------------------------------------------------------------------- /test/__mocks__/useState.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { act } from 'react-testing-library'; 3 | 4 | let setState; 5 | 6 | const useStateMock = jest.fn(() => { 7 | const [value, setter] = useState({}); 8 | setState = setter; 9 | return value; 10 | }); 11 | useStateMock.set = value => act(() => 12 | setState(value) 13 | ); 14 | 15 | export default useStateMock; 16 | -------------------------------------------------------------------------------- /test/components/ActivityButton-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ActivityButton } from '../../src/'; 3 | import { render, fireEvent, cleanup } from 'react-testing-library'; 4 | import MockPromise from 'jest-mock-promise'; 5 | import data from '@solid/query-ldflex'; 6 | import useLDflex from '../../src/hooks/useLDflex'; 7 | 8 | jest.mock('../../src/hooks/useLDflex', () => require('../__mocks__/useLDflex')); 9 | 10 | const currentUrl = 'https://example.org/page/#fragment'; 11 | const like = 'https://www.w3.org/ns/activitystreams#Like'; 12 | 13 | describe('An ActivityButton', () => { 14 | let button; 15 | beforeAll(() => { 16 | window.location.href = currentUrl; 17 | }); 18 | afterEach(cleanup); 19 | 20 | describe('without attributes', () => { 21 | const findExpression = `[${currentUrl}].findActivity("${like}")`; 22 | const createExpression = `[${currentUrl}].createActivity("${like}")`; 23 | const deleteExpression = `[${currentUrl}].deleteActivity("${like}")`; 24 | 25 | beforeEach(() => { 26 | const { container } = render(); 27 | button = container.firstChild; 28 | }); 29 | 30 | it('has the "solid" class', () => { 31 | expect(button).toHaveClass('solid'); 32 | }); 33 | 34 | it('has the "activity" class', () => { 35 | expect(button).toHaveClass('solid'); 36 | }); 37 | 38 | it('does not have the "performed" class', () => { 39 | expect(button).not.toHaveClass('performed'); 40 | }); 41 | 42 | it('has "Like" as a label', () => { 43 | expect(button).toHaveProperty('innerHTML', 'Like'); 44 | }); 45 | 46 | describe('when no activity exists', () => { 47 | beforeEach(() => { 48 | useLDflex.resolve(findExpression, undefined); 49 | }); 50 | 51 | it('has "Like" as a label', () => { 52 | expect(button).toHaveProperty('innerHTML', 'Like'); 53 | }); 54 | 55 | it('does not have the "performed" class', () => { 56 | expect(button).not.toHaveClass('performed'); 57 | }); 58 | 59 | describe('when clicked', () => { 60 | let activity; 61 | beforeEach(() => { 62 | activity = new MockPromise(); 63 | data.resolve.mockReturnValue(activity); 64 | fireEvent.click(button); 65 | }); 66 | 67 | it('has "Like" as a label', () => { 68 | expect(button).toHaveProperty('innerHTML', 'Like'); 69 | }); 70 | 71 | it('has the "performed" class', () => { 72 | expect(button).toHaveClass('performed'); 73 | }); 74 | 75 | it('creates an activity', () => { 76 | expect(data.resolve).toHaveBeenCalledWith(createExpression); 77 | }); 78 | 79 | describe('when activity creation succeeds', () => { 80 | beforeEach(() => { 81 | // mute `act` warning caused by asynchronous `reject`, 82 | // since no workaround currently exists 83 | // https://github.com/facebook/jest/issues/7151 84 | console.mute(); 85 | activity.resolve({}); 86 | }); 87 | afterEach(() => { 88 | console.unmute(); 89 | }); 90 | 91 | it('has "Like" as a label', () => { 92 | expect(button).toHaveProperty('innerHTML', 'Like'); 93 | }); 94 | 95 | it('has the "performed" class', () => { 96 | expect(button).toHaveClass('performed'); 97 | }); 98 | }); 99 | 100 | describe('when activity creation fails', () => { 101 | beforeEach(() => { 102 | console.mute(); 103 | activity.reject(new Error()); 104 | }); 105 | afterEach(() => { 106 | console.unmute(); 107 | }); 108 | 109 | it('has "Like" as a label', () => { 110 | expect(button).toHaveProperty('innerHTML', 'Like'); 111 | }); 112 | 113 | it('does not have the "performed" class', () => { 114 | expect(button).not.toHaveClass('performed'); 115 | }); 116 | }); 117 | }); 118 | }); 119 | 120 | describe('when an activity exists', () => { 121 | beforeEach(() => { 122 | useLDflex.resolve(findExpression, {}); 123 | }); 124 | 125 | it('has "Like" as a label', () => { 126 | expect(button).toHaveProperty('innerHTML', 'Like'); 127 | }); 128 | 129 | it('has the "performed" class', () => { 130 | expect(button).toHaveClass('performed'); 131 | }); 132 | 133 | describe('when clicked', () => { 134 | let activity; 135 | beforeEach(() => { 136 | activity = new MockPromise(); 137 | data.resolve.mockReturnValue(activity); 138 | fireEvent.click(button); 139 | }); 140 | 141 | it('has "Like" as a label', () => { 142 | expect(button).toHaveProperty('innerHTML', 'Like'); 143 | }); 144 | 145 | it('does not have the "performed" class', () => { 146 | expect(button).not.toHaveClass('performed'); 147 | }); 148 | 149 | it('removes an activity', () => { 150 | expect(data.resolve).toHaveBeenCalledWith(deleteExpression); 151 | }); 152 | 153 | describe('when activity removal succeeds', () => { 154 | beforeEach(() => { 155 | console.mute(); 156 | activity.resolve({}); 157 | }); 158 | afterEach(() => { 159 | console.unmute(); 160 | }); 161 | 162 | it('has "Like" as a label', () => { 163 | expect(button).toHaveProperty('innerHTML', 'Like'); 164 | }); 165 | 166 | it('does not have the "performed" class', () => { 167 | expect(button).not.toHaveClass('performed'); 168 | }); 169 | }); 170 | 171 | describe('when activity removal fails', () => { 172 | beforeEach(() => { 173 | console.mute(); 174 | activity.reject(new Error()); 175 | }); 176 | afterEach(() => { 177 | console.unmute(); 178 | }); 179 | 180 | it('has "Like" as a label', () => { 181 | expect(button).toHaveProperty('innerHTML', 'Like'); 182 | }); 183 | 184 | it('has the "performed" class', () => { 185 | expect(button).toHaveClass('performed'); 186 | }); 187 | }); 188 | }); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /test/components/AuthButton-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AuthButton } from '../../src/'; 3 | import { render, cleanup } from 'react-testing-library'; 4 | import auth from 'solid-auth-client'; 5 | 6 | describe('An AuthButton', () => { 7 | let container; 8 | const button = () => container.firstChild; 9 | afterAll(cleanup); 10 | 11 | describe('without properties', () => { 12 | beforeAll(() => { 13 | ({ container } = render()); 14 | }); 15 | 16 | describe('when the user is not logged in', () => { 17 | beforeAll(() => auth.mockWebId(null)); 18 | 19 | it('renders the login button', () => { 20 | expect(button()).toHaveTextContent('Log in'); 21 | }); 22 | 23 | it('uses default class names', () => { 24 | expect(button()).toHaveClass('solid', 'auth', 'login'); 25 | expect(button()).not.toHaveClass('logout'); 26 | }); 27 | }); 28 | 29 | describe('when the user is logged in', () => { 30 | beforeAll(() => auth.mockWebId('https://example.org/#me')); 31 | 32 | it('renders the logout button', () => { 33 | expect(button()).toHaveTextContent('Log out'); 34 | }); 35 | 36 | it('uses default class names', () => { 37 | expect(button()).toHaveClass('solid', 'auth', 'logout'); 38 | expect(button()).not.toHaveClass('login'); 39 | }); 40 | }); 41 | }); 42 | 43 | describe('with a className property', () => { 44 | beforeAll(() => { 45 | ({ container } = render()); 46 | }); 47 | 48 | describe('when the user is not logged in', () => { 49 | beforeAll(() => auth.mockWebId(null)); 50 | 51 | it('does not use the built-in classes', () => { 52 | expect(button()).not.toHaveClass('solid', 'auth', 'logout'); 53 | }); 54 | 55 | it('uses the custom classes', () => { 56 | expect(button()).toHaveClass('custom', 'styling'); 57 | }); 58 | }); 59 | 60 | describe('when the user is logged in', () => { 61 | beforeAll(() => auth.mockWebId('https://example.org/#me')); 62 | 63 | it('does not use the built-in classes', () => { 64 | expect(button()).not.toHaveClass('solid', 'auth', 'logout'); 65 | }); 66 | 67 | it('uses the custom classes', () => { 68 | expect(button()).toHaveClass('custom', 'styling'); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('with custom labels', () => { 74 | beforeAll(() => { 75 | ({ container } = render()); 76 | }); 77 | 78 | describe('when the user is not logged in', () => { 79 | beforeAll(() => auth.mockWebId(null)); 80 | 81 | it('uses the custom login label', () => { 82 | expect(button()).toHaveTextContent('Hello'); 83 | }); 84 | }); 85 | 86 | describe('when the user is logged in', () => { 87 | beforeAll(() => auth.mockWebId('https://example.org/#me')); 88 | 89 | it('uses the custom logout label', () => { 90 | expect(button()).toHaveTextContent('Goodbye'); 91 | }); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/components/DislikeButton-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DislikeButton } from '../../src/'; 3 | import { render, fireEvent, cleanup } from 'react-testing-library'; 4 | import MockPromise from 'jest-mock-promise'; 5 | import data from '@solid/query-ldflex'; 6 | import useLDflex from '../../src/hooks/useLDflex'; 7 | 8 | jest.mock('../../src/hooks/useLDflex', () => require('../__mocks__/useLDflex')); 9 | 10 | const currentUrl = 'https://example.org/page/#fragment'; 11 | const dislike = 'https://www.w3.org/ns/activitystreams#Dislike'; 12 | 13 | describe('A DislikeButton', () => { 14 | let button; 15 | beforeAll(() => { 16 | window.location.href = currentUrl; 17 | }); 18 | afterEach(cleanup); 19 | 20 | describe('without attributes', () => { 21 | const findExpression = `[${currentUrl}].findActivity("${dislike}")`; 22 | const createExpression = `[${currentUrl}].createActivity("${dislike}")`; 23 | const deleteExpression = `[${currentUrl}].deleteActivity("${dislike}")`; 24 | 25 | beforeEach(() => { 26 | data.resolve.mockClear(); 27 | const { container } = render(); 28 | button = container.firstChild; 29 | }); 30 | 31 | it('has the "solid" class', () => { 32 | expect(button).toHaveClass('solid'); 33 | }); 34 | 35 | it('has the "activity" class', () => { 36 | expect(button).toHaveClass('solid'); 37 | }); 38 | 39 | it('has the "dislike" class', () => { 40 | expect(button).toHaveClass('dislike'); 41 | }); 42 | 43 | it('does not have the "performed" class', () => { 44 | expect(button).not.toHaveClass('performed'); 45 | }); 46 | 47 | it('has "Dislike this page" as a label', () => { 48 | expect(button).toHaveProperty('innerHTML', 'Dislike this page'); 49 | }); 50 | 51 | describe('when no activity exists', () => { 52 | beforeEach(() => { 53 | useLDflex.resolve(findExpression, undefined); 54 | }); 55 | 56 | it('has "Dislike this page" as a label', () => { 57 | expect(button).toHaveProperty('innerHTML', 'Dislike this page'); 58 | }); 59 | 60 | it('does not have the "performed" class', () => { 61 | expect(button).not.toHaveClass('performed'); 62 | }); 63 | 64 | describe('when clicked', () => { 65 | let activity; 66 | beforeEach(() => { 67 | activity = new MockPromise(); 68 | data.resolve.mockReturnValue(activity); 69 | fireEvent.click(button); 70 | }); 71 | 72 | it('has "You disliked this page" as a label', () => { 73 | expect(button).toHaveProperty('innerHTML', 'You disliked this page'); 74 | }); 75 | 76 | it('has the "performed" class', () => { 77 | expect(button).toHaveClass('performed'); 78 | }); 79 | 80 | it('creates an activity', () => { 81 | expect(data.resolve).toHaveBeenCalledWith(createExpression); 82 | }); 83 | 84 | describe('when activity creation succeeds', () => { 85 | beforeEach(() => { 86 | // mute `act` warning caused by asynchronous `reject`, 87 | // since no workaround currently exists 88 | // https://github.com/facebook/jest/issues/7151 89 | console.mute(); 90 | activity.resolve({}); 91 | }); 92 | afterEach(() => { 93 | console.unmute(); 94 | }); 95 | 96 | it('has "You disliked this page" as a label', () => { 97 | expect(button).toHaveProperty('innerHTML', 'You disliked this page'); 98 | }); 99 | 100 | it('has the "performed" class', () => { 101 | expect(button).toHaveClass('performed'); 102 | }); 103 | }); 104 | 105 | describe('when activity creation fails', () => { 106 | beforeEach(() => { 107 | console.mute(); 108 | activity.reject(new Error()); 109 | }); 110 | afterEach(() => { 111 | console.unmute(); 112 | }); 113 | 114 | it('has "Dislike this page" as a label', () => { 115 | expect(button).toHaveProperty('innerHTML', 'Dislike this page'); 116 | }); 117 | 118 | it('does not have the "performed" class', () => { 119 | expect(button).not.toHaveClass('performed'); 120 | }); 121 | }); 122 | }); 123 | }); 124 | 125 | describe('when an activity exists', () => { 126 | beforeEach(() => { 127 | useLDflex.resolve(findExpression, {}); 128 | }); 129 | 130 | it('has "You disliked this page" as a label', () => { 131 | expect(button).toHaveProperty('innerHTML', 'You disliked this page'); 132 | }); 133 | 134 | it('has the "performed" class', () => { 135 | expect(button).toHaveClass('performed'); 136 | }); 137 | 138 | describe('when clicked', () => { 139 | let activity; 140 | beforeEach(() => { 141 | activity = new MockPromise(); 142 | data.resolve.mockReturnValue(activity); 143 | fireEvent.click(button); 144 | }); 145 | 146 | it('has "Dislike this page" as a label', () => { 147 | expect(button).toHaveProperty('innerHTML', 'Dislike this page'); 148 | }); 149 | 150 | it('does not have the "performed" class', () => { 151 | expect(button).not.toHaveClass('performed'); 152 | }); 153 | 154 | it('creates an activity', () => { 155 | expect(data.resolve).toHaveBeenCalledWith(deleteExpression); 156 | }); 157 | 158 | describe('when activity creation succeeds', () => { 159 | beforeEach(() => { 160 | console.mute(); 161 | activity.resolve({}); 162 | }); 163 | afterEach(() => { 164 | console.unmute(); 165 | }); 166 | 167 | it('has "Dislike this page" as a label', () => { 168 | expect(button).toHaveProperty('innerHTML', 'Dislike this page'); 169 | }); 170 | 171 | it('does not have the "performed" class', () => { 172 | expect(button).not.toHaveClass('performed'); 173 | }); 174 | }); 175 | 176 | describe('when activity creation fails', () => { 177 | beforeEach(() => { 178 | console.mute(); 179 | activity.reject(new Error()); 180 | }); 181 | afterEach(() => { 182 | console.unmute(); 183 | }); 184 | 185 | it('has "You disliked this page" as a label', () => { 186 | expect(button).toHaveProperty('innerHTML', 'You disliked this page'); 187 | }); 188 | 189 | it('has the "performed" class', () => { 190 | expect(button).toHaveClass('performed'); 191 | }); 192 | }); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('with an object', () => { 198 | const object = 'https://example.org/#thing'; 199 | const findExpression = `["${object}"].findActivity("${dislike}")`; 200 | const createExpression = `["${object}"].createActivity("${dislike}")`; 201 | 202 | beforeEach(() => { 203 | data.resolve.mockClear(); 204 | const { container } = render(); 205 | button = container.firstChild; 206 | }); 207 | 208 | describe('when no activity exists', () => { 209 | beforeEach(() => { 210 | useLDflex.resolve(findExpression, undefined); 211 | }); 212 | 213 | it('has "Dislike" as a label', () => { 214 | expect(button).toHaveProperty('innerHTML', 'Dislike'); 215 | }); 216 | 217 | describe('when clicked', () => { 218 | let activity; 219 | beforeEach(() => { 220 | activity = new MockPromise(); 221 | data.resolve.mockReturnValue(activity); 222 | fireEvent.click(button); 223 | }); 224 | 225 | it('has "Disliked" as a label', () => { 226 | expect(button).toHaveProperty('innerHTML', 'Disliked'); 227 | }); 228 | 229 | it('creates an activity', () => { 230 | expect(data.resolve).toHaveBeenCalledWith(createExpression); 231 | }); 232 | }); 233 | }); 234 | }); 235 | 236 | describe('with children', () => { 237 | beforeEach(() => { 238 | data.resolve.mockClear(); 239 | const { container } = render(this thing); 240 | button = container.firstChild; 241 | }); 242 | 243 | it('has "Dislike this thing" as a label', () => { 244 | expect(button).toHaveProperty('innerHTML', 'Dislike this thing'); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /test/components/FollowButton-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FollowButton } from '../../src/'; 3 | import { render, fireEvent, cleanup } from 'react-testing-library'; 4 | import MockPromise from 'jest-mock-promise'; 5 | import data from '@solid/query-ldflex'; 6 | import useLDflex from '../../src/hooks/useLDflex'; 7 | 8 | jest.mock('../../src/hooks/useLDflex', () => require('../__mocks__/useLDflex')); 9 | 10 | const currentUrl = 'https://example.org/page/#fragment'; 11 | const follow = 'https://www.w3.org/ns/activitystreams#Follow'; 12 | 13 | describe('A FollowButton', () => { 14 | let button; 15 | beforeAll(() => { 16 | window.location.href = currentUrl; 17 | }); 18 | afterEach(cleanup); 19 | 20 | describe('without attributes', () => { 21 | const findExpression = `[${currentUrl}].findActivity("${follow}")`; 22 | const createExpression = `[${currentUrl}].createActivity("${follow}")`; 23 | const deleteExpression = `[${currentUrl}].deleteActivity("${follow}")`; 24 | 25 | beforeEach(() => { 26 | data.resolve.mockClear(); 27 | const { container } = render(); 28 | button = container.firstChild; 29 | }); 30 | 31 | it('has the "solid" class', () => { 32 | expect(button).toHaveClass('solid'); 33 | }); 34 | 35 | it('has the "activity" class', () => { 36 | expect(button).toHaveClass('solid'); 37 | }); 38 | 39 | it('has the "follow" class', () => { 40 | expect(button).toHaveClass('follow'); 41 | }); 42 | 43 | it('does not have the "performed" class', () => { 44 | expect(button).not.toHaveClass('performed'); 45 | }); 46 | 47 | it('has "Follow this page" as a label', () => { 48 | expect(button).toHaveProperty('innerHTML', 'Follow this page'); 49 | }); 50 | 51 | describe('when no activity exists', () => { 52 | beforeEach(() => { 53 | useLDflex.resolve(findExpression, undefined); 54 | }); 55 | 56 | it('has "Follow this page" as a label', () => { 57 | expect(button).toHaveProperty('innerHTML', 'Follow this page'); 58 | }); 59 | 60 | it('does not have the "performed" class', () => { 61 | expect(button).not.toHaveClass('performed'); 62 | }); 63 | 64 | describe('when clicked', () => { 65 | let activity; 66 | beforeEach(() => { 67 | activity = new MockPromise(); 68 | data.resolve.mockReturnValue(activity); 69 | fireEvent.click(button); 70 | }); 71 | 72 | it('has "You follow this page" as a label', () => { 73 | expect(button).toHaveProperty('innerHTML', 'You follow this page'); 74 | }); 75 | 76 | it('has the "performed" class', () => { 77 | expect(button).toHaveClass('performed'); 78 | }); 79 | 80 | it('creates an activity', () => { 81 | expect(data.resolve).toHaveBeenCalledWith(createExpression); 82 | }); 83 | 84 | describe('when activity creation succeeds', () => { 85 | beforeEach(() => { 86 | // mute `act` warning caused by asynchronous `reject`, 87 | // since no workaround currently exists 88 | // https://github.com/facebook/jest/issues/7151 89 | console.mute(); 90 | activity.resolve({}); 91 | }); 92 | afterEach(() => { 93 | console.unmute(); 94 | }); 95 | 96 | it('has "You follow this page" as a label', () => { 97 | expect(button).toHaveProperty('innerHTML', 'You follow this page'); 98 | }); 99 | 100 | it('has the "performed" class', () => { 101 | expect(button).toHaveClass('performed'); 102 | }); 103 | }); 104 | 105 | describe('when activity creation fails', () => { 106 | beforeEach(() => { 107 | console.mute(); 108 | activity.reject(new Error()); 109 | }); 110 | afterEach(() => { 111 | console.unmute(); 112 | }); 113 | 114 | it('has "Follow this page" as a label', () => { 115 | expect(button).toHaveProperty('innerHTML', 'Follow this page'); 116 | }); 117 | 118 | it('does not have the "performed" class', () => { 119 | expect(button).not.toHaveClass('performed'); 120 | }); 121 | }); 122 | }); 123 | }); 124 | 125 | describe('when an activity exists', () => { 126 | beforeEach(() => { 127 | useLDflex.resolve(findExpression, {}); 128 | }); 129 | 130 | it('has "You follow this page" as a label', () => { 131 | expect(button).toHaveProperty('innerHTML', 'You follow this page'); 132 | }); 133 | 134 | it('has the "performed" class', () => { 135 | expect(button).toHaveClass('performed'); 136 | }); 137 | 138 | describe('when clicked', () => { 139 | let activity; 140 | beforeEach(() => { 141 | activity = new MockPromise(); 142 | data.resolve.mockReturnValue(activity); 143 | fireEvent.click(button); 144 | }); 145 | 146 | it('has "Follow this page" as a label', () => { 147 | expect(button).toHaveProperty('innerHTML', 'Follow this page'); 148 | }); 149 | 150 | it('does not have the "performed" class', () => { 151 | expect(button).not.toHaveClass('performed'); 152 | }); 153 | 154 | it('creates an activity', () => { 155 | expect(data.resolve).toHaveBeenCalledWith(deleteExpression); 156 | }); 157 | 158 | describe('when activity creation succeeds', () => { 159 | beforeEach(() => { 160 | console.mute(); 161 | activity.resolve({}); 162 | }); 163 | afterEach(() => { 164 | console.unmute(); 165 | }); 166 | 167 | it('has "Follow this page" as a label', () => { 168 | expect(button).toHaveProperty('innerHTML', 'Follow this page'); 169 | }); 170 | 171 | it('does not have the "performed" class', () => { 172 | expect(button).not.toHaveClass('performed'); 173 | }); 174 | }); 175 | 176 | describe('when activity creation fails', () => { 177 | beforeEach(() => { 178 | console.mute(); 179 | activity.reject(new Error()); 180 | }); 181 | afterEach(() => { 182 | console.unmute(); 183 | }); 184 | 185 | it('has "You follow this page" as a label', () => { 186 | expect(button).toHaveProperty('innerHTML', 'You follow this page'); 187 | }); 188 | 189 | it('has the "performed" class', () => { 190 | expect(button).toHaveClass('performed'); 191 | }); 192 | }); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('with an object', () => { 198 | const object = 'https://example.org/#thing'; 199 | const findExpression = `["${object}"].findActivity("${follow}")`; 200 | const createExpression = `["${object}"].createActivity("${follow}")`; 201 | 202 | beforeEach(() => { 203 | data.resolve.mockClear(); 204 | const { container } = render(); 205 | button = container.firstChild; 206 | }); 207 | 208 | describe('when no activity exists', () => { 209 | beforeEach(() => { 210 | useLDflex.resolve(findExpression, undefined); 211 | }); 212 | 213 | it('has "Follow" as a label', () => { 214 | expect(button).toHaveProperty('innerHTML', 'Follow'); 215 | }); 216 | 217 | describe('when clicked', () => { 218 | let activity; 219 | beforeEach(() => { 220 | activity = new MockPromise(); 221 | data.resolve.mockReturnValue(activity); 222 | fireEvent.click(button); 223 | }); 224 | 225 | it('has "Following" as a label', () => { 226 | expect(button).toHaveProperty('innerHTML', 'Following'); 227 | }); 228 | 229 | it('creates an activity', () => { 230 | expect(data.resolve).toHaveBeenCalledWith(createExpression); 231 | }); 232 | }); 233 | }); 234 | }); 235 | 236 | describe('with children', () => { 237 | beforeEach(() => { 238 | data.resolve.mockClear(); 239 | const { container } = render(this thing); 240 | button = container.firstChild; 241 | }); 242 | 243 | it('has "Follow this thing" as a label', () => { 244 | expect(button).toHaveProperty('innerHTML', 'Follow this thing'); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /test/components/Image-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image } from '../../src/'; 3 | import { render, cleanup } from 'react-testing-library'; 4 | import useLDflex from '../../src/hooks/useLDflex'; 5 | 6 | jest.mock('../../src/hooks/useLDflex', () => require('../__mocks__/useLDflex')); 7 | 8 | describe('An Image', () => { 9 | afterEach(cleanup); 10 | 11 | describe('with a src property', () => { 12 | let container; 13 | const img = () => container.firstChild; 14 | beforeEach(() => { 15 | const image = ; 16 | ({ container } = render(image)); 17 | }); 18 | 19 | describe('before the expression resolves', () => { 20 | it('is empty', () => { 21 | expect(container.innerHTML).toBe(''); 22 | }); 23 | }); 24 | 25 | describe('after src resolves to a URL', () => { 26 | beforeEach(() => { 27 | useLDflex.resolve('user.image', 'https://example.com/image.jpg'); 28 | }); 29 | 30 | it('is an img', () => { 31 | expect(img().tagName).toMatch(/^img$/i); 32 | }); 33 | 34 | it('has the resolved src', () => { 35 | expect(img()).toHaveAttribute('src', 'https://example.com/image.jpg'); 36 | }); 37 | 38 | it('copies other properties', () => { 39 | expect(img()).toHaveAttribute('class', 'pic'); 40 | expect(img()).toHaveAttribute('width', '100'); 41 | }); 42 | }); 43 | 44 | describe('after src resolves to undefined', () => { 45 | beforeEach(() => { 46 | useLDflex.resolve('user.image', undefined); 47 | }); 48 | 49 | it('is empty', () => { 50 | expect(container.innerHTML).toBe(''); 51 | }); 52 | }); 53 | 54 | describe('after src errors', () => { 55 | beforeEach(() => { 56 | useLDflex.reject('user.image', new Error()); 57 | }); 58 | 59 | it('is empty', () => { 60 | expect(container.innerHTML).toBe(''); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('with src and defaultSrc properties', () => { 66 | let container; 67 | const img = () => container.firstChild; 68 | beforeEach(() => { 69 | const image = ; 70 | ({ container } = render(image)); 71 | }); 72 | 73 | describe('before the expression resolves', () => { 74 | it('is an img', () => { 75 | expect(img().tagName).toMatch(/^img$/i); 76 | }); 77 | 78 | it('has the defaultSrc', () => { 79 | expect(img()).toHaveAttribute('src', '/default.png'); 80 | }); 81 | }); 82 | 83 | describe('after src resolves to a URL', () => { 84 | beforeEach(() => { 85 | useLDflex.resolve('user.image', 'https://example.com/image.jpg'); 86 | }); 87 | 88 | it('is an img', () => { 89 | expect(img().tagName).toMatch(/^img$/i); 90 | }); 91 | 92 | it('has the resolved src', () => { 93 | expect(img()).toHaveAttribute('src', 'https://example.com/image.jpg'); 94 | }); 95 | }); 96 | 97 | describe('after src resolves to undefined', () => { 98 | beforeEach(() => { 99 | useLDflex.resolve('user.image', undefined); 100 | }); 101 | 102 | it('is an img', () => { 103 | expect(img().tagName).toMatch(/^img$/i); 104 | }); 105 | 106 | it('has the defaultSrc', () => { 107 | expect(img()).toHaveAttribute('src', '/default.png'); 108 | }); 109 | }); 110 | 111 | describe('after src errors', () => { 112 | beforeEach(() => { 113 | useLDflex.reject('user.image', new Error()); 114 | }); 115 | 116 | it('is an img', () => { 117 | expect(img().tagName).toMatch(/^img$/i); 118 | }); 119 | 120 | it('has the defaultSrc', () => { 121 | expect(img()).toHaveAttribute('src', '/default.png'); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('with src and children', () => { 127 | let container; 128 | const img = () => container.firstChild; 129 | beforeEach(() => { 130 | const image = children; 131 | ({ container } = render(image)); 132 | }); 133 | 134 | describe('before the expression resolves', () => { 135 | it('renders the children', () => { 136 | expect(container.innerHTML).toBe('children'); 137 | }); 138 | }); 139 | 140 | describe('after src resolves to a URL', () => { 141 | beforeEach(() => { 142 | useLDflex.resolve('user.image', 'https://example.com/image.jpg'); 143 | }); 144 | 145 | it('is an img', () => { 146 | expect(img().tagName).toMatch(/^img$/i); 147 | }); 148 | 149 | it('has the resolved src', () => { 150 | expect(img()).toHaveAttribute('src', 'https://example.com/image.jpg'); 151 | }); 152 | }); 153 | 154 | describe('after src resolves to undefined', () => { 155 | beforeEach(() => { 156 | useLDflex.resolve('user.image', undefined); 157 | }); 158 | 159 | it('renders the children', () => { 160 | expect(container.innerHTML).toBe('children'); 161 | }); 162 | }); 163 | 164 | describe('after src errors', () => { 165 | beforeEach(() => { 166 | useLDflex.reject('user.image', new Error()); 167 | }); 168 | 169 | it('renders the children', () => { 170 | expect(container.innerHTML).toBe('children'); 171 | }); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/components/Label-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Label } from '../../src/'; 3 | import { render, cleanup } from 'react-testing-library'; 4 | import useLDflex from '../../src/hooks/useLDflex'; 5 | 6 | jest.mock('../../src/hooks/useLDflex', () => require('../__mocks__/useLDflex')); 7 | 8 | describe('Label', () => { 9 | afterEach(cleanup); 10 | 11 | it('renders a label with src', async () => { 12 | const label =