├── .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 | [](https://www.npmjs.com/package/@solid/react)
6 | [](https://travis-ci.com/solid/react-components)
7 | [](https://coveralls.io/github/solid/react-components?branch=master)
8 | [](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 |
19 |
20 |
21 |
22 | Welcome back, .
23 | Friends
24 |
25 |
26 |
27 | You are logged out.
28 |
29 |
30 |
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 |
53 | { exists ? deactivateLabel : activateLabel }
54 |
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 || {`${href}`} ;
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 => ,
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 auth.popupLogin({ popupUri: popup })}>{children} ;
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 auth.logout()}>{children} ;
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 = ;
13 | const { container } = render(label);
14 |
15 | useLDflex.resolve('user.label', 'Example Label');
16 | expect(container).toHaveTextContent('Example Label');
17 | });
18 |
19 | it('renders children until src resolves', async () => {
20 | const label = default ;
21 | const { container } = render(label);
22 | expect(container).toHaveTextContent('default');
23 |
24 | useLDflex.resolve('user.label', 'Example Label');
25 | expect(container).toHaveTextContent('Example Label');
26 | });
27 |
28 | it('renders children if src does not resolve', async () => {
29 | const label = default ;
30 | const { container } = render(label);
31 | expect(container).toHaveTextContent('default');
32 |
33 | useLDflex.resolve('other.label', undefined);
34 | expect(container).toHaveTextContent('default');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/test/components/LikeButton-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LikeButton } 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('A LikeButton', () => {
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 | 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 "like" class', () => {
40 | expect(button).toHaveClass('like');
41 | });
42 |
43 | it('does not have the "performed" class', () => {
44 | expect(button).not.toHaveClass('performed');
45 | });
46 |
47 | it('has "Like this page" as a label', () => {
48 | expect(button).toHaveProperty('innerHTML', 'Like this page');
49 | });
50 |
51 | describe('when no activity exists', () => {
52 | beforeEach(() => {
53 | useLDflex.resolve(findExpression, undefined);
54 | });
55 |
56 | it('has "Like this page" as a label', () => {
57 | expect(button).toHaveProperty('innerHTML', 'Like 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 liked this page" as a label', () => {
73 | expect(button).toHaveProperty('innerHTML', 'You liked 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 liked this page" as a label', () => {
97 | expect(button).toHaveProperty('innerHTML', 'You liked 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 "Like this page" as a label', () => {
115 | expect(button).toHaveProperty('innerHTML', 'Like 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 liked this page" as a label', () => {
131 | expect(button).toHaveProperty('innerHTML', 'You liked 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 "Like this page" as a label', () => {
147 | expect(button).toHaveProperty('innerHTML', 'Like 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 "Like this page" as a label', () => {
168 | expect(button).toHaveProperty('innerHTML', 'Like 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 liked this page" as a label', () => {
186 | expect(button).toHaveProperty('innerHTML', 'You liked 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("${like}")`;
200 | const createExpression = `["${object}"].createActivity("${like}")`;
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 "Like" as a label', () => {
214 | expect(button).toHaveProperty('innerHTML', 'Like');
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 "Liked" as a label', () => {
226 | expect(button).toHaveProperty('innerHTML', 'Liked');
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 "Like this thing" as a label', () => {
244 | expect(button).toHaveProperty('innerHTML', 'Like this thing');
245 | });
246 | });
247 | });
248 |
--------------------------------------------------------------------------------
/test/components/Link-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } 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('Link', () => {
9 | afterEach(cleanup);
10 |
11 | it('renders a link with children', async () => {
12 | const link = Inbox;
13 | const { container } = render(link);
14 | expect(container).toHaveTextContent('Inbox');
15 | });
16 |
17 | it('renders a link with href', async () => {
18 | const link = ;
19 | const { container } = render(link);
20 |
21 | useLDflex.resolve('other.inbox', 'https://other.org/inbox/');
22 | expect(container.innerHTML).toBe(
23 | 'https://other.org/inbox/ ');
24 | });
25 |
26 | it('renders a link with href with an available label', async () => {
27 | const link = ;
28 | const { container } = render(link);
29 |
30 | useLDflex.resolve('user.inbox', 'https://user.me/inbox/');
31 | useLDflex.resolve('[https://user.me/inbox/].label', 'My Inbox');
32 | expect(container.innerHTML).toBe(
33 | 'My Inbox ');
34 | });
35 |
36 | it('renders a link with href and children', async () => {
37 | const link = Inbox;
38 | const { container } = render(link);
39 |
40 | useLDflex.resolve('user.inbox', 'https://user.me/inbox/');
41 | expect(container.innerHTML).toBe(
42 | 'Inbox ');
43 | });
44 |
45 | it('renders a link with href and children and other props', async () => {
46 | const link = Inbox;
47 | const { container } = render(link);
48 |
49 | useLDflex.resolve('user.inbox', 'https://user.me/inbox/');
50 | expect(container.innerHTML).toBe(
51 | 'Inbox ');
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/test/components/List-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { List } 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('A List', () => {
9 | let container;
10 | afterAll(cleanup);
11 |
12 | describe('for an expression resulting in a list of size 0', () => {
13 | beforeAll(() => {
14 | ({ container } = render(
));
15 | });
16 |
17 | describe('before resolving', () => {
18 | it('renders the empty list', () => {
19 | expect(container.innerHTML).toBe('');
20 | });
21 | });
22 |
23 | describe('after resolving', () => {
24 | beforeAll(() => {
25 | useLDflex.resolve('expr.items', []);
26 | });
27 |
28 | it('renders the empty list', () => {
29 | expect(container.innerHTML).toBe('');
30 | });
31 | });
32 | });
33 |
34 | describe('for an expression resulting in a list of size 3', () => {
35 | beforeAll(() => {
36 | ({ container } = render(
));
37 | });
38 |
39 | describe('before resolving', () => {
40 | it('renders the empty list', () => {
41 | expect(container.innerHTML).toBe('');
42 | });
43 | });
44 |
45 | describe('after resolving', () => {
46 | beforeAll(() => {
47 | useLDflex.resolve('expr.items', ['a', 'b', 'c']);
48 | });
49 |
50 | it('renders a element list of size 3', () => {
51 | expect(container.innerHTML).toBe(
52 | '');
53 | });
54 | });
55 | });
56 |
57 | describe('with children and a container', () => {
58 | beforeAll(() => {
59 | ({ container } = render(
60 | {children}
}>
61 | {(item, i) => {item} }
62 |
63 | ));
64 | useLDflex.resolve('expr.items', ['a', 'b', 'c']);
65 | });
66 |
67 | it('uses the custom children function', () => {
68 | expect(container.innerHTML).toBe(
69 | 'a b c
');
70 | });
71 | });
72 |
73 | describe('with children and an empty container', () => {
74 | beforeAll(() => {
75 | ({ container } = render(
76 |
77 | {(item, i) => {item} }
78 |
79 | ));
80 | useLDflex.resolve('expr.items', ['a', 'b', 'c']);
81 | });
82 |
83 | it('uses no container', () => {
84 | expect(container.innerHTML).toBe(
85 | 'a b c ');
86 | });
87 | });
88 |
89 | describe('with a limit and offset', () => {
90 | beforeAll(() => {
91 | ({ container } = render(
));
92 | useLDflex.resolve('expr.items', ['a', 'b', 'c', 'd', 'e', 'f', 'g']);
93 | });
94 |
95 | it('renders `limit` elements starting at `offset`', () => {
96 | expect(container.innerHTML).toBe(
97 | '');
98 | });
99 | });
100 |
101 | describe('with a filter', () => {
102 | beforeAll(() => {
103 | ({ container } = render( n % 2}/>));
104 | useLDflex.resolve('expr.items', [0, 1, 2, 3, 4, 5, 6]);
105 | });
106 |
107 | it('renders matching items', () => {
108 | expect(container.innerHTML).toBe(
109 | '');
110 | });
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/test/components/LiveUpdate-test.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { LiveUpdate } from '../../src/';
3 | import { UpdateContext } from '../../src/';
4 | import useLatestUpdate from '../../src/hooks/useLatestUpdate';
5 | import { render, cleanup } from 'react-testing-library';
6 |
7 | jest.mock('../../src/hooks/useLatestUpdate', () => require('../__mocks__/useState'));
8 |
9 | function ShowContext() {
10 | const context = useContext(UpdateContext);
11 | return JSON.stringify(context);
12 | }
13 |
14 | describe('a LiveUpdate component', () => {
15 | let container;
16 | afterAll(cleanup);
17 |
18 | describe('without children', () => {
19 | beforeAll(() => {
20 | ({ container } = render( ));
21 | });
22 |
23 | it('renders an empty string', () => {
24 | expect(container.innerHTML).toBe('');
25 | });
26 | });
27 |
28 | describe('without a subscribe attribute', () => {
29 | beforeAll(() => {
30 | useLatestUpdate.mockClear();
31 | ({ container } = render(
32 |
33 |
34 |
35 | ));
36 | });
37 |
38 | it('calls useLatestUpdate with the * parameter', () => {
39 | expect(useLatestUpdate).toHaveBeenCalledTimes(1);
40 | expect(useLatestUpdate).toHaveBeenCalledWith('*');
41 | });
42 |
43 | it('initially sets the UpdateContext value to the empty object', () => {
44 | expect(container.innerHTML).toBe('{}');
45 | });
46 |
47 | it('changes the UpdateContext value when an update arrives', () => {
48 | useLatestUpdate.set({ update: true });
49 | expect(container.innerHTML).toBe('{"update":true}');
50 | });
51 | });
52 |
53 | describe('with an array as subscribe attribute', () => {
54 | beforeAll(() => {
55 | useLatestUpdate.mockClear();
56 | ({ container } = render(
57 |
58 |
59 |
60 | ));
61 | });
62 |
63 | it('calls useLatestUpdate with the given resources', () => {
64 | expect(useLatestUpdate).toHaveBeenCalledTimes(1);
65 | expect(useLatestUpdate).toHaveBeenCalledWith('https://a.com/1', 'https://b.com/2');
66 | });
67 |
68 | it('initially sets the UpdateContext value to the empty object', () => {
69 | expect(container.innerHTML).toBe('{}');
70 | });
71 |
72 | it('changes the UpdateContext value when an update arrives', () => {
73 | useLatestUpdate.set({ update: true });
74 | expect(container.innerHTML).toBe('{"update":true}');
75 | });
76 | });
77 |
78 | describe('with an empty string as subscribe attribute', () => {
79 | beforeAll(() => {
80 | useLatestUpdate.mockClear();
81 | ({ container } = render(
82 |
83 |
84 |
85 | ));
86 | });
87 |
88 | it('calls useLatestUpdate without parameters', () => {
89 | expect(useLatestUpdate).toHaveBeenCalledTimes(1);
90 | expect(useLatestUpdate).toHaveBeenCalledWith();
91 | });
92 | });
93 |
94 | describe('with a non-empty string as subscribe attribute', () => {
95 | beforeAll(() => {
96 | useLatestUpdate.mockClear();
97 | ({ container } = render(
98 |
99 |
100 |
101 | ));
102 | });
103 |
104 | it('calls useLatestUpdate with the given resources', () => {
105 | expect(useLatestUpdate).toHaveBeenCalledTimes(1);
106 | expect(useLatestUpdate).toHaveBeenCalledWith('https://a.com/1', 'https://b.com/2');
107 | });
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/test/components/LoggedIn-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LoggedIn } from '../../src/';
3 | import { render, cleanup } from 'react-testing-library';
4 | import auth from 'solid-auth-client';
5 |
6 | describe('A LoggedIn pane', () => {
7 | let container;
8 | afterAll(cleanup);
9 |
10 | describe('with children', () => {
11 | beforeAll(() => {
12 | ({ container } = render(Logged in ));
13 | });
14 |
15 | describe('when the user is not logged in', () => {
16 | beforeAll(() => auth.mockWebId(null));
17 |
18 | it('is empty', () => {
19 | expect(container.innerHTML).toBe('');
20 | });
21 | });
22 |
23 | describe('when the user is logged in', () => {
24 | beforeAll(() => auth.mockWebId('https://example.org/#me'));
25 |
26 | it('renders the content', () => {
27 | expect(container.innerHTML).toBe('Logged in');
28 | });
29 | });
30 | });
31 |
32 | describe('without children', () => {
33 | beforeAll(() => {
34 | ({ container } = render( ));
35 | });
36 |
37 | describe('when the user is logged in', () => {
38 | beforeAll(() => auth.mockWebId('https://example.org/#me'));
39 |
40 | it('is empty', () => {
41 | expect(container.innerHTML).toBe('');
42 | });
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/test/components/LoggedOut-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LoggedOut } from '../../src/';
3 | import { render, cleanup } from 'react-testing-library';
4 | import auth from 'solid-auth-client';
5 |
6 | describe('A LoggedOut pane', () => {
7 | let container;
8 | afterAll(cleanup);
9 |
10 | describe('with children', () => {
11 | beforeAll(() => {
12 | ({ container } = render(Logged out ));
13 | });
14 |
15 | describe('when the user is not logged in', () => {
16 | beforeAll(() => auth.mockWebId(null));
17 |
18 | it('renders the content', () => {
19 | expect(container.innerHTML).toBe('Logged out');
20 | });
21 | });
22 |
23 | describe('when the user is logged in', () => {
24 | beforeAll(() => auth.mockWebId('https://example.org/#me'));
25 |
26 | it('is empty', () => {
27 | expect(container.innerHTML).toBe('');
28 | });
29 | });
30 | });
31 |
32 | describe('without children', () => {
33 | beforeAll(() => {
34 | ({ container } = render( ));
35 | });
36 |
37 | describe('when the user is not logged in', () => {
38 | beforeAll(() => auth.mockWebId(null));
39 |
40 | it('is empty', () => {
41 | expect(container.innerHTML).toBe('');
42 | });
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/test/components/LoginButton-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LoginButton } from '../../src/';
3 | import { render, fireEvent, cleanup } from 'react-testing-library';
4 | import auth from 'solid-auth-client';
5 |
6 | describe('A LoginButton', () => {
7 | let button;
8 | afterAll(cleanup);
9 |
10 | describe('with a popup attribute', () => {
11 | beforeAll(() => {
12 | const { container } = render( );
13 | button = container.firstChild;
14 | });
15 |
16 | it('has the solid class', () => {
17 | expect(button).toHaveClass('solid');
18 | });
19 |
20 | it('has the auth class', () => {
21 | expect(button).toHaveClass('auth');
22 | });
23 |
24 | it('has the login class', () => {
25 | expect(button).toHaveClass('login');
26 | });
27 |
28 | it('has "Log in" as label', () => {
29 | expect(button).toHaveTextContent('Log in');
30 | });
31 |
32 | it('logs the user in when clicked', () => {
33 | expect(auth.popupLogin).not.toHaveBeenCalled();
34 | fireEvent.click(button);
35 | expect(auth.popupLogin).toHaveBeenCalledTimes(1);
36 | expect(auth.popupLogin).toHaveBeenCalledWith({ popupUri: 'popup.html' });
37 | });
38 | });
39 |
40 | describe('with a string as child', () => {
41 | beforeAll(() => {
42 | const { container } = render(Hello );
43 | button = container.firstChild;
44 | });
45 |
46 | it('has the string as label', () => {
47 | expect(button).toHaveTextContent('Hello');
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/test/components/LogoutButton-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LogoutButton } from '../../src/';
3 | import { render, fireEvent, cleanup } from 'react-testing-library';
4 | import auth from 'solid-auth-client';
5 |
6 | describe('A LogoutButton', () => {
7 | let button;
8 | afterAll(cleanup);
9 |
10 | describe('without attributes', () => {
11 | beforeAll(() => {
12 | const { container } = render( );
13 | button = container.firstChild;
14 | });
15 |
16 | it('has the solid class', () => {
17 | expect(button).toHaveClass('solid');
18 | });
19 |
20 | it('has the auth class', () => {
21 | expect(button).toHaveClass('auth');
22 | });
23 |
24 | it('has the logout class', () => {
25 | expect(button).toHaveClass('logout');
26 | });
27 |
28 | it('has "Log out" as label', () => {
29 | expect(button).toHaveTextContent('Log out');
30 | });
31 |
32 | it('logs the user out when clicked', () => {
33 | expect(auth.logout).not.toHaveBeenCalled();
34 | fireEvent.click(button);
35 | expect(auth.logout).toHaveBeenCalledTimes(1);
36 | expect(auth.logout).toHaveBeenCalledWith();
37 | });
38 | });
39 |
40 | describe('with a string as child', () => {
41 | beforeAll(() => {
42 | const { container } = render(Goodbye );
43 | button = container.firstChild;
44 | });
45 |
46 | it('has the string as label', () => {
47 | expect(button).toHaveTextContent('Goodbye');
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/test/components/Name-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Name } 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('Name', () => {
9 | afterEach(cleanup);
10 |
11 | it('renders a name with src', async () => {
12 | const name = ;
13 | const { container } = render(name);
14 |
15 | useLDflex.resolve('user.name', 'Example Name');
16 | expect(container).toHaveTextContent('Example Name');
17 | });
18 |
19 | it('renders children until src resolves', async () => {
20 | const name = default ;
21 | const { container } = render(name);
22 | expect(container).toHaveTextContent('default');
23 |
24 | useLDflex.resolve('user.name', 'Example Name');
25 | expect(container).toHaveTextContent('Example Name');
26 | });
27 |
28 | it('renders children if src does not resolve', async () => {
29 | const name = default ;
30 | const { container } = render(name);
31 | expect(container).toHaveTextContent('default');
32 |
33 | useLDflex.resolve('other.name', undefined);
34 | expect(container).toHaveTextContent('default');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/test/components/Value-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Value } 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('A Value', () => {
9 | let container, rerender;
10 | const span = () => container.firstChild;
11 | afterEach(cleanup);
12 |
13 | describe('with a string expression', () => {
14 | beforeEach(() => {
15 | ({ container, rerender } = render( ));
16 | });
17 |
18 | describe('before the expression is evaluated', () => {
19 | it('is an empty span', () => {
20 | expect(span().tagName).toMatch(/^span$/i);
21 | expect(span()).toHaveTextContent('');
22 | });
23 |
24 | it('has the solid class', () => {
25 | expect(span()).toHaveClass('solid');
26 | });
27 |
28 | it('has the value class', () => {
29 | expect(span()).toHaveClass('value');
30 | });
31 |
32 | it('has the pending class', () => {
33 | expect(span()).toHaveClass('pending');
34 | });
35 | });
36 |
37 | describe('after the expression is evaluated', () => {
38 | beforeEach(() => {
39 | useLDflex.resolve('user.firstname', { toString: () => 'contents' });
40 | });
41 |
42 | it('contains the resolved contents', () => {
43 | expect(container.innerHTML).toBe('contents');
44 | });
45 | });
46 |
47 | describe('after the expression evaluates to undefined', () => {
48 | beforeEach(() => {
49 | useLDflex.resolve('user.firstname', undefined);
50 | });
51 |
52 | it('is an empty span', () => {
53 | expect(span().tagName).toMatch(/^span$/i);
54 | expect(span()).toHaveTextContent('');
55 | });
56 |
57 | it('has the solid class', () => {
58 | expect(span()).toHaveClass('solid');
59 | });
60 |
61 | it('has the value class', () => {
62 | expect(span()).toHaveClass('value');
63 | });
64 |
65 | it('has the empty class', () => {
66 | expect(span()).toHaveClass('empty');
67 | });
68 | });
69 |
70 | describe('after the expression errors', () => {
71 | beforeEach(() => {
72 | useLDflex.reject('user.firstname', new Error('the error message'));
73 | });
74 |
75 | it('is an empty span', () => {
76 | expect(span().tagName).toMatch(/^span$/i);
77 | expect(span()).toHaveTextContent('');
78 | });
79 |
80 | it('has the error message in the data-error attribute', () => {
81 | expect(span()).toHaveAttribute('data-error', 'the error message');
82 | });
83 |
84 | it('has the solid class', () => {
85 | expect(span()).toHaveClass('solid');
86 | });
87 |
88 | it('has the value class', () => {
89 | expect(span()).toHaveClass('value');
90 | });
91 |
92 | it('has the error class', () => {
93 | expect(span()).toHaveClass('error');
94 | });
95 | });
96 |
97 | describe('after src changes', () => {
98 | beforeEach(() => {
99 | rerender( );
100 | useLDflex.resolve('user.other', 'new contents');
101 | });
102 |
103 | it('contains the resolved contents', () => {
104 | expect(container).toHaveTextContent('new contents');
105 | });
106 | });
107 | });
108 |
109 | describe('with a string expression and children', () => {
110 | beforeEach(() => {
111 | ({ container, rerender } = render(children ));
112 | });
113 |
114 | describe('before the expression is evaluated', () => {
115 | it('renders the children', () => {
116 | expect(container).toHaveTextContent('children');
117 | });
118 | });
119 |
120 | describe('after the expression is evaluated', () => {
121 | beforeEach(() => {
122 | useLDflex.resolve('user.firstname', { toString: () => 'contents' });
123 | });
124 |
125 | it('contains the resolved contents', () => {
126 | expect(container.innerHTML).toBe('contents');
127 | });
128 | });
129 |
130 | describe('after the expression evaluates to undefined', () => {
131 | beforeEach(() => {
132 | useLDflex.resolve('user.firstname', undefined);
133 | });
134 |
135 | it('renders the children', () => {
136 | expect(container).toHaveTextContent('children');
137 | });
138 | });
139 |
140 | describe('after the expression errors', () => {
141 | beforeEach(() => {
142 | useLDflex.reject('user.firstname', new Error('the error message'));
143 | });
144 |
145 | it('renders the children', () => {
146 | expect(container).toHaveTextContent('children');
147 | });
148 | });
149 | });
150 | });
151 |
--------------------------------------------------------------------------------
/test/components/evaluateExpressions-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { evaluateExpressions } from '../../src/';
3 | import { render, cleanup, wait, waitForDomChange } from 'react-testing-library';
4 | import MockPromise from 'jest-mock-promise';
5 | import data from '@solid/query-ldflex';
6 | import auth from 'solid-auth-client';
7 |
8 | describe('An evaluateExpressions wrapper', () => {
9 | const Wrapper = evaluateExpressions(['foo', 'bar'], ({ foo, bar, pending, other, error }) =>
10 | contents
16 | );
17 | let container, foo, bar, rerender;
18 | const wrapped = () => container.firstChild;
19 |
20 | beforeEach(() => {
21 | foo = new MockPromise();
22 | bar = new MockPromise();
23 | jest.spyOn(foo, 'then');
24 | jest.spyOn(bar, 'then');
25 | auth.mockWebId(null);
26 | data.resolve.mockReturnValue(bar);
27 | const wrapper = ;
28 | ({ container, rerender } = render(wrapper));
29 | });
30 | afterEach(cleanup);
31 |
32 | it('renders the wrapped component', () => {
33 | expect(container).toHaveTextContent('contents');
34 | });
35 |
36 | it('accepts null as valueProps and listProps', () => {
37 | const Component = evaluateExpressions(null, null, () => null);
38 | render( );
39 | });
40 |
41 | it('accepts objects as valueProps and listProps', async () => {
42 | const Component = evaluateExpressions(['a'], ['b'], ({ a, b }) =>
43 | );
44 |
45 | const rendered = render( );
46 | await waitForDomChange();
47 | expect(rendered.container.firstChild).toHaveAttribute('data-a', '1234');
48 | expect(rendered.container.firstChild).toHaveAttribute('data-b', '4');
49 | });
50 |
51 | it('accepts an LDFlex expression resulting in a regular object as valueProps and listProps', async () => {
52 | data.resolve.mockReturnValue(1234);
53 | const Component = evaluateExpressions(['a'], ['b'], ({ a, b }) =>
54 | );
55 |
56 | const rendered = render( );
57 | await waitForDomChange();
58 | expect(rendered.container.firstChild).toHaveAttribute('data-a', '1234');
59 | expect(rendered.container.firstChild).toHaveAttribute('data-b', '0');
60 | });
61 |
62 | it('accepts empty properties', async () => {
63 | const Component = evaluateExpressions(['a'], ['b'],
64 | ({ error }) => );
65 | ({ container } = render( ));
66 | await expect(waitForDomChange({ timeout: 50 })).rejects.toThrow();
67 |
68 | expect(wrapped()).toHaveAttribute('data-error', 'undefined');
69 | });
70 |
71 | describe('before properties are resolved', () => {
72 | beforeEach(async () => {
73 | await wait(() => {
74 | expect(data.resolve).toHaveBeenCalled();
75 | });
76 | });
77 |
78 | it('passes the first property as undefined', () => {
79 | expect(wrapped()).toHaveAttribute('data-foo', 'undefined');
80 | });
81 |
82 | it('passes the second property as undefined', () => {
83 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
84 | });
85 |
86 | it('sets pending to true', () => {
87 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
88 | });
89 |
90 | it('sets error to undefined', () => {
91 | expect(wrapped()).toHaveAttribute('data-error', 'undefined');
92 | });
93 |
94 | it('passes other properties to the wrapped component', () => {
95 | expect(wrapped()).toHaveAttribute('data-other', 'value');
96 | });
97 |
98 | it('resolves the string expression', () => {
99 | expect(data.resolve).toHaveBeenLastCalledWith('user.bar');
100 | });
101 |
102 | describe('after the second property changes', () => {
103 | let newBar;
104 | beforeEach(async () => {
105 | newBar = new MockPromise();
106 | data.resolve.mockReturnValue(newBar);
107 | const wrapper = ;
108 | rerender(wrapper);
109 | });
110 |
111 | it('passes the second property as undefined', () => {
112 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
113 | });
114 |
115 | describe('after the original second property resolves', () => {
116 | beforeEach(async () => {
117 | await bar.resolve('second');
118 | await expect(waitForDomChange({ timeout: 50 })).rejects.toThrow();
119 | });
120 |
121 | it('sets pending to true', () => {
122 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
123 | });
124 | });
125 |
126 | describe('after the new second property resolves', () => {
127 | beforeEach(async () => {
128 | newBar.resolve('new second');
129 | await waitForDomChange();
130 | });
131 |
132 | it('has the new second property value', () => {
133 | expect(wrapped()).toHaveAttribute('data-bar', 'new second');
134 | });
135 |
136 | it('sets pending to true', () => {
137 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
138 | });
139 | });
140 | });
141 | });
142 |
143 | describe('after the first property resolves', () => {
144 | beforeEach(async () => {
145 | foo.resolve('first');
146 | await waitForDomChange();
147 | });
148 |
149 | it('passes the first property value', () => {
150 | expect(wrapped()).toHaveAttribute('data-foo', 'first');
151 | });
152 |
153 | it('passes the second property as undefined', () => {
154 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
155 | });
156 |
157 | it('sets pending to true', () => {
158 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
159 | });
160 |
161 | describe('after the second property changes', () => {
162 | let newBar;
163 | beforeEach(async () => {
164 | newBar = new MockPromise();
165 | data.resolve.mockReturnValue(newBar);
166 | const wrapper = ;
167 | rerender(wrapper);
168 | });
169 |
170 | it('passes the second property as undefined', () => {
171 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
172 | });
173 |
174 | describe('after the original second property resolves', () => {
175 | beforeEach(async () => {
176 | await bar.resolve('second');
177 | });
178 |
179 | it('passes the second property as undefined', () => {
180 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
181 | });
182 |
183 | it('sets pending to true', () => {
184 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
185 | });
186 | });
187 |
188 | describe('after the new second property resolves', () => {
189 | beforeEach(async () => {
190 | await newBar.resolve('new second');
191 | await waitForDomChange();
192 | });
193 |
194 | it('still has the new second property value', () => {
195 | expect(wrapped()).toHaveAttribute('data-bar', 'new second');
196 | });
197 |
198 | it('sets pending to false', () => {
199 | expect(wrapped()).toHaveAttribute('data-pending', 'false');
200 | });
201 | });
202 | });
203 | });
204 |
205 | describe('after the first property errors', () => {
206 | beforeEach(async () => {
207 | foo.reject(new Error('error'));
208 | await waitForDomChange();
209 | });
210 |
211 | it('passes the first property as undefined', () => {
212 | expect(wrapped()).toHaveAttribute('data-foo', 'undefined');
213 | });
214 |
215 | it('passes the second property as undefined', () => {
216 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
217 | });
218 |
219 | it('sets pending to true', () => {
220 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
221 | });
222 |
223 | it('sets the error', () => {
224 | expect(wrapped()).toHaveAttribute('data-error', 'Error: error');
225 | });
226 |
227 | describe('after the first property changes', () => {
228 | let newFoo;
229 | beforeEach(async () => {
230 | newFoo = new MockPromise();
231 | data.resolve.mockReturnValue(newFoo);
232 | const wrapper = ;
233 | rerender(wrapper);
234 | });
235 |
236 | it('sets error to undefined', () => {
237 | expect(wrapped()).toHaveAttribute('data-error', 'undefined');
238 | });
239 |
240 | it('passes the first property as undefined', () => {
241 | expect(wrapped()).toHaveAttribute('data-foo', 'undefined');
242 | });
243 |
244 | it('resolves the string expression', () => {
245 | expect(data.resolve).toHaveBeenLastCalledWith('user.newFoo');
246 | });
247 |
248 | describe('after the new first property resolves without error', () => {
249 | beforeEach(async () => {
250 | newFoo.resolve('new first');
251 | await waitForDomChange();
252 | });
253 |
254 | it('has the new first property value', () => {
255 | expect(wrapped()).toHaveAttribute('data-foo', 'new first');
256 | });
257 |
258 | it('sets error to undefined', () => {
259 | expect(wrapped()).toHaveAttribute('data-error', 'undefined');
260 | });
261 | });
262 | });
263 | });
264 |
265 | describe('after the second property resolves', () => {
266 | beforeEach(async () => {
267 | bar.resolve('second');
268 | await waitForDomChange();
269 | });
270 |
271 | it('passes the first property as undefined', () => {
272 | expect(wrapped()).toHaveAttribute('data-foo', 'undefined');
273 | });
274 |
275 | it('passes the second property value', () => {
276 | expect(wrapped()).toHaveAttribute('data-bar', 'second');
277 | });
278 |
279 | it('sets pending to true', () => {
280 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
281 | });
282 | });
283 |
284 | describe('after the second property errors', () => {
285 | beforeEach(async () => {
286 | bar.reject(new Error('error'));
287 | await waitForDomChange();
288 | });
289 |
290 | it('passes the first property as undefined', () => {
291 | expect(wrapped()).toHaveAttribute('data-foo', 'undefined');
292 | });
293 |
294 | it('passes the second property as undefined', () => {
295 | expect(wrapped()).toHaveAttribute('data-bar', 'undefined');
296 | });
297 |
298 | it('sets pending to true', () => {
299 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
300 | });
301 |
302 | it('sets the error', () => {
303 | expect(wrapped()).toHaveAttribute('data-error', 'Error: error');
304 | });
305 | });
306 |
307 | describe('after both properties resolve', () => {
308 | beforeEach(async () => {
309 | foo.resolve('first');
310 | bar.resolve('second');
311 | await waitForDomChange();
312 | });
313 |
314 | it('passes the first property value', () => {
315 | expect(wrapped()).toHaveAttribute('data-foo', 'first');
316 | });
317 |
318 | it('passes the second property value', () => {
319 | expect(wrapped()).toHaveAttribute('data-bar', 'second');
320 | });
321 |
322 | it('sets pending to false', () => {
323 | expect(wrapped()).toHaveAttribute('data-pending', 'false');
324 | });
325 |
326 | describe('after the user changes', () => {
327 | beforeEach(async () => {
328 | bar = new MockPromise();
329 | data.resolve.mockReturnValue(bar);
330 | auth.mockWebId('https://example.org/#me');
331 | });
332 |
333 | it('passes the first property value', () => {
334 | expect(wrapped()).toHaveAttribute('data-foo', 'first');
335 | });
336 |
337 | it('passes the old second property value', () => {
338 | expect(wrapped()).toHaveAttribute('data-bar', 'second');
339 | });
340 |
341 | it('sets pending to true', () => {
342 | expect(wrapped()).toHaveAttribute('data-pending', 'true');
343 | });
344 |
345 | describe('after both properties resolve', () => {
346 | beforeEach(async () => {
347 | bar.resolve('second change');
348 | await waitForDomChange();
349 | });
350 |
351 | it('passes the same first property value', () => {
352 | expect(wrapped()).toHaveAttribute('data-foo', 'first');
353 | });
354 |
355 | it('passes the changed second property value', () => {
356 | expect(wrapped()).toHaveAttribute('data-bar', 'second change');
357 | });
358 |
359 | it('sets pending to false', () => {
360 | expect(wrapped()).toHaveAttribute('data-pending', 'false');
361 | });
362 | });
363 | });
364 | });
365 | });
366 |
--------------------------------------------------------------------------------
/test/components/evaluateList-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { evaluateList } from '../../src/';
3 | import { render, cleanup, waitForDomChange } from 'react-testing-library';
4 | import { asyncIterable } from '../util';
5 |
6 | describe('An evaluateList wrapper', () => {
7 | const Wrapper = evaluateList('items', ({ items, foo, pending, error }) =>
8 | {JSON.stringify(items)}
12 | );
13 | let container, iterable;
14 | const span = () => container.firstChild;
15 | afterAll(cleanup);
16 |
17 | describe('for an empty iterable', () => {
18 | beforeAll(() => {
19 | iterable = asyncIterable(undefined);
20 | const wrapper = ;
21 | ({ container } = render(wrapper));
22 | });
23 |
24 | it('renders the wrapped component', () => {
25 | expect(span().tagName).toMatch(/^span$/i);
26 | });
27 |
28 | it('passes other properties to the wrapped component', () => {
29 | expect(span()).toHaveAttribute('data-foo', 'bar');
30 | });
31 |
32 | describe('before resolving', () => {
33 | beforeAll(async () => {
34 | await iterable;
35 | });
36 |
37 | it('passes the empty list', () => {
38 | expect(span()).toHaveTextContent('[]');
39 | });
40 |
41 | it('sets pending to true', () => {
42 | expect(span()).toHaveAttribute('data-pending', 'true');
43 | });
44 |
45 | it('sets error to undefined', () => {
46 | expect(span()).toHaveAttribute('data-error', 'undefined');
47 | });
48 | });
49 |
50 | describe('after resolving', () => {
51 | beforeAll(async () => {
52 | iterable.resume();
53 | await waitForDomChange();
54 | });
55 |
56 | it('passes the empty list', () => {
57 | expect(span()).toHaveTextContent('[]');
58 | });
59 |
60 | it('sets pending to false', () => {
61 | expect(span()).toHaveAttribute('data-pending', 'false');
62 | });
63 |
64 | it('sets error to undefined', () => {
65 | expect(span()).toHaveAttribute('data-error', 'undefined');
66 | });
67 | });
68 | });
69 |
70 | describe('for an iterable of length 1', () => {
71 | beforeAll(() => {
72 | iterable = asyncIterable(undefined, 'a');
73 | const wrapper = ;
74 | ({ container } = render(wrapper));
75 | });
76 |
77 | describe('before resolving', () => {
78 | beforeAll(async () => {
79 | await iterable;
80 | });
81 |
82 | it('passes the empty list', () => {
83 | expect(span()).toHaveTextContent('[]');
84 | });
85 |
86 | it('sets pending to true', () => {
87 | expect(span()).toHaveAttribute('data-pending', 'true');
88 | });
89 |
90 | it('sets error to undefined', () => {
91 | expect(span()).toHaveAttribute('data-error', 'undefined');
92 | });
93 | });
94 |
95 | describe('after resolving', () => {
96 | beforeAll(async () => {
97 | iterable.resume();
98 | await waitForDomChange();
99 | });
100 |
101 | it('passes the list', () => {
102 | expect(span()).toHaveTextContent('["a"]');
103 | });
104 |
105 | it('sets pending to false', () => {
106 | expect(span()).toHaveAttribute('data-pending', 'false');
107 | });
108 |
109 | it('sets error to undefined', () => {
110 | expect(span()).toHaveAttribute('data-error', 'undefined');
111 | });
112 | });
113 | });
114 |
115 | describe('for an iterable of length 3', () => {
116 | beforeAll(() => {
117 | iterable = asyncIterable(undefined, 'a', 'b', 'c');
118 | const wrapper = ;
119 | ({ container } = render(wrapper));
120 | });
121 |
122 | describe('before resolving', () => {
123 | beforeAll(async () => {
124 | await iterable;
125 | });
126 |
127 | it('passes the empty list', () => {
128 | expect(span()).toHaveTextContent('[]');
129 | });
130 |
131 | it('sets pending to true', () => {
132 | expect(span()).toHaveAttribute('data-pending', 'true');
133 | });
134 |
135 | it('sets error to undefined', () => {
136 | expect(span()).toHaveAttribute('data-error', 'undefined');
137 | });
138 | });
139 |
140 | describe('after resolving', () => {
141 | beforeAll(async () => {
142 | iterable.resume();
143 | await waitForDomChange();
144 | });
145 |
146 | it('passes the list', () => {
147 | expect(span()).toHaveTextContent('["a","b","c"]');
148 | });
149 |
150 | it('sets pending to false', () => {
151 | expect(span()).toHaveAttribute('data-pending', 'false');
152 | });
153 |
154 | it('sets error to undefined', () => {
155 | expect(span()).toHaveAttribute('data-error', 'undefined');
156 | });
157 | });
158 | });
159 |
160 | describe('for an iterable that errors', () => {
161 | beforeAll(() => {
162 | iterable = asyncIterable(undefined, 'a', 'b', new Error('c'), 'd');
163 | const wrapper = ;
164 | ({ container } = render(wrapper));
165 | });
166 |
167 | describe('before resolving', () => {
168 | beforeAll(async () => {
169 | await iterable;
170 | });
171 |
172 | it('passes the list', () => {
173 | expect(span()).toHaveTextContent('[]');
174 | });
175 |
176 | it('sets pending to true', () => {
177 | expect(span()).toHaveAttribute('data-pending', 'true');
178 | });
179 |
180 | it('sets error to undefined', () => {
181 | expect(span()).toHaveAttribute('data-error', 'undefined');
182 | });
183 | });
184 |
185 | describe('after resolving', () => {
186 | beforeAll(async () => {
187 | iterable.resume();
188 | await waitForDomChange();
189 | });
190 |
191 | it('passes the items up to the error', () => {
192 | expect(span()).toHaveTextContent('["a","b"]');
193 | });
194 |
195 | it('sets pending to false', () => {
196 | expect(span()).toHaveAttribute('data-pending', 'false');
197 | });
198 |
199 | it('sets the error', () => {
200 | expect(span()).toHaveAttribute('data-error', 'Error: c');
201 | });
202 | });
203 | });
204 |
205 | describe('for an iterable that is replaced during iteration', () => {
206 | let rerender;
207 | beforeAll(() => {
208 | iterable = asyncIterable('a', 'b', undefined, 'c', 'd');
209 | const wrapper = ;
210 | ({ container, rerender } = render(wrapper));
211 | });
212 |
213 | describe('before replacing', () => {
214 | beforeAll(async () => {
215 | await waitForDomChange();
216 | });
217 |
218 | it('sets the property to the items so far', () => {
219 | expect(span()).toHaveTextContent('["a","b"]');
220 | });
221 |
222 | it('sets pending to true', () => {
223 | expect(span()).toHaveAttribute('data-pending', 'true');
224 | });
225 |
226 | it('sets error to undefined', () => {
227 | expect(span()).toHaveAttribute('data-error', 'undefined');
228 | });
229 | });
230 |
231 | describe('after replacing', () => {
232 | let newIterable;
233 | beforeAll(async () => {
234 | newIterable = asyncIterable('x', 'y', undefined, 'z');
235 | const wrapper = ;
236 | rerender(wrapper);
237 | await iterable.resume();
238 | });
239 |
240 | describe('while the replacement is iterating', () => {
241 | beforeAll(async () => {
242 | await waitForDomChange();
243 | });
244 |
245 | it('sets the property to the items so far', () => {
246 | expect(span()).toHaveTextContent('["x","y"]');
247 | });
248 |
249 | it('sets pending to true', () => {
250 | expect(span()).toHaveAttribute('data-pending', 'true');
251 | });
252 |
253 | it('sets error to undefined', () => {
254 | expect(span()).toHaveAttribute('data-error', 'undefined');
255 | });
256 | });
257 |
258 | describe('after the replacement is done iterating', () => {
259 | beforeAll(async () => {
260 | newIterable.resume();
261 | await waitForDomChange();
262 | });
263 |
264 | it('sets the property to all items', () => {
265 | expect(span()).toHaveTextContent('["x","y","z"]');
266 | });
267 |
268 | it('sets pending to false', () => {
269 | expect(span()).toHaveAttribute('data-pending', 'false');
270 | });
271 |
272 | it('sets error to undefined', () => {
273 | expect(span()).toHaveAttribute('data-error', 'undefined');
274 | });
275 | });
276 | });
277 | });
278 | });
279 |
--------------------------------------------------------------------------------
/test/components/withWebId-test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withWebId } from '../../src/';
3 | import { render, cleanup } from 'react-testing-library';
4 | import auth from 'solid-auth-client';
5 |
6 | describe('A withWebId wrapper', () => {
7 | let container;
8 |
9 | beforeAll(() => {
10 | const Wrapper = withWebId(({ foo, webId }) =>
11 | contents );
12 | ({ container } = render( ));
13 | });
14 | afterAll(cleanup);
15 |
16 | describe('before a session is received', () => {
17 | it('renders the wrapped component', () => {
18 | expect(container).toHaveTextContent('contents');
19 | });
20 |
21 | it('passes a webID of undefined to the wrapped component', () => {
22 | expect(container.firstChild).toHaveAttribute('data-webId', 'undefined');
23 | });
24 |
25 | it('passes properties to the wrapped component', () => {
26 | expect(container.firstChild).toHaveAttribute('data-foo', 'bar');
27 | });
28 | });
29 |
30 | describe('when the user is not logged in', () => {
31 | beforeAll(() => auth.mockWebId(null));
32 |
33 | it('renders the wrapped component', () => {
34 | expect(container).toHaveTextContent('contents');
35 | });
36 |
37 | it('passes a webID of null to the wrapped component', () => {
38 | expect(container.firstChild).toHaveAttribute('data-webId', 'null');
39 | });
40 |
41 | it('passes properties to the wrapped component', () => {
42 | expect(container.firstChild).toHaveAttribute('data-foo', 'bar');
43 | });
44 | });
45 |
46 | describe('when the user is logged in', () => {
47 | const webId = 'https://example.org/#me';
48 | beforeAll(() => auth.mockWebId(webId));
49 |
50 | it('renders the wrapped component', () => {
51 | expect(container).toHaveTextContent('contents');
52 | });
53 |
54 | it("passes the user's webID to the wrapped component", () => {
55 | expect(container.firstChild).toHaveAttribute('data-webId', webId);
56 | });
57 |
58 | it('passes properties to the wrapped component', () => {
59 | expect(container.firstChild).toHaveAttribute('data-foo', 'bar');
60 | });
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/test/hooks/useLDflex-test.js:
--------------------------------------------------------------------------------
1 | import { useLDflex } from '../../src/';
2 | import { useLiveUpdate } from '../../src/';
3 | import { toString } from '../../src/hooks/useLDflex';
4 | import { act, renderHook, cleanup } from 'react-hooks-testing-library';
5 | import ExpressionEvaluator from '../../src/ExpressionEvaluator';
6 | import auth from 'solid-auth-client';
7 |
8 | const evaluator = ExpressionEvaluator.prototype;
9 | evaluator.evaluate = jest.fn();
10 | jest.spyOn(evaluator, 'destroy');
11 |
12 | jest.mock('../../src/hooks/useLiveUpdate', () => require('../__mocks__/useState'));
13 |
14 | describe('useLDflex', () => {
15 | beforeEach(jest.clearAllMocks);
16 | afterEach(cleanup);
17 |
18 | it('destroys the evaluator after unmounting', () => {
19 | const { unmount } = renderHook(() => useLDflex());
20 | unmount();
21 | expect(evaluator.destroy).toHaveBeenCalledTimes(1);
22 | });
23 |
24 | it('resolves a value expression', () => {
25 | const { result } = renderHook(() => useLDflex('foo'));
26 | expect(result.current).toEqual([undefined, true, undefined]);
27 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
28 | expect(evaluator.evaluate.mock.calls[0][0]).toEqual({ result: 'foo' });
29 | expect(evaluator.evaluate.mock.calls[0][1]).toEqual({});
30 |
31 | const callback = evaluator.evaluate.mock.calls[0][2];
32 | act(() => callback({ result: 'bar', pending: false, error: undefined }));
33 | expect(result.current).toEqual(['bar', false, undefined]);
34 | });
35 |
36 | it('resolves a list expression', () => {
37 | const { result } = renderHook(() => useLDflex('foo', true));
38 | expect(result.current).toEqual([[], true, undefined]);
39 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
40 | expect(evaluator.evaluate.mock.calls[0][0]).toEqual({});
41 | expect(evaluator.evaluate.mock.calls[0][1]).toEqual({ result: 'foo' });
42 |
43 | const callback = evaluator.evaluate.mock.calls[0][2];
44 | act(() => callback({ result: [1, 2, 3], pending: false, error: undefined }));
45 | expect(result.current).toEqual([[1, 2, 3], false, undefined]);
46 | });
47 |
48 | it('re-evaluates a string expression when a user logs in', () => {
49 | renderHook(() => useLDflex('foo', true));
50 | evaluator.evaluate.mockClear();
51 | auth.mockWebId('https://example.org/profile#me');
52 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
53 | });
54 |
55 | it('does not re-evaluate a Promise when a user logs in', () => {
56 | renderHook(() => useLDflex(Promise.resolve(), true));
57 | evaluator.evaluate.mockClear();
58 | auth.mockWebId('https://example.org/profile#me');
59 | expect(evaluator.evaluate).toHaveBeenCalledTimes(0);
60 | });
61 |
62 | it('re-evaluates when the UpdateContext changes', () => {
63 | renderHook(() => useLDflex('foo', true));
64 | evaluator.evaluate.mockClear();
65 | useLiveUpdate.set({ other: true });
66 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
67 | });
68 | });
69 |
70 | describe('toString', () => {
71 | it('converts objects to strings', () => {
72 | expect(toString({ toString: () => 'foo' })).toBe('foo');
73 | });
74 |
75 | it('converts arrays to an array of strings', () => {
76 | expect(toString([1, 2])).toEqual(['1', '2']);
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/test/hooks/useLDflexList-test.js:
--------------------------------------------------------------------------------
1 | import { useLDflexList } from '../../src/';
2 | import { act, renderHook, cleanup } from 'react-hooks-testing-library';
3 | import ExpressionEvaluator from '../../src/ExpressionEvaluator';
4 |
5 | const evaluator = ExpressionEvaluator.prototype;
6 | evaluator.evaluate = jest.fn();
7 | jest.spyOn(evaluator, 'destroy');
8 |
9 | describe('useLDflexList', () => {
10 | beforeEach(jest.clearAllMocks);
11 | afterEach(cleanup);
12 |
13 | it('destroys the evaluator after unmounting', () => {
14 | const { unmount } = renderHook(() => useLDflexList());
15 | unmount();
16 | expect(evaluator.destroy).toHaveBeenCalledTimes(1);
17 | });
18 |
19 | it('resolves a list expression', () => {
20 | const { result } = renderHook(() => useLDflexList('foo'));
21 | expect(result.current).toEqual([]);
22 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
23 | expect(evaluator.evaluate.mock.calls[0][0]).toEqual({});
24 | expect(evaluator.evaluate.mock.calls[0][1]).toEqual({ result: 'foo' });
25 |
26 | const callback = evaluator.evaluate.mock.calls[0][2];
27 | act(() => callback({ result: [1, 2, 3], pending: false, error: undefined }));
28 | expect(result.current).toEqual([1, 2, 3]);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/test/hooks/useLDflexValue-test.js:
--------------------------------------------------------------------------------
1 | import { useLDflexValue } from '../../src/';
2 | import { act, renderHook, cleanup } from 'react-hooks-testing-library';
3 | import ExpressionEvaluator from '../../src/ExpressionEvaluator';
4 |
5 | const evaluator = ExpressionEvaluator.prototype;
6 | evaluator.evaluate = jest.fn();
7 | jest.spyOn(evaluator, 'destroy');
8 |
9 | describe('useLDflexValue', () => {
10 | beforeEach(jest.clearAllMocks);
11 | afterEach(cleanup);
12 |
13 | it('destroys the evaluator after unmounting', () => {
14 | const { unmount } = renderHook(() => useLDflexValue());
15 | unmount();
16 | expect(evaluator.destroy).toHaveBeenCalledTimes(1);
17 | });
18 |
19 | it('resolves a value expression', () => {
20 | const { result } = renderHook(() => useLDflexValue('foo'));
21 | expect(result.current).toEqual(undefined);
22 | expect(evaluator.evaluate).toHaveBeenCalledTimes(1);
23 | expect(evaluator.evaluate.mock.calls[0][0]).toEqual({ result: 'foo' });
24 | expect(evaluator.evaluate.mock.calls[0][1]).toEqual({});
25 |
26 | const callback = evaluator.evaluate.mock.calls[0][2];
27 | act(() => callback({ result: 'bar', pending: false, error: undefined }));
28 | expect(result.current).toEqual('bar');
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/test/hooks/useLatestUpdate-test.js:
--------------------------------------------------------------------------------
1 | import useLatestUpdate from '../../src/hooks/useLatestUpdate';
2 | import UpdateTracker from '../../src/UpdateTracker';
3 | import { renderHook, act, cleanup } from 'react-hooks-testing-library';
4 |
5 | jest.mock('../../src/UpdateTracker', () =>
6 | jest.fn(() => ({
7 | subscribe: jest.fn(),
8 | unsubscribe: jest.fn(),
9 | }))
10 | );
11 |
12 | describe('useLatestUpdate', () => {
13 | const resources = [
14 | 'http://a.com/docs/1',
15 | 'http://b.com/docs/2',
16 | ];
17 | let result, unmount, rerender;
18 | beforeAll(() => {
19 | ({ result, unmount, rerender } = renderHook(
20 | ({ args }) => useLatestUpdate(...args),
21 | { initialProps: { args: resources } }));
22 | });
23 | afterAll(cleanup);
24 |
25 | it('initially returns the empty object', () => {
26 | expect(result.current).toEqual({});
27 | });
28 |
29 | it('creates an UpdateTracker with a callback', () => {
30 | expect(UpdateTracker).toHaveBeenCalledTimes(1);
31 | const callback = UpdateTracker.mock.calls[0][0];
32 | expect(callback).toBeInstanceOf(Function);
33 | });
34 |
35 | it('subscribes to the resources', () => {
36 | const updateTracker = UpdateTracker.mock.results[0].value;
37 | expect(updateTracker.subscribe).toHaveBeenCalledTimes(1);
38 | expect(updateTracker.subscribe).toHaveBeenCalledWith(...resources);
39 | });
40 |
41 | describe('when an update arrives', () => {
42 | const update = { update: true };
43 | beforeAll(() => {
44 | const callback = UpdateTracker.mock.calls[0][0];
45 | act(() => callback(update));
46 | });
47 |
48 | it('returns the updated value', () => {
49 | expect(result.current).toBe(update);
50 | });
51 | });
52 |
53 | describe('when unmounted', () => {
54 | beforeAll(() => {
55 | unmount();
56 | });
57 |
58 | it('unsubscribes from the resources', () => {
59 | const updateTracker = UpdateTracker.mock.results[0].value;
60 | expect(updateTracker.unsubscribe).toHaveBeenCalledTimes(1);
61 | expect(updateTracker.unsubscribe).toHaveBeenCalledWith(...resources);
62 | });
63 | });
64 |
65 | describe('when called with different arguments', () => {
66 | const others = [
67 | 'http://a.com/docs/1',
68 | 'http://b.com/docs/3',
69 | ];
70 | beforeAll(() => {
71 | rerender({ args: others });
72 | });
73 |
74 | it('unsubscribes from the old resources', () => {
75 | const updateTracker = UpdateTracker.mock.results[0].value;
76 | expect(updateTracker.unsubscribe).toHaveBeenCalledTimes(1);
77 | expect(updateTracker.unsubscribe).toHaveBeenCalledWith(...resources);
78 | });
79 |
80 | it('subscribes to the new resources', () => {
81 | expect(UpdateTracker).toHaveBeenCalledTimes(2);
82 | const updateTracker = UpdateTracker.mock.results[1].value;
83 | expect(updateTracker.subscribe).toHaveBeenCalledTimes(1);
84 | expect(updateTracker.subscribe).toHaveBeenCalledWith(...others);
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/test/hooks/useLiveUpdate-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { UpdateContext, useLiveUpdate } from '../../src/';
3 | import { render, cleanup } from 'react-testing-library';
4 |
5 | describe('useLiveUpdate', () => {
6 | afterAll(cleanup);
7 |
8 | it('uses an UpdateContext', () => {
9 | function ShowUpdateContext() {
10 | return useLiveUpdate();
11 | }
12 |
13 | const { container } = render(
14 |
15 |
16 |
17 | );
18 | expect(container.innerHTML).toBe('live-update-value');
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/test/hooks/useLoggedIn-test.js:
--------------------------------------------------------------------------------
1 | import { useLoggedIn } from '../../src/';
2 | import { renderHook, cleanup } from 'react-hooks-testing-library';
3 | import auth from 'solid-auth-client';
4 |
5 | describe('useLoggedIn', () => {
6 | let result, unmount;
7 | beforeAll(() => {
8 | ({ result, unmount } = renderHook(() => useLoggedIn()));
9 | });
10 | afterAll(() => unmount());
11 | afterAll(cleanup);
12 |
13 | it('returns undefined when the login status is unknown', () => {
14 | expect(result.current).toBeUndefined();
15 | });
16 |
17 | it('returns false when the user is logged out', () => {
18 | auth.mockWebId(null);
19 | expect(result.current).toBe(false);
20 | });
21 |
22 | it('returns true the user is logged in', () => {
23 | auth.mockWebId('https://example.org/#me');
24 | expect(result.current).toBe(true);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/test/hooks/useLoggedOut-test.js:
--------------------------------------------------------------------------------
1 | import { useLoggedOut } from '../../src/';
2 | import { renderHook, cleanup } from 'react-hooks-testing-library';
3 | import auth from 'solid-auth-client';
4 |
5 | describe('useLoggedOut', () => {
6 | let result, unmount;
7 | beforeAll(() => {
8 | ({ result, unmount } = renderHook(() => useLoggedOut()));
9 | });
10 | afterAll(() => unmount());
11 | afterAll(cleanup);
12 |
13 | it('returns undefined when the login status is unknown', () => {
14 | expect(result.current).toBeUndefined();
15 | });
16 |
17 | it('returns true when the user is logged out', () => {
18 | auth.mockWebId(null);
19 | expect(result.current).toBe(true);
20 | });
21 |
22 | it('returns false the user is logged in', () => {
23 | auth.mockWebId('https://example.org/#me');
24 | expect(result.current).toBe(false);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/test/hooks/useWebId-test.js:
--------------------------------------------------------------------------------
1 | import { useWebId } from '../../src/';
2 | import { renderHook, cleanup } from 'react-hooks-testing-library';
3 | import auth from 'solid-auth-client';
4 |
5 | describe('useWebId', () => {
6 | let result;
7 | beforeAll(() => {
8 | ({ result } = renderHook(() => useWebId()));
9 | });
10 | afterAll(cleanup);
11 |
12 | it('returns undefined when the login status is unknown', () => {
13 | expect(result.current).toBeUndefined();
14 | });
15 |
16 | it('returns null when the user is logged out', () => {
17 | auth.mockWebId(null);
18 | expect(result.current).toBeNull();
19 | });
20 |
21 | it('returns the WebID when the user is logged in', () => {
22 | auth.mockWebId('https://example.org/#me');
23 | expect(result.current).toBe('https://example.org/#me');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/index-test.js:
--------------------------------------------------------------------------------
1 | import * as SolidReactComponents from '../src';
2 |
3 | describe('The SolidReactComponents module', () => {
4 | const exports = [
5 | 'useWebId',
6 | 'useLoggedIn',
7 | 'useLoggedOut',
8 | 'useLDflex',
9 | 'useLDflexValue',
10 | 'useLDflexList',
11 | 'useLiveUpdate',
12 |
13 | 'withWebId',
14 | 'evaluateExpressions',
15 |
16 | 'LoggedIn',
17 | 'LoggedOut',
18 | 'LoginButton',
19 | 'LogoutButton',
20 | 'AuthButton',
21 | 'Value',
22 | 'Image',
23 | 'Link',
24 | 'Label',
25 | 'Name',
26 | 'List',
27 | 'LiveUpdate',
28 | 'ActivityButton',
29 | 'LikeButton',
30 | 'Like',
31 | 'DislikeButton',
32 | 'Dislike',
33 | 'FollowButton',
34 | 'Follow',
35 |
36 | 'UpdateContext',
37 | ];
38 |
39 | exports.forEach(name => {
40 | it(`exports ${name}`, () => {
41 | expect(SolidReactComponents[name]).toBeInstanceOf(Object);
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: off */
2 | import 'jest-dom/extend-expect';
3 |
4 | // Hide warnings and errors we trigger on purpose
5 | const { warn, error } = console;
6 | let muted = false;
7 | Object.assign(console, {
8 | warn(...args) {
9 | // Ignore warnings we generate ourselves
10 | if (muted || args[0] === '@solid/react-components')
11 | return;
12 | warn(...args);
13 | },
14 |
15 | error(...args) {
16 | // Ignore invalid prop-types that we test on purpose
17 | if (muted || /Failed prop type/.test(args[0]))
18 | return;
19 | error(...args);
20 | },
21 |
22 | mute() {
23 | muted = true;
24 | },
25 |
26 | unmute() {
27 | muted = false;
28 | },
29 | });
30 |
31 | // Mock the window.location property
32 | Object.defineProperty(window, 'location', {
33 | value: {
34 | href: 'http://localhost/',
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/test/util.js:
--------------------------------------------------------------------------------
1 | import MockPromise from 'jest-mock-promise';
2 |
3 | export function asyncIterable(...items) {
4 | let halt = new MockPromise();
5 | const iterable = {
6 | then: (...args) => halt.then(...args),
7 | [Symbol.asyncIterator]: () => ({ next }),
8 | };
9 | async function next() {
10 | if (!items.length) {
11 | halt.resolve();
12 | return { done: true };
13 | }
14 | const value = items.shift();
15 | // Throw an error
16 | if (value instanceof Error)
17 | throw value;
18 | // Halt iteration until resume is called
19 | if (value === undefined) {
20 | halt.resolve();
21 | return new Promise(resolve => {
22 | iterable.resume = () => resolve(next());
23 | });
24 | }
25 | // Return a simple value
26 | return { value };
27 | }
28 | return iterable;
29 | }
30 |
--------------------------------------------------------------------------------
/webpack/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | env: {
3 | node: true,
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/webpack/webpack.bundle.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | Exports @solid/react and all dependencies.
3 | */
4 |
5 | const extendConfig = require('./webpack.common.config');
6 |
7 | module.exports = extendConfig('./dist/', (outputDir, common) => ({
8 | ...common,
9 | output: {
10 | path: outputDir,
11 | filename: '[name].bundle.js',
12 | library: ['solid', 'react'],
13 | },
14 | externals: {},
15 | }));
16 |
--------------------------------------------------------------------------------
/webpack/webpack.common.config.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('path');
2 |
3 | module.exports = function extendConfig(outputDir, customize) {
4 | return customize(resolve(outputDir), {
5 | mode: 'none',
6 | context: resolve(__dirname, '..'),
7 | entry: {
8 | 'solid-react': './src/',
9 | },
10 | module: {
11 | rules: [
12 | {
13 | test: /\.jsx?$/,
14 | loader: 'babel-loader',
15 | exclude: resolve(__dirname, '../node_modules'),
16 | },
17 | ],
18 | },
19 | resolve: {
20 | extensions: ['.mjs', '.js', '.jsx', '.json'],
21 | },
22 | externals: {
23 | 'solid-auth-client': ['solid', 'auth'],
24 | '@solid/query-ldflex': ['solid', 'data'],
25 | },
26 | devtool: 'source-map',
27 | });
28 | };
29 |
--------------------------------------------------------------------------------
/webpack/webpack.demo.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | Exports the demo application.
3 | */
4 |
5 | const extendConfig = require('./webpack.common.config');
6 | const CopyWebpackPlugin = require('copy-webpack-plugin');
7 | const HtmlWebpackPlugin = require('html-webpack-plugin');
8 | const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin');
9 |
10 | const localAssets = [
11 | 'index.css',
12 | 'profile.svg',
13 | ];
14 | const externalAssets = [
15 | 'solid-auth-client/dist-popup/popup.html',
16 | 'solid-auth-client/dist-lib/solid-auth-client.bundle.js',
17 | 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map',
18 | '@solid/query-ldflex/dist/solid-query-ldflex.bundle.js',
19 | '@solid/query-ldflex/dist/solid-query-ldflex.bundle.js.map',
20 | ];
21 |
22 | module.exports = extendConfig('./dist/demo/', (outputDir, common) => ({
23 | ...common,
24 | entry: {
25 | demo: './demo/index.jsx',
26 | },
27 | output: {
28 | filename: '[name].bundle.js',
29 | path: outputDir,
30 | },
31 | plugins: [
32 | new CopyWebpackPlugin(localAssets, { context: 'demo' }),
33 | new CopyWebpackPlugin(externalAssets.map(a => require.resolve(a))),
34 | new HtmlWebpackPlugin({
35 | title: 'Solid React Components Demo',
36 | filename: 'index.html',
37 | }),
38 | new HtmlWebpackIncludeAssetsPlugin({
39 | assets: [
40 | ...localAssets,
41 | ...externalAssets.map(f => f.replace(/.*\//, '')),
42 | ].filter(f => /\.(js|css)$/.test(f)),
43 | append: false,
44 | }),
45 | ],
46 | }));
47 |
--------------------------------------------------------------------------------
/webpack/webpack.lib.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | Exports only @solid/react itself, expecting:
3 | - global.React to be react
4 | - global.PropTypes to be prop-types
5 | - global.solid.auth to be solid-auth-client
6 | - global.solid.data to be @solid/query-ldflex
7 | */
8 |
9 | const extendConfig = require('./webpack.common.config');
10 |
11 | module.exports = extendConfig('./dist', (outputDir, { externals, ...common }) => ({
12 | ...common,
13 | output: {
14 | path: outputDir,
15 | filename: '[name].js',
16 | library: ['solid', 'react'],
17 | },
18 | externals: {
19 | ...externals,
20 | 'react': 'React',
21 | 'prop-types': 'PropTypes',
22 | },
23 | }));
24 |
--------------------------------------------------------------------------------