├── .circleci
└── config.yml
├── .github
├── CODE_OF_CONDUCT.md
├── ISSUE_TEMPLATE.md
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── client
├── .env.local.example
├── .eslintrc
├── index.html
├── jsconfig.json
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── .well-known
│ │ └── security.txt
│ ├── img
│ │ ├── brands
│ │ │ ├── powered-by-algolia.svg
│ │ │ └── workplace.png
│ │ ├── email_header.png
│ │ ├── favicon
│ │ │ ├── favicon-32.png
│ │ │ ├── favicon-64.png
│ │ │ ├── favicon-96.png
│ │ │ ├── favicon.ico
│ │ │ └── favicon.png
│ │ ├── portrait_placeholder.png
│ │ ├── thinking_face-200.png
│ │ └── thinking_face.png
│ ├── manifest.json
│ └── robot.txt
├── src
│ ├── components
│ │ ├── AlertStack
│ │ │ ├── Alert.jsx
│ │ │ ├── Alert.mdx
│ │ │ ├── AlertContext.jsx
│ │ │ ├── AlertStack.jsx
│ │ │ └── index.js
│ │ ├── Authenticated
│ │ │ ├── Authenticated.jsx
│ │ │ └── index.js
│ │ ├── Avatar
│ │ │ ├── Avatar.jsx
│ │ │ ├── Avatar.mdx
│ │ │ └── index.js
│ │ ├── Button
│ │ │ ├── Button.jsx
│ │ │ ├── Button.mdx
│ │ │ └── index.js
│ │ ├── Card
│ │ │ ├── Card.jsx
│ │ │ ├── Card.mdx
│ │ │ ├── CardActions.jsx
│ │ │ ├── CardText.jsx
│ │ │ ├── CardTitle.jsx
│ │ │ ├── PermanentClosableCard.jsx
│ │ │ └── index.js
│ │ ├── CtrlEnter
│ │ │ ├── CtrlEnter.jsx
│ │ │ └── index.js
│ │ ├── Dropdown
│ │ │ ├── Dropdown.jsx
│ │ │ ├── Dropdown.mdx
│ │ │ ├── DropdownDivider.jsx
│ │ │ ├── DropdownItem.jsx
│ │ │ └── index.js
│ │ ├── ErrorBoundary
│ │ │ ├── ErrorBoundary.jsx
│ │ │ └── index.js
│ │ ├── Flags
│ │ │ ├── Flag.jsx
│ │ │ ├── Flag.mdx
│ │ │ ├── Flags.jsx
│ │ │ └── index.js
│ │ ├── Icon
│ │ │ ├── Icon.jsx
│ │ │ └── index.js
│ │ ├── Input
│ │ │ ├── Input.jsx
│ │ │ ├── Input.mdx
│ │ │ ├── MarkdownEditor
│ │ │ │ ├── MarkdownEditor.jsx
│ │ │ │ └── index.js
│ │ │ └── index.js
│ │ ├── List
│ │ │ ├── List.jsx
│ │ │ ├── List.mdx
│ │ │ ├── ListItem.jsx
│ │ │ └── index.js
│ │ ├── Loading
│ │ │ ├── Loading.css
│ │ │ ├── Loading.jsx
│ │ │ ├── Loading.mdx
│ │ │ ├── index.js
│ │ │ ├── withError.jsx
│ │ │ └── withLoading.jsx
│ │ ├── Modal
│ │ │ ├── Modal.jsx
│ │ │ └── index.js
│ │ ├── Pagination
│ │ │ ├── DefaultPagination.jsx
│ │ │ ├── Pagination.jsx
│ │ │ ├── Pagination.mdx
│ │ │ ├── index.js
│ │ │ └── withPagination.jsx
│ │ ├── PairInputList
│ │ │ ├── PairInput.jsx
│ │ │ ├── PairInputList.jsx
│ │ │ └── index.js
│ │ ├── PrivateRoute
│ │ │ ├── PrivateRoute.jsx
│ │ │ └── index.js
│ │ ├── Radio
│ │ │ ├── Radio.jsx
│ │ │ └── index.js
│ │ ├── Tabs
│ │ │ ├── Tab.jsx
│ │ │ ├── Tabs.jsx
│ │ │ └── index.js
│ │ ├── TagPicker
│ │ │ ├── TagPicker.jsx
│ │ │ └── index.js
│ │ ├── Tags
│ │ │ ├── Tags.jsx
│ │ │ ├── Tags.mdx
│ │ │ └── index.js
│ │ ├── Tips
│ │ │ ├── Tips.jsx
│ │ │ └── index.js
│ │ ├── UsersList
│ │ │ ├── UsersList.jsx
│ │ │ ├── components
│ │ │ │ ├── Specialist.jsx
│ │ │ │ └── SpecialtiesList.jsx
│ │ │ ├── index.js
│ │ │ └── queries.js
│ │ └── index.js
│ ├── contexts
│ │ ├── Auth
│ │ │ ├── AuthProvider.jsx
│ │ │ ├── hooks.js
│ │ │ ├── index.js
│ │ │ └── queries.js
│ │ ├── Configuration
│ │ │ ├── ConfigurationProvider.jsx
│ │ │ ├── hooks.js
│ │ │ └── index.js
│ │ ├── User
│ │ │ ├── UserProvider.jsx
│ │ │ ├── hooks.js
│ │ │ ├── index.js
│ │ │ └── queries.js
│ │ └── index.js
│ ├── helpers
│ │ ├── answerCanBeCertified.js
│ │ ├── compose.js
│ │ ├── history.js
│ │ ├── index.js
│ │ ├── isUuid.js
│ │ ├── onListChange.js
│ │ ├── prompt.js
│ │ ├── question.js
│ │ ├── safeFetch.js
│ │ ├── serialize.js
│ │ ├── translation.js
│ │ └── useClickOutside.js
│ ├── index.css
│ ├── index.jsx
│ ├── scenes
│ │ ├── App
│ │ │ ├── App.jsx
│ │ │ ├── components
│ │ │ │ ├── Footer
│ │ │ │ │ ├── Footer.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── Navbar
│ │ │ │ │ ├── Navbar.jsx
│ │ │ │ │ ├── components
│ │ │ │ │ ├── GithubIcon
│ │ │ │ │ │ ├── GithubIcon.jsx
│ │ │ │ │ │ └── index.js
│ │ │ │ │ ├── UserMenu
│ │ │ │ │ │ ├── UserMenu.jsx
│ │ │ │ │ │ └── index.js
│ │ │ │ │ └── index.js
│ │ │ │ │ └── index.js
│ │ │ └── index.js
│ │ ├── Auth
│ │ │ ├── Auth.jsx
│ │ │ ├── Callback.jsx
│ │ │ ├── Login.jsx
│ │ │ ├── Logout.jsx
│ │ │ └── index.js
│ │ ├── Home
│ │ │ ├── Home.jsx
│ │ │ ├── components
│ │ │ │ ├── NoResults
│ │ │ │ │ ├── NoResults.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Result
│ │ │ │ │ ├── Result.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── ResultList
│ │ │ │ │ ├── ResultList.container.js
│ │ │ │ │ ├── ResultList.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Searchbar
│ │ │ │ │ ├── Searchbar.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ └── queries.js
│ │ ├── NotFound
│ │ │ ├── NotFound.jsx
│ │ │ └── index.js
│ │ ├── Question
│ │ │ ├── Question.jsx
│ │ │ ├── QuestionRoutes.jsx
│ │ │ ├── components
│ │ │ │ ├── ActionMenu
│ │ │ │ │ ├── ActionMenu.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ ├── queries.js
│ │ │ └── scenes
│ │ │ │ ├── Answer
│ │ │ │ ├── Answer.jsx
│ │ │ │ ├── components
│ │ │ │ │ └── Tips
│ │ │ │ │ │ ├── Tips.jsx
│ │ │ │ │ │ └── index.js
│ │ │ │ ├── helpers.js
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ │ │ ├── Edit
│ │ │ │ ├── Edit.jsx
│ │ │ │ ├── components
│ │ │ │ │ └── Tips
│ │ │ │ │ │ ├── Tips.jsx
│ │ │ │ │ │ └── index.js
│ │ │ │ ├── helpers.js
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ │ │ ├── Random
│ │ │ │ ├── Random.container.js
│ │ │ │ ├── Random.jsx
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ │ │ └── Read
│ │ │ │ ├── Read.jsx
│ │ │ │ ├── components
│ │ │ │ ├── FlagsDropdown
│ │ │ │ │ ├── FlagsDropdown.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── History
│ │ │ │ │ ├── History.jsx
│ │ │ │ │ ├── HistoryAction.jsx
│ │ │ │ │ ├── HistoryActions.container.js
│ │ │ │ │ ├── HistoryActions.jsx
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── queries.js
│ │ │ │ ├── LanguageDropdown
│ │ │ │ │ ├── LanguageDropdown.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Meta
│ │ │ │ │ ├── Meta.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Share
│ │ │ │ │ ├── Share.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Sources
│ │ │ │ │ ├── Sources.jsx
│ │ │ │ │ └── index.js
│ │ │ │ ├── Views
│ │ │ │ │ ├── Views.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── index.js
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ ├── Settings
│ │ │ ├── Settings.jsx
│ │ │ ├── components
│ │ │ │ ├── TagsEditor
│ │ │ │ │ ├── TagsEditor.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── index.js
│ │ │ ├── helpers.js
│ │ │ ├── index.js
│ │ │ ├── queries.js
│ │ │ └── scenes
│ │ │ │ ├── General.jsx
│ │ │ │ ├── Integrations.jsx
│ │ │ │ ├── Specialists.jsx
│ │ │ │ ├── Synonyms.jsx
│ │ │ │ ├── Tags.jsx
│ │ │ │ └── index.js
│ │ └── UserProfile
│ │ │ ├── UserProfile.container.jsx
│ │ │ ├── UserProfile.jsx
│ │ │ ├── components
│ │ │ ├── Logs
│ │ │ │ ├── Logs.container.js
│ │ │ │ ├── Logs.jsx
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ │ └── Specialties
│ │ │ │ ├── Specialties.container.js
│ │ │ │ ├── Specialties.jsx
│ │ │ │ ├── index.js
│ │ │ │ └── queries.js
│ │ │ ├── index.js
│ │ │ └── queries.js
│ ├── services
│ │ ├── alert.jsx
│ │ ├── apollo.jsx
│ │ ├── auth.js
│ │ ├── index.js
│ │ ├── intl.js
│ │ ├── markdown.jsx
│ │ └── routing.js
│ └── styles
│ │ ├── index.js
│ │ └── tooltip.css
├── tailwind.config.js
└── vite.config.js
├── docs
├── README.md
├── assets.json
├── doczrc.js
├── index.html
├── index.mdx
├── package-lock.json
├── package.json
├── src
│ ├── Wrapper.css
│ ├── Wrapper.jsx
│ ├── advanced
│ │ ├── backing_services.mdx
│ │ ├── configuration.mdx
│ │ ├── contributing.mdx
│ │ ├── integrations.mdx
│ │ ├── multi_tenancy.mdx
│ │ └── testing.mdx
│ ├── banner_img.png
│ ├── build.sh
│ ├── getting-started.mdx
│ └── index.prefix.txt
└── static
│ ├── css
│ └── app.c37236e96cca911241e2.css
│ └── js
│ ├── 11.5791dc59.js
│ ├── 11.c37236e96cca911241e2.js.map
│ ├── app.0f57e38e.js
│ ├── app.c37236e96cca911241e2.js.map
│ ├── index.54dfa37d.js
│ ├── index.c37236e96cca911241e2.js.map
│ ├── runtime~app.c37236e96cca911241e2.js
│ ├── runtime~app.c37236e96cca911241e2.js.map
│ ├── src-advanced-backing-services.14b600d4.js
│ ├── src-advanced-backing-services.c37236e96cca911241e2.js.map
│ ├── src-advanced-configuration.b6e8bb10.js
│ ├── src-advanced-configuration.c37236e96cca911241e2.js.map
│ ├── src-advanced-contributing.c37236e96cca911241e2.js.map
│ ├── src-advanced-contributing.ffc832ac.js
│ ├── src-advanced-integrations.415ab1e4.js
│ ├── src-advanced-integrations.c37236e96cca911241e2.js.map
│ ├── src-advanced-multi-tenancy.9321d3e0.js
│ ├── src-advanced-multi-tenancy.c37236e96cca911241e2.js.map
│ ├── src-advanced-testing.8ee28a8a.js
│ ├── src-advanced-testing.c37236e96cca911241e2.js.map
│ ├── src-getting-started.9c1c62a2.js
│ ├── src-getting-started.c37236e96cca911241e2.js.map
│ ├── vendors.c37236e96cca911241e2.js.map
│ └── vendors.d89f3129.js
├── e2e
├── .eslintrc
├── .gitignore
├── client.test.js
├── package-lock.json
├── package.json
├── playwright.config.js
└── translation_mocks.json
└── server
├── .env.local.example
├── .eslintrc
├── .graphqlconfig.yml
├── .prettierignore
├── package-lock.json
├── package.json
├── prisma
├── Dockerfile.clever-cloud
├── datamodel.graphql
├── docker-compose.yml
└── prisma.yml
├── scripts
├── algolia_resync
│ ├── README.md
│ └── index.js
├── algolia_settings
│ └── index.js
├── graphcool_migration
│ ├── README.md
│ ├── export.sh
│ ├── import.sh
│ └── transform.js
├── helpers.js
├── prisma_deploy_all
│ └── index.js
├── prisma_new_service
│ └── index.js
├── prisma_token
│ ├── README.md
│ └── token.sh
├── prisma_transfer_service
│ ├── README.md
│ └── index.js
└── tmp_tag_resync
│ └── part1.js
└── src
├── directives
├── admin.js
└── index.js
├── endpoints
├── configuration.js
├── index.js
└── integrations.js
├── generated
└── prisma.graphql
├── helpers
├── certified.js
├── diffTags.js
├── history.js
├── index.js
├── logger.js
├── randomString.js
├── requireText.js
├── translation.js
├── updateHistoryAndAlgolia.js
└── validateAndParseIdToken.js
├── index.js
├── integrations
├── algolia.js
├── index.js
├── mailgun
│ ├── answer
│ │ ├── answer.mjml
│ │ ├── answer.txt
│ │ └── index.js
│ └── index.js
└── slack.js
├── middlewares
├── auth.js
├── configuration.js
├── error.js
├── first-user.js
└── index.js
├── multiTenant.js
├── resolvers
├── answer.js
├── configuration.js
├── flag.js
├── history.js
├── index.js
├── question.js
├── random.js
├── search.js
└── user.js
└── schema.graphql
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | For **feature requests**, please fill out the [feature request template](https://github.com/zenika-open-source/FAQ/issues/new?template=feature_request.md)
2 |
3 | For **bug reports**, please fill out the [bug report issue template](https://github.com/zenika-open-source/FAQ/issues/new?template=bug_report.md)
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | **Describe the bug**
8 | A clear and concise description of what the bug is.
9 |
10 | **To Reproduce**
11 | Steps to reproduce the behavior:
12 | 1. Go to '...'
13 | 2. Click on '....'
14 | 3. Scroll down to '....'
15 | 4. See error
16 |
17 | **Expected behavior**
18 | A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots**
21 | If applicable, add screenshots to help explain your problem.
22 |
23 | **Additional context**
24 | Add any other context about the problem here.
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | /coverage
8 | /e2e/test-results
9 |
10 | # production
11 | .build
12 | client/build
13 | server/front_build
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 | .graphcoolrc
22 | .vscode
23 | stats.json
24 | .docz
25 |
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions, issues and feature requests are very welcome. If you are using this app and improved it or fixed a bug, please consider submitting a PR!
4 |
5 | ## Guidelines
6 |
7 | ### Commit messages
8 |
9 | This project uses [gitmoji](https://gitmoji.carloscuesta.me/) as the commit convention, because it's fun and provides an easy way of identifying the purpose or intention of a commit only by looking at the emojis used. All you have to do is to find the emoji corresponding to the purpose of your commit in [this list](https://gitmoji.carloscuesta.me/), followed by a short message explaining the update. You can also add a description if it's needed. That's all ;)
10 |
11 | https://gitmoji.carloscuesta.me/
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # FAQ Zenika
4 |
5 | Internal Knowledge Database for your organization.
6 |
7 | ## What is FAQ?
8 |
9 | **FAQ** is an **internal knowledge database** for your organization's members. It aims to be the **"single source of truth"** for most of your company-oriented information: _"How does the variable part of the salary work?"_, _"Can the client ask me to stay late at work?"_, ...
10 |
11 | ## Philosophy
12 |
13 | - **Single source of truth:** Regrouping useful information
14 | - **Intuitive:** Not only developer-oriented
15 | - **Integrated:** Works well with your existing tool suite
16 | - **Internationalized:** Open to the world
17 |
18 | ## Technologies
19 |
20 | #### Frontend
21 |
22 | - JS _(React, Apollo)_
23 | - CSS _(Custom-made)_
24 |
25 | #### Backend
26 |
27 | - Node JS
28 | - Prisma
29 | - PostgreSQL
30 |
31 | #### Backing services
32 |
33 | - Algolia _(Search-as-a-Service)_
34 | - Mailgun _(Mail-as-a-Service)_
35 | - Auth0 _(Auth-as-a-Service)_
36 |
37 | #### Integrations
38 |
39 | - Slack: Sending new questions into a dedicated channel and `/faq` command
40 | - Workplace: Button to share questions
41 | - Public API: Write your own integration to query the FAQ
42 |
43 | ## Documentation
44 |
45 | New to this project ?
46 |
47 | - [Step-by-step installation](https://zenika-open-source.github.io/FAQ/#/getting-started) with only the required backing services
48 |
49 | The full documentation is available here:
50 |
51 | - https://zenika-open-source.github.io/FAQ/
52 |
53 | ## License
54 |
55 | This project is under the Apache License 2.0 - See the [LICENSE.md](https://github.com/zenika-open-source/FAQ/blob/main/LICENSE.md) file for details
56 |
57 | 
58 |
--------------------------------------------------------------------------------
/client/.env.local.example:
--------------------------------------------------------------------------------
1 | VITE_FAQ_URL=faq.zenika.com
2 |
3 | VITE_GRAPHQL_ENDPOINT=/gql
4 | VITE_REST_ENDPOINT=/rest
5 | VITE_PRISMA_SERVICE=default/default
6 |
7 | VITE_DISABLE_AUTH=
--------------------------------------------------------------------------------
/client/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2022": true
5 | },
6 | "extends": ["standard", "plugin:react/recommended", "plugin:react/jsx-runtime", "prettier"],
7 | "parser": "@babel/eslint-parser",
8 | "parserOptions": {
9 | "ecmaVersion": 2019,
10 | "sourceType": "module",
11 | "requireConfigFile": false,
12 | "ecmaFeatures": {
13 | "jsx": true,
14 | "modules": true
15 | },
16 | "babelOptions": {
17 | "presets": ["@babel/preset-react"]
18 | }
19 | },
20 | "settings": {
21 | "react": {
22 | "version": "detect"
23 | }
24 | },
25 | "rules": {
26 | "no-console": "warn",
27 | "react/prop-types": "off",
28 | "react/no-unescaped-entities": "off",
29 | "linebreak-style": ["error", "unix"],
30 | "space-before-function-paren": [
31 | "error",
32 | { "anonymous": "never", "named": "never", "asyncArrow": "always" }
33 | ]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/client/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/client/public/.well-known/security.txt:
--------------------------------------------------------------------------------
1 | Contact: thibaud.courtoison@zenika.com
2 | Preferred-Languages: en, fr
3 |
--------------------------------------------------------------------------------
/client/public/img/brands/workplace.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/brands/workplace.png
--------------------------------------------------------------------------------
/client/public/img/email_header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/email_header.png
--------------------------------------------------------------------------------
/client/public/img/favicon/favicon-32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/favicon/favicon-32.png
--------------------------------------------------------------------------------
/client/public/img/favicon/favicon-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/favicon/favicon-64.png
--------------------------------------------------------------------------------
/client/public/img/favicon/favicon-96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/favicon/favicon-96.png
--------------------------------------------------------------------------------
/client/public/img/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/favicon/favicon.ico
--------------------------------------------------------------------------------
/client/public/img/favicon/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/favicon/favicon.png
--------------------------------------------------------------------------------
/client/public/img/portrait_placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/portrait_placeholder.png
--------------------------------------------------------------------------------
/client/public/img/thinking_face-200.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/thinking_face-200.png
--------------------------------------------------------------------------------
/client/public/img/thinking_face.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/client/public/img/thinking_face.png
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "FAQ",
3 | "name": "FAQ",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#af1e3a",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/public/robot.txt:
--------------------------------------------------------------------------------
1 | User-Agent: *
2 | Allow: /
--------------------------------------------------------------------------------
/client/src/components/AlertStack/Alert.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Alert
3 | route: /components/alert
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import { AlertProvider } from './AlertContext.jsx'
9 | import AlertStack from './AlertStack.jsx'
10 | import Button from '../Button'
11 |
12 | # Alert
13 |
14 | ```js
15 | import { AlertStack, AlertProvider } from 'components'
16 | ```
17 |
18 | ## Basic usage
19 |
20 |
21 |
22 |
23 |
24 |
25 | Push alerts into the stack:
26 | {
27 | ['primary', 'info','success','warning','error']
28 | .map(type => AlertProvider.pushAlert({type, message: 'This is an alert of type: '+type})}>{type} )
29 | }
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/client/src/components/AlertStack/AlertContext.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | const AlertContext = React.createContext()
5 |
6 | class AlertProvider extends Component {
7 | constructor(props) {
8 | super(props)
9 |
10 | this.state = {
11 | alerts: [],
12 | pushAlert: this.pushAlert,
13 | showAlert: this.showAlert,
14 | closeAlert: this.closeAlert,
15 | }
16 |
17 | AlertProvider.pushAlert = this.pushAlert
18 | }
19 |
20 | pushAlert = (alert) => {
21 | this.setState((state) => ({
22 | alerts: [{ ...alert, id: Math.random() }, ...state.alerts],
23 | }))
24 |
25 | if (['error', 'warning'].includes(alert.type)) {
26 | // eslint-disable-next-line no-console
27 | console.error(alert)
28 | }
29 | }
30 |
31 | showAlert = (alert) =>
32 | this.setState((state) => ({
33 | alerts: state.alerts.map((a) => {
34 | if (a !== alert) return a
35 | return { ...alert, shown: true }
36 | }),
37 | }))
38 |
39 | closeAlert = (alert) =>
40 | this.setState((state) => ({
41 | alerts: state.alerts.map((a) => {
42 | if (a !== alert) return a
43 | return { ...alert, shown: false, closed: true }
44 | }),
45 | }))
46 |
47 | render() {
48 | return {this.props.children}
49 | }
50 | }
51 |
52 | AlertProvider.propTypes = {
53 | children: PropTypes.node.isRequired,
54 | }
55 |
56 | export default AlertContext
57 | export { AlertProvider }
58 |
--------------------------------------------------------------------------------
/client/src/components/AlertStack/AlertStack.jsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 |
3 | import Alert from './Alert'
4 | import AlertContext from './AlertContext'
5 |
6 | class AlertStack extends Component {
7 | static contextType = AlertContext
8 | render() {
9 | return (
10 |
11 | {this.context.alerts.map((alert) => (
12 |
13 | ))}
14 |
15 | )
16 | }
17 | }
18 |
19 | export default AlertStack
20 |
--------------------------------------------------------------------------------
/client/src/components/AlertStack/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './AlertStack'
2 | export { default as AlertContext, AlertProvider } from './AlertContext'
3 |
--------------------------------------------------------------------------------
/client/src/components/Authenticated/Authenticated.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Navigate } from 'react-router-dom'
3 |
4 | import { useAuth } from 'contexts'
5 |
6 | const Authenticated = ({ reverse, redirect, children, admin, specialist }) => {
7 | const { isAuth, isAdmin, isSpecialist } = useAuth()
8 |
9 | if (admin && isAdmin) {
10 | return children
11 | }
12 |
13 | if (specialist && isSpecialist) {
14 | return children
15 | }
16 |
17 | if (admin || specialist) {
18 | return redirect ? : ''
19 | }
20 |
21 | if ((isAuth && !reverse) || (!isAuth && reverse)) {
22 | return children
23 | }
24 |
25 | if (redirect) {
26 | return
27 | }
28 |
29 | return ''
30 | }
31 |
32 | Authenticated.propTypes = {
33 | reverse: PropTypes.bool,
34 | redirect: PropTypes.string,
35 | children: PropTypes.node,
36 | admin: PropTypes.bool,
37 | specialist: PropTypes.bool,
38 | }
39 |
40 | export default Authenticated
41 |
--------------------------------------------------------------------------------
/client/src/components/Authenticated/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Authenticated'
2 |
--------------------------------------------------------------------------------
/client/src/components/Avatar/Avatar.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | const Avatar = ({ image, alt, ...otherProps }) => (
4 |
5 | )
6 |
7 | Avatar.propTypes = {
8 | image: PropTypes.string.isRequired,
9 | alt: PropTypes.string,
10 | }
11 |
12 | export default Avatar
13 |
--------------------------------------------------------------------------------
/client/src/components/Avatar/Avatar.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Avatar
3 | route: /components/avatar
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Avatar from './Avatar.jsx'
9 |
10 | # Avatar
11 |
12 | ```js
13 | import { Avatar } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ## Properties
25 |
26 |
27 |
--------------------------------------------------------------------------------
/client/src/components/Avatar/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Avatar'
2 |
--------------------------------------------------------------------------------
/client/src/components/Button/Button.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Button
3 | route: /components/button
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Button from './Button.jsx'
9 |
10 | # Button
11 |
12 | ```js
13 | import { Button } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 | Primary
20 | Secondary
21 | Link
22 |
23 |
24 | Primary Round
25 |
26 |
27 | Primary disabled
28 |
29 |
30 | Primary raised
31 |
32 |
33 |
34 | Primary
35 |
36 |
37 | Secondary
38 |
39 |
40 | Link
41 |
42 |
43 |
44 | ## Properties
45 |
46 |
47 |
--------------------------------------------------------------------------------
/client/src/components/Button/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Button'
2 |
--------------------------------------------------------------------------------
/client/src/components/Card/Card.jsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import PropTypes from 'prop-types'
3 |
4 | import CardActions from './CardActions'
5 | import CardText from './CardText'
6 | import CardTitle from './CardTitle'
7 |
8 | const Card = ({ children, className, ...otherProps }) => (
9 |
13 | {children}
14 |
15 | )
16 |
17 | Card.propTypes = {
18 | children: PropTypes.node.isRequired,
19 | className: PropTypes.string,
20 | }
21 |
22 | Card.Title = CardTitle
23 | Card.Text = CardText
24 | Card.Actions = CardActions
25 |
26 | export default Card
27 |
--------------------------------------------------------------------------------
/client/src/components/Card/Card.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Card
3 | menu: Components
4 | route: /components/card
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Card from './Card.jsx'
9 | import CardTitle from './CardTitle.jsx'
10 | import CardText from './CardText.jsx'
11 | import CardActions from './CardActions.jsx'
12 | import Button from '../Button'
13 |
14 | # Card
15 |
16 | ```js
17 | import { Card, CardTitle, CardText, CardActions } from 'components'
18 | ```
19 |
20 | ## Basic usage
21 |
22 |
23 |
24 |
25 | My card
26 |
27 |
28 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec leo eros,
29 | pharetra quis rutrum nec, fermentum a urna. Cras maximus ipsum felis, id
30 | pharetra nisl aliquam vel. Aliquam odio risus, porta sed odio dictum,
31 | egestas malesuada orci. Quisque vestibulum efficitur volutpat. Aliquam
32 | erat volutpat. Nam viverra id tellus ut lobortis. Maecenas sapien magna,
33 | mattis a bibendum sed, semper et turpis. Nulla facilisi. Duis tempor
34 | pharetra mi, efficitur aliquet est porttitor nec. Ut nisi mi, facilisis a
35 | lacus et, aliquam feugiat libero. Lorem ipsum dolor sit amet, consectetur
36 | adipiscing elit.
37 |
38 |
39 | Cancel
40 | Save
41 |
42 |
43 |
44 |
45 | ## Properties
46 |
47 | ### Card
48 |
49 |
50 |
51 | ### CardTitle
52 |
53 |
54 |
55 | ### CardText
56 |
57 |
58 |
59 | ### CardActions
60 |
61 |
62 |
--------------------------------------------------------------------------------
/client/src/components/Card/CardActions.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | const CardAction = ({ children, ...otherProps }) => (
4 |
8 | {children}
9 |
10 | )
11 |
12 | CardAction.propTypes = {
13 | children: PropTypes.node.isRequired,
14 | }
15 |
16 | export default CardAction
17 |
--------------------------------------------------------------------------------
/client/src/components/Card/CardText.jsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import PropTypes from 'prop-types'
3 |
4 | const CardText = ({ children, collapsed, className, ...otherProps }) => (
5 |
13 | {children}
14 |
15 | )
16 |
17 | CardText.propTypes = {
18 | children: PropTypes.node.isRequired,
19 | collapsed: PropTypes.bool,
20 | className: PropTypes.string,
21 | }
22 |
23 | export default CardText
24 |
--------------------------------------------------------------------------------
/client/src/components/Card/CardTitle.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import cn from 'classnames'
3 |
4 | const CardTitle = ({ children, className, onClick, ...otherProps }) => (
5 |
15 | {children}
16 |
17 | )
18 |
19 | CardTitle.propTypes = {
20 | children: PropTypes.node.isRequired,
21 | style: PropTypes.object,
22 | onClick: PropTypes.func,
23 | }
24 |
25 | export default CardTitle
26 |
--------------------------------------------------------------------------------
/client/src/components/Card/PermanentClosableCard.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Component } from 'react'
3 |
4 | import { getIntl } from 'services'
5 |
6 | import Button from 'components/Button'
7 |
8 | import Card from './Card'
9 | import CardActions from './CardActions'
10 |
11 | class PermanentClosableCard extends Component {
12 | static setValue(name, value) {
13 | const json = JSON.parse(localStorage.getItem('permanent_closable_cards')) || {}
14 | json[name] = value
15 | localStorage.setItem('permanent_closable_cards', JSON.stringify(json))
16 | }
17 |
18 | static isOpen(name) {
19 | const json = JSON.parse(localStorage.getItem('permanent_closable_cards'))
20 | return json ? (json[name] !== undefined ? json[name] : true) : true
21 | }
22 |
23 | render() {
24 | const intl = getIntl(PermanentClosableCard)
25 |
26 | const { open, close, children, ...otherProps } = this.props
27 |
28 | return (
29 | <>
30 | {open && (
31 |
32 |
33 | close
34 |
35 | {children}
36 |
37 |
44 |
45 |
46 | )}
47 | >
48 | )
49 | }
50 | }
51 |
52 | PermanentClosableCard.propTypes = {
53 | open: PropTypes.bool.isRequired,
54 | close: PropTypes.func.isRequired,
55 | children: PropTypes.node.isRequired,
56 | }
57 |
58 | PermanentClosableCard.translations = {
59 | en: { understood: 'Understood!' },
60 | fr: { understood: 'Compris !' },
61 | }
62 |
63 | export default PermanentClosableCard
64 |
--------------------------------------------------------------------------------
/client/src/components/Card/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Card'
2 | export { default as CardText } from './CardText'
3 | export { default as CardTitle } from './CardTitle'
4 | export { default as CardActions } from './CardActions'
5 | export { default as PermanentClosableCard } from './PermanentClosableCard'
6 |
--------------------------------------------------------------------------------
/client/src/components/CtrlEnter/CtrlEnter.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | class CtrlEnter extends Component {
5 | constructor(props) {
6 | super(props)
7 |
8 | this.ref = React.createRef()
9 | }
10 |
11 | componentDidMount() {
12 | if (this.ref.current) {
13 | this.ref.current.addEventListener('keydown', this.keydownHandler)
14 | }
15 | }
16 |
17 | componentWillUnmount() {
18 | if (this.ref.current) {
19 | this.ref.current.removeEventListener('keydown', this.keydownHandler)
20 | }
21 | }
22 |
23 | keydownHandler = (e) => {
24 | if (e.keyCode === 13 && e.ctrlKey) {
25 | this.props.onCtrlEnterCallback()
26 | }
27 | }
28 |
29 | render() {
30 | const { children, onCtrlEnterCallback, ...rest } = this.props
31 | return (
32 |
33 | {this.props.children}
34 |
35 | )
36 | }
37 | }
38 |
39 | CtrlEnter.propTypes = {
40 | onCtrlEnterCallback: PropTypes.func.isRequired,
41 | children: PropTypes.node.isRequired,
42 | }
43 |
44 | export default CtrlEnter
45 |
--------------------------------------------------------------------------------
/client/src/components/CtrlEnter/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './CtrlEnter'
2 |
--------------------------------------------------------------------------------
/client/src/components/Dropdown/Dropdown.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import PropTypes from 'prop-types'
3 | import cn from 'classnames'
4 |
5 | export const DropdownContext = React.createContext()
6 |
7 | const Dropdown = ({ className, button, children }) => {
8 | const [active, setActive] = useState(false)
9 |
10 | return (
11 |
12 | setActive(true)}
15 | onMouseLeave={() => setActive(false)}
16 | >
17 |
{button}
18 |
22 | {children}
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | Dropdown.propTypes = {
30 | className: PropTypes.string,
31 | button: PropTypes.node.isRequired,
32 | children: PropTypes.node.isRequired,
33 | }
34 |
35 | export default Dropdown
36 |
--------------------------------------------------------------------------------
/client/src/components/Dropdown/Dropdown.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Dropdown
3 | route: /components/dropdown
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Dropdown from './Dropdown.jsx'
9 | import DropdownItem from './DropdownItem.jsx'
10 | import DropdownDivider from './DropdownDivider.jsx'
11 | import Button from '../Button'
12 |
13 | # Dropdown
14 |
15 | ```js
16 | import { Dropdown, DropdownItem, DropdownDivider } from 'components'
17 | ```
18 |
19 | ## Basic usage
20 |
21 |
22 |
23 | Hover here}>
24 | Example item
25 |
26 | Icon left
27 | Icon right
28 |
29 | Disabled
30 |
31 | As a link
32 |
33 |
34 |
35 |
36 |
37 | ## Properties
38 |
39 | ### Dropdown
40 |
41 |
42 |
43 | ### DropdownItem
44 |
45 |
46 |
47 | ### DropdownDivider
48 |
49 | _DropdownDivider doesn't take any props_
50 |
--------------------------------------------------------------------------------
/client/src/components/Dropdown/DropdownDivider.jsx:
--------------------------------------------------------------------------------
1 | const DropdownDivider = () =>
2 |
3 | export default DropdownDivider
4 |
--------------------------------------------------------------------------------
/client/src/components/Dropdown/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Dropdown'
2 | export { default as DropdownItem } from './DropdownItem'
3 | export { default as DropdownDivider } from './DropdownDivider'
4 |
--------------------------------------------------------------------------------
/client/src/components/ErrorBoundary/ErrorBoundary.jsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import { getIntl } from 'services'
3 |
4 | class ErrorBoundary extends Component {
5 | constructor(props) {
6 | super(props)
7 | this.state = { hasError: false, error: null }
8 | }
9 |
10 | static getDerivedStateFromError(error) {
11 | return { hasError: true, error }
12 | }
13 |
14 | render() {
15 | const intl = getIntl(ErrorBoundary)
16 |
17 | if (this.state.hasError) {
18 | return {intl('message')}
19 | }
20 |
21 | return this.props.children
22 | }
23 | }
24 |
25 | ErrorBoundary.translations = {
26 | en: { message: 'Something went wrong.' },
27 | fr: { message: "Quelque chose s'est mal passé." },
28 | }
29 |
30 | export default ErrorBoundary
31 |
--------------------------------------------------------------------------------
/client/src/components/ErrorBoundary/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ErrorBoundary'
2 |
--------------------------------------------------------------------------------
/client/src/components/Flags/Flag.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Flag
3 | route: /components/flag
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Flag from './Flag.jsx'
9 | import './Flags.css'
10 |
11 | # Flag
12 |
13 | ```js
14 | import { Flag } from 'components'
15 | ```
16 |
17 | ## Basic usage
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ## Properties
36 |
37 |
38 |
--------------------------------------------------------------------------------
/client/src/components/Flags/Flags.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import clone from 'lodash/clone'
3 | import find from 'lodash/find'
4 | import map from 'lodash/map'
5 | import { format } from 'date-fns'
6 |
7 | import { getIntl } from 'services'
8 |
9 | import Flag, { flagMeta } from './Flag'
10 |
11 | const Flags = ({ node, withLabels }) => {
12 | const intl = getIntl(Flags)
13 | const flagIntl = getIntl(Flag)
14 |
15 | const flags = clone(node.flags)
16 |
17 | if (flags.length === 0) return ''
18 |
19 | return (
20 |
21 | {map(flagMeta, (meta, type) => {
22 | let flag = find(flags, { type })
23 |
24 | if (!flag) return null
25 |
26 | let tooltip
27 |
28 | if (withLabels && flag.user) {
29 | tooltip = intl('tooltip')(flag.user.name, format(new Date(flag.createdAt), 'P'))
30 | } else {
31 | tooltip = flagIntl(flag.type).toUpperCase()
32 | }
33 |
34 | return
35 | })}
36 |
37 | )
38 | }
39 |
40 | Flags.propTypes = {
41 | node: PropTypes.object.isRequired,
42 | withLabels: PropTypes.bool,
43 | }
44 |
45 | Flags.translations = {
46 | en: { tooltip: (name, date) => `By ${name} on ${date}` },
47 | fr: { tooltip: (name, date) => `Par ${name} le ${date}` },
48 | }
49 |
50 | export default Flags
51 |
--------------------------------------------------------------------------------
/client/src/components/Flags/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Flags'
2 | export { default as Flag, flagMeta } from './Flag'
3 |
--------------------------------------------------------------------------------
/client/src/components/Icon/Icon.jsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import PropTypes from 'prop-types'
3 |
4 | const Icon = ({ material, className, ...rest }) => {
5 | if (material)
6 | return (
7 |
8 | {material}
9 |
10 | )
11 |
12 | // Currently, this component only support material-icons, but in the future, there could be others
13 | return null
14 | }
15 |
16 | Icon.propTypes = {
17 | material: PropTypes.string,
18 | className: PropTypes.string,
19 | }
20 |
21 | export default Icon
22 |
--------------------------------------------------------------------------------
/client/src/components/Icon/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Icon'
2 |
--------------------------------------------------------------------------------
/client/src/components/Input/Input.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Input
3 | route: /components/input
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Input from './Input.jsx'
9 |
10 | # Input
11 |
12 | ```js
13 | import { Input } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | ## Properties
27 |
28 |
29 |
--------------------------------------------------------------------------------
/client/src/components/Input/MarkdownEditor/MarkdownEditor.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import MDEditor from '@uiw/react-md-editor'
4 |
5 | const MarkdownEditor = ({ content, onChange }) => {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
13 | MarkdownEditor.propTypes = {
14 | content: PropTypes.string,
15 | onChange: PropTypes.func,
16 | }
17 |
18 | export default MarkdownEditor
19 |
--------------------------------------------------------------------------------
/client/src/components/Input/MarkdownEditor/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './MarkdownEditor'
2 |
--------------------------------------------------------------------------------
/client/src/components/Input/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Input'
2 | export { default as MarkdownEditor } from './MarkdownEditor'
3 |
--------------------------------------------------------------------------------
/client/src/components/List/List.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | const List = ({ children, title, ...otherProps }) => (
4 |
5 | {title &&
{title} }
6 |
{children}
7 |
8 | )
9 |
10 | List.propTypes = {
11 | children: PropTypes.node.isRequired,
12 | title: PropTypes.node,
13 | }
14 |
15 | export default List
16 |
--------------------------------------------------------------------------------
/client/src/components/List/List.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: List
3 | route: /components/list
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import List from './List.jsx'
9 | import ListItem from './ListItem.jsx'
10 |
11 | # List
12 |
13 | ```js
14 | import { List, ListItem } from 'components'
15 | ```
16 |
17 | ## Basic usage
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | ## Properties
29 |
30 | ### List
31 |
32 |
33 |
34 | ### ListItem
35 |
36 |
37 |
--------------------------------------------------------------------------------
/client/src/components/List/ListItem.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | const ListItem = ({ caption, icon, href }) => {
4 | const ret = (
5 |
6 | {icon && {icon} }
7 | {caption}
8 |
9 | )
10 |
11 | return href ? (
12 |
13 | {ret}
14 |
15 | ) : (
16 | ret
17 | )
18 | }
19 |
20 | ListItem.propTypes = {
21 | caption: PropTypes.node.isRequired,
22 | icon: PropTypes.string,
23 | href: PropTypes.string,
24 | }
25 |
26 | export default ListItem
27 |
--------------------------------------------------------------------------------
/client/src/components/List/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './List'
2 | export { default as ListItem } from './ListItem'
3 |
--------------------------------------------------------------------------------
/client/src/components/Loading/Loading.css:
--------------------------------------------------------------------------------
1 | .path {
2 | stroke-dasharray: 1, 200;
3 | stroke-dashoffset: 0;
4 | animation:
5 | dash 1.5s ease-in-out infinite,
6 | color 6s ease-in-out infinite;
7 | stroke-linecap: round;
8 | }
9 |
10 | @keyframes rotate {
11 | 100% {
12 | transform: rotate(360deg);
13 | }
14 | }
15 |
16 | @keyframes dash {
17 | 0% {
18 | stroke-dasharray: 1, 200;
19 | stroke-dashoffset: 0;
20 | }
21 | 50% {
22 | stroke-dasharray: 89, 200;
23 | stroke-dashoffset: -35px;
24 | }
25 | 100% {
26 | stroke-dasharray: 89, 200;
27 | stroke-dashoffset: -124px;
28 | }
29 | }
30 |
31 | @keyframes color {
32 | 100%,
33 | 0% {
34 | stroke: #d62d20;
35 | }
36 | 40% {
37 | stroke: #0057e7;
38 | }
39 | 66% {
40 | stroke: #008744;
41 | }
42 | 80%,
43 | 90% {
44 | stroke: #ffa700;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/client/src/components/Loading/Loading.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { getIntl } from 'services'
4 |
5 | import './Loading.css'
6 |
7 | const Loading = ({ text }) => {
8 | const intl = getIntl(Loading)
9 |
10 | return (
11 |
12 |
13 |
14 |
23 |
24 |
25 |
26 | {text || intl('loading')}
27 |
28 |
29 | )
30 | }
31 |
32 | Loading.propTypes = {
33 | text: PropTypes.string,
34 | }
35 |
36 | Loading.translations = {
37 | en: { loading: 'Loading...' },
38 | fr: { loading: 'Chargement...' },
39 | }
40 |
41 | export default Loading
42 |
--------------------------------------------------------------------------------
/client/src/components/Loading/Loading.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Loading
3 | route: /components/loading
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Loading from './Loading.jsx'
9 |
10 | # Loading
11 |
12 | ```js
13 | import { Loading } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 |
20 |
21 |
22 |
23 | ## Properties
24 |
25 |
26 |
--------------------------------------------------------------------------------
/client/src/components/Loading/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Loading'
2 | export { default as withLoading } from './withLoading'
3 | export { default as withError } from './withError'
4 |
--------------------------------------------------------------------------------
/client/src/components/Loading/withError.jsx:
--------------------------------------------------------------------------------
1 | /* eslint react/display-name: 0 react/prop-types: 0 */
2 |
3 | const withError =
4 | (text = 'Error :(') =>
5 | (Component) => {
6 | const withErrorWrapper = (props) => {
7 | if (props.error) return text !== false ? {text}
: null
8 |
9 | return
10 | }
11 |
12 | return withErrorWrapper
13 | }
14 |
15 | export default withError
16 |
--------------------------------------------------------------------------------
/client/src/components/Loading/withLoading.jsx:
--------------------------------------------------------------------------------
1 | /* eslint react/display-name: 0 react/prop-types: 0 */
2 |
3 | import Loading from './Loading'
4 |
5 | const withLoading = (text) => (Component) => {
6 | const withLoadingWrapper = (props) => {
7 | if (props.loading) return text !== false ? : null
8 |
9 | return
10 | }
11 |
12 | return withLoadingWrapper
13 | }
14 |
15 | export default withLoading
16 |
--------------------------------------------------------------------------------
/client/src/components/Modal/Modal.jsx:
--------------------------------------------------------------------------------
1 | import { useContext, createContext } from 'react'
2 |
3 | import { useClickOutside } from 'helpers'
4 |
5 | const ModalContext = createContext()
6 |
7 | const Modal = ({ active, setActive, loading, children }) => {
8 | const closeModal = () => {
9 | if (!loading) {
10 | setActive(false)
11 | }
12 | }
13 |
14 | const ref = useClickOutside(closeModal)
15 |
16 | return (
17 |
18 |
23 |
24 |
28 | {children}
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | const Title = ({ children }) => {
36 | const closeModal = useContext(ModalContext)
37 | return (
38 |
39 | {children}
40 |
44 |
45 | )
46 | }
47 |
48 | const Text = ({ children }) => (
49 |
50 | {children}
51 |
52 | )
53 |
54 | const Alert = ({ children }) => (
55 |
56 | {children}
57 |
58 | )
59 |
60 | Modal.Title = Title
61 | Modal.Text = Text
62 | Modal.Alert = Alert
63 |
64 | export default Modal
65 |
--------------------------------------------------------------------------------
/client/src/components/Modal/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Modal'
2 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/DefaultPagination.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import Button from '../Button'
4 |
5 | import Pagination from './Pagination'
6 |
7 | const renderPage = ({ index, isCurrent, onClick }) => {
8 | return (
9 |
17 | )
18 | }
19 |
20 | const renderEllipsis = ({ key }) =>
21 |
22 | const renderNav = ({ type, disabled, onClick }) => {
23 | return (
24 |
32 | )
33 | }
34 |
35 | const DefaultPagination = ({ nbPages, current, onPageSelected }) => (
36 |
45 | )
46 |
47 | DefaultPagination.propTypes = {
48 | nbPages: PropTypes.number,
49 | current: PropTypes.number,
50 | onPageSelected: PropTypes.func,
51 | }
52 |
53 | renderPage.propTypes = {
54 | index: PropTypes.number,
55 | isCurrent: PropTypes.bool,
56 | onClick: PropTypes.func,
57 | }
58 |
59 | renderEllipsis.propTypes = {
60 | key: PropTypes.string,
61 | }
62 |
63 | renderNav.propTypes = {
64 | type: PropTypes.string,
65 | disabled: PropTypes.bool,
66 | onClick: PropTypes.func,
67 | }
68 |
69 | export default DefaultPagination
70 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/Pagination.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import clamp from 'lodash/clamp'
3 |
4 | const filterPages = (pagesCount, current) =>
5 | [...Array(pagesCount)]
6 | .map((x, i) => i + 1)
7 | .reduce(
8 | (acc, i) =>
9 | i === 1 || i === pagesCount || (i >= current - 2 && i <= current + 2)
10 | ? [...acc, i]
11 | : [...acc, null],
12 | [],
13 | )
14 | .reduce((acc, x, index, arr) => {
15 | if (x == null) {
16 | if (arr[index + 1] != null) {
17 | return [...acc, '...']
18 | }
19 | return acc
20 | }
21 | return [...acc, x]
22 | }, [])
23 |
24 | const Pagination = ({
25 | nbPages,
26 | current,
27 | onPageSelected,
28 | renderPage,
29 | renderEllipsis,
30 | renderNav,
31 | ...rest
32 | }) => {
33 | nbPages = Math.max(nbPages, 1)
34 | current = clamp(current, 1, nbPages)
35 |
36 | const pages = filterPages(nbPages, current).map((index, i) => {
37 | if (index === '...') return renderEllipsis({ key: `ellipsis-${i}` })
38 |
39 | const isCurrent = index === current
40 | return renderPage({
41 | index,
42 | isCurrent,
43 | onClick: () => !isCurrent && onPageSelected(index),
44 | })
45 | })
46 |
47 | const previous = renderNav({
48 | type: 'previous',
49 | disabled: current === 1,
50 | onClick: () => current > 1 && onPageSelected(current - 1),
51 | })
52 |
53 | const next = renderNav({
54 | type: 'next',
55 | disabled: current === nbPages,
56 | onClick: () => current < nbPages && onPageSelected(current + 1),
57 | })
58 |
59 | return (
60 |
61 | {previous}
62 | {pages}
63 | {next}
64 |
65 | )
66 | }
67 |
68 | Pagination.propTypes = {
69 | nbPages: PropTypes.number,
70 | current: PropTypes.number,
71 | onPageSelected: PropTypes.func,
72 | renderPage: PropTypes.func,
73 | renderEllipsis: PropTypes.func,
74 | renderNav: PropTypes.func,
75 | }
76 |
77 | export default Pagination
78 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/Pagination.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Pagination
3 | route: /components/pagination
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import DefaultPagination from './DefaultPagination.jsx'
9 |
10 | # Pagination
11 |
12 | ```js
13 | import { DefaultPagination } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ## Properties
25 |
26 |
27 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Pagination'
2 | export { default as DefaultPagination } from './DefaultPagination'
3 | export { default as withPagination } from './withPagination'
4 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/withPagination.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { useEffect } from 'react'
3 |
4 | import { addToQueryString, unserialize } from 'helpers'
5 | import { useLocation } from 'react-router'
6 | import { useSearchParams } from 'react-router-dom'
7 |
8 | const withPagination =
9 | (options = { push: true }) =>
10 | (Component) => {
11 | const withPaginationWrapper = (props) => {
12 | const location = useLocation()
13 | const [, setSearchParams] = useSearchParams()
14 | const { loading, meta } = props
15 |
16 | const onMountOrUpdate = () => {
17 | const pagesCount = meta ? meta.pagesCount : 1
18 | const { page: currentPage } = unserialize(location.search)
19 |
20 | // If currentPage > pagesCount, redirect to last page
21 | if (!loading && pagesCount > 0 && currentPage > pagesCount) {
22 | addToQueryString(setSearchParams, location, { page: pagesCount }, { push: options.push })
23 | }
24 | }
25 |
26 | useEffect(() => {
27 | onMountOrUpdate()
28 | }, [location.search])
29 |
30 | const count = meta ? meta.entriesCount : 0
31 | const pagesCount = meta ? meta.pagesCount : 1
32 | const { page } = unserialize(location.search)
33 |
34 | return (
35 |
41 | addToQueryString(setSearchParams, location, { page }, { push: options.push })
42 | }
43 | />
44 | )
45 | }
46 |
47 | withPaginationWrapper.propTypes = {
48 | location: PropTypes.object,
49 | loading: PropTypes.bool,
50 | meta: PropTypes.object,
51 | }
52 |
53 | return withPaginationWrapper
54 | }
55 |
56 | export default withPagination
57 |
--------------------------------------------------------------------------------
/client/src/components/PairInputList/PairInput.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { Input, Button } from 'components'
4 |
5 | const PairInput = ({ pair, options, actions, disabled }) => {
6 | const { key, value } = pair
7 | return (
8 |
38 | )
39 | }
40 |
41 | PairInput.propTypes = {
42 | pair: PropTypes.object.isRequired,
43 | options: PropTypes.object.isRequired,
44 | actions: PropTypes.object.isRequired,
45 | disabled: PropTypes.bool,
46 | }
47 |
48 | export default PairInput
49 |
--------------------------------------------------------------------------------
/client/src/components/PairInputList/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './PairInputList'
2 |
--------------------------------------------------------------------------------
/client/src/components/PrivateRoute/PrivateRoute.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Outlet } from 'react-router-dom'
3 |
4 | import Authenticated from 'components/Authenticated'
5 |
6 | const PrivateRoute = ({ element, render, admin, specialist, ...otherProps }) => {
7 | return (
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | PrivateRoute.propTypes = {
15 | component: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
16 | render: PropTypes.func,
17 | admin: PropTypes.bool,
18 | }
19 |
20 | export default PrivateRoute
21 |
--------------------------------------------------------------------------------
/client/src/components/PrivateRoute/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './PrivateRoute'
2 |
--------------------------------------------------------------------------------
/client/src/components/Radio/Radio.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import cn from 'classnames'
3 |
4 | const RadioContext = React.createContext()
5 |
6 | const Radio = ({ label, value, disabled, className, ...props }) => {
7 | const { name, selected, onChange, disabled: groupDisabled } = useContext(RadioContext)
8 |
9 | return (
10 |
17 | onChange(value)}
22 | id={`radio-${name}-${value}`}
23 | disabled={disabled || groupDisabled}
24 | />
25 |
26 | {label}
27 |
28 |
29 | )
30 | }
31 |
32 | Radio.Group = function RadioGroup({
33 | name,
34 | selected,
35 | onChange,
36 | disabled,
37 | className,
38 | children,
39 | ...props
40 | }) {
41 | return (
42 |
43 |
44 | {children}
45 |
46 |
47 | )
48 | }
49 |
50 | export default Radio
51 |
--------------------------------------------------------------------------------
/client/src/components/Radio/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Radio'
2 |
--------------------------------------------------------------------------------
/client/src/components/Tabs/Tab.jsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import { useContext, useEffect } from 'react'
3 |
4 | import { TabsContext } from './Tabs'
5 |
6 | const Tab = ({ label, children, className, ...rest }) => {
7 | const [current, register] = useContext(TabsContext)
8 |
9 | useEffect(() => {
10 | register(label)
11 | }, [label, register])
12 |
13 | return (
14 |
21 | {children}
22 |
23 | )
24 | }
25 |
26 | export default Tab
27 |
--------------------------------------------------------------------------------
/client/src/components/Tabs/Tabs.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from 'react'
2 | import cn from 'classnames'
3 |
4 | export const TabsContext = React.createContext()
5 |
6 | const Tabs = ({ children, className, ...rest }) => {
7 | const [current, setCurrent] = useState(null)
8 | const [tabs, setTabs] = useState([])
9 |
10 | const register = useMemo(
11 | () => (tab) => {
12 | setTabs((tabs) => [...tabs, tab])
13 | },
14 | [setTabs],
15 | )
16 |
17 | const active = current || tabs[0]
18 |
19 | const value = useMemo(() => [active, register], [active, register])
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | {tabs.map((label) => (
27 | setCurrent(label)}
30 | className={cn('block', {
31 | '[&_span]:bg-primary-font [&_span]:border-[#dbdbdb] [&_span]:border-b-secondary-light [&_span]:text-primary':
32 | label === active,
33 | })}
34 | >
35 |
36 | {label}
37 |
38 |
39 | ))}
40 |
41 |
42 |
{children}
43 |
44 |
45 | )
46 | }
47 |
48 | export default Tabs
49 |
--------------------------------------------------------------------------------
/client/src/components/Tabs/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Tabs'
2 | export { default as Tab } from './Tab'
3 |
--------------------------------------------------------------------------------
/client/src/components/TagPicker/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './TagPicker'
2 |
--------------------------------------------------------------------------------
/client/src/components/Tags/Tags.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | const Tags = ({ tags, ...rest }) => (
4 |
5 |
local_offer
6 |
7 | {tags.map((tag) => tag.label.name.toLowerCase()).join(', ')}
8 |
9 |
10 | )
11 |
12 | Tags.propTypes = {
13 | tags: PropTypes.array.isRequired,
14 | }
15 |
16 | export default Tags
17 |
--------------------------------------------------------------------------------
/client/src/components/Tags/Tags.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Tags
3 | route: /components/tags
4 | menu: Components
5 | ---
6 |
7 | import { Playground, PropsTable } from 'docz'
8 | import Tags from './Tags'
9 |
10 | # Tags
11 |
12 | ```js
13 | import { Tags } from 'components'
14 | ```
15 |
16 | ## Basic usage
17 |
18 |
19 |
27 |
28 |
29 | ## Properties
30 |
31 |
32 |
--------------------------------------------------------------------------------
/client/src/components/Tags/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Tags'
2 |
--------------------------------------------------------------------------------
/client/src/components/Tips/Tips.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { CardText, PermanentClosableCard } from 'components/Card'
4 |
5 | const Tips = (props) => {
6 | const { uid, children, ...otherProps } = props
7 | return (
8 |
9 | {props.children}
10 |
11 | )
12 | }
13 |
14 | Tips.propTypes = {
15 | uid: PropTypes.string.isRequired,
16 | children: PropTypes.node.isRequired,
17 | }
18 |
19 | export default Tips
20 |
--------------------------------------------------------------------------------
/client/src/components/Tips/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Tips'
2 |
--------------------------------------------------------------------------------
/client/src/components/UsersList/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './UsersList'
2 |
--------------------------------------------------------------------------------
/client/src/components/UsersList/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const GET_TAG_CATEGORIES = gql`
4 | query {
5 | configuration {
6 | id
7 | tagCategories {
8 | id
9 | name
10 | labels {
11 | id
12 | name
13 | }
14 | }
15 | }
16 | }
17 | `
18 |
19 | export const GET_USERS = gql`
20 | query {
21 | users {
22 | id
23 | name
24 | email
25 | admin
26 | specialties {
27 | id
28 | name
29 | }
30 | }
31 | }
32 | `
33 |
34 | export const UPDATE_SPECIALTIES = gql`
35 | mutation updateSpecialties($id: ID!, $specialties: [SpecialtiesInput]!) {
36 | updateSpecialties(id: $id, specialties: $specialties) {
37 | id
38 | }
39 | }
40 | `
41 |
--------------------------------------------------------------------------------
/client/src/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as AlertStack, AlertProvider, AlertContext } from './AlertStack'
2 | export { default as Authenticated } from './Authenticated'
3 | export { default as Avatar } from './Avatar'
4 | export { default as Button } from './Button'
5 | export { default as Card } from './Card'
6 | export { default as CtrlEnter } from './CtrlEnter'
7 | export { default as Dropdown } from './Dropdown'
8 | export { default as ErrorBoundary } from './ErrorBoundary'
9 | export { default as Flags } from './Flags'
10 | export { default as Icon } from './Icon'
11 | export { default as Input, MarkdownEditor } from './Input'
12 | export { default as List } from './List'
13 | export { default as Loading, withLoading, withError } from './Loading'
14 | export { default as Modal } from './Modal'
15 | export { default as Pagination, DefaultPagination, withPagination } from './Pagination'
16 | export { default as PairInputList } from './PairInputList'
17 | export { default as PrivateRoute } from './PrivateRoute'
18 | export { default as Radio } from './Radio'
19 | export { default as Tabs, Tab } from './Tabs'
20 | export { default as TagPicker } from './TagPicker'
21 | export { default as Tags } from './Tags'
22 | export { default as Tips } from './Tips'
23 | export { default as UsersList } from './UsersList'
24 |
--------------------------------------------------------------------------------
/client/src/contexts/Auth/hooks.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 |
3 | import { AuthContext } from './AuthProvider'
4 |
5 | export const useAuth = () => {
6 | const [auth, actions] = useContext(AuthContext)
7 |
8 | const isAuth = !!(auth.session && auth.session.expiresAt > new Date().getTime() && auth.user)
9 | const wasAuth = !!(auth.session && auth.session.expiresAt < new Date().getTime() && auth.user)
10 | const isAdmin = isAuth && auth.user && auth.user.admin
11 | const isSpecialist = isAuth && auth.user && (auth.user.specialties?.length > 0 ?? false)
12 |
13 | return { ready: auth.ready, isAuth, wasAuth, isAdmin, isSpecialist, ...actions }
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/contexts/Auth/index.js:
--------------------------------------------------------------------------------
1 | export { default as AuthProvider, AuthContext } from './AuthProvider'
2 | export * from './hooks'
3 |
--------------------------------------------------------------------------------
/client/src/contexts/Auth/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const AUTHENTICATE_MUTATION = gql`
4 | mutation ($idToken: String!) {
5 | authenticate(idToken: $idToken) {
6 | id
7 | auth0Id
8 | admin
9 | name
10 | email
11 | picture
12 | }
13 | }
14 | `
15 |
--------------------------------------------------------------------------------
/client/src/contexts/Configuration/ConfigurationProvider.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useMemo } from 'react'
2 |
3 | import { safeFetch } from 'helpers'
4 |
5 | export const ConfigurationContext = React.createContext()
6 |
7 | const ConfigurationProvider = ({ children }) => {
8 | const [reload, setReload] = useState(0)
9 | const [configuration, setConfiguration] = useState(() => {
10 | // Retrieve cached configuration in local storage
11 | if (localStorage.configuration) {
12 | return {
13 | ...JSON.parse(localStorage.configuration),
14 | loading: false,
15 | }
16 | }
17 | return {
18 | loading: true,
19 | }
20 | })
21 |
22 | // Retrieve configuration from server
23 | useEffect(() => {
24 | safeFetch('configuration').then((conf) => {
25 | localStorage.configuration = JSON.stringify(conf)
26 | setConfiguration({ ...conf, loading: false })
27 | })
28 | }, [reload])
29 |
30 | // Provide reload function when editing configuration
31 | const value = useMemo(
32 | () => ({ ...configuration, reload: () => setReload((state) => state + 1) }),
33 | [configuration],
34 | )
35 |
36 | return {children}
37 | }
38 |
39 | export default ConfigurationProvider
40 |
--------------------------------------------------------------------------------
/client/src/contexts/Configuration/hooks.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 |
3 | import { ConfigurationContext } from './ConfigurationProvider'
4 |
5 | export const useConfiguration = () => useContext(ConfigurationContext)
6 |
--------------------------------------------------------------------------------
/client/src/contexts/Configuration/index.js:
--------------------------------------------------------------------------------
1 | export { default as ConfigurationProvider, ConfigurationContext } from './ConfigurationProvider'
2 | export * from './hooks'
3 |
--------------------------------------------------------------------------------
/client/src/contexts/User/UserProvider.jsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client'
2 | import React, { useMemo } from 'react'
3 |
4 | import { useAuth } from '../Auth'
5 |
6 | import { GET_ME } from './queries'
7 |
8 | export const UserContext = React.createContext({})
9 |
10 | const UserProvider = ({ children }) => {
11 | const { isAuth } = useAuth()
12 |
13 | const { data } = useQuery(GET_ME, {
14 | skip: !isAuth,
15 | })
16 |
17 | const value = useMemo(() => data && data.me, [data])
18 |
19 | return {children}
20 | }
21 |
22 | export default UserProvider
23 |
--------------------------------------------------------------------------------
/client/src/contexts/User/hooks.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 |
3 | import { UserContext } from './UserProvider'
4 |
5 | export const useUser = () => useContext(UserContext)
6 |
--------------------------------------------------------------------------------
/client/src/contexts/User/index.js:
--------------------------------------------------------------------------------
1 | export { default as UserProvider, UserContext } from './UserProvider'
2 | export * from './hooks'
3 |
--------------------------------------------------------------------------------
/client/src/contexts/User/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const GET_ME = gql`
4 | query {
5 | me {
6 | id
7 | auth0Id
8 | name
9 | email
10 | picture
11 | specialties {
12 | id
13 | name
14 | }
15 | }
16 | }
17 | `
18 |
--------------------------------------------------------------------------------
/client/src/contexts/index.js:
--------------------------------------------------------------------------------
1 | export * from './Auth'
2 | export * from './Configuration'
3 | export * from './User'
4 |
--------------------------------------------------------------------------------
/client/src/helpers/answerCanBeCertified.js:
--------------------------------------------------------------------------------
1 | const answerCanBeCertified = (specialties, tags, answer, flagTypes) => {
2 | let isCertified = false
3 |
4 | if (answer) {
5 | tags.forEach((tag) => {
6 | specialties.forEach((specialty) => {
7 | if (tag.label.name.includes(specialty.name) && !isCertified) {
8 | flagTypes.push('certified')
9 | isCertified = true
10 | }
11 | })
12 | })
13 | }
14 | }
15 |
16 | export default answerCanBeCertified
17 |
--------------------------------------------------------------------------------
/client/src/helpers/compose.js:
--------------------------------------------------------------------------------
1 | const compose = (...funcs) =>
2 | funcs.reduce(
3 | (a, b) =>
4 | (...args) =>
5 | a(b(...args)),
6 | (arg) => arg,
7 | )
8 |
9 | export default compose
10 |
--------------------------------------------------------------------------------
/client/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export { default as answerCanBeCertified } from './answerCanBeCertified'
2 | export { default as compose } from './compose'
3 | export * from './history'
4 | export * from './isUuid'
5 | export { default as onListChange } from './onListChange'
6 | export * from './prompt'
7 | export * from './question'
8 | export { default as safeFetch } from './safeFetch'
9 | export * from './serialize'
10 | export * from './translation'
11 | export { default as useClickOutside } from './useClickOutside'
12 |
--------------------------------------------------------------------------------
/client/src/helpers/isUuid.js:
--------------------------------------------------------------------------------
1 | export const isUuidV4 = (str) =>
2 | !!str.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
3 |
--------------------------------------------------------------------------------
/client/src/helpers/onListChange.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid/v4'
2 |
3 | const onListChange = (setState, name) => {
4 | const actionBuilder = (action) => (item) =>
5 | setState((state) => {
6 | let list = state[name]
7 | switch (action) {
8 | case 'create':
9 | list = [...list, { id: uuid(), key: '', value: '' }]
10 | break
11 | case 'update':
12 | list = list.map((x) => (x.id === item.id ? item : x))
13 | break
14 | case 'delete':
15 | list = list.filter((x) => x.id !== item.id)
16 | break
17 | default:
18 | return
19 | }
20 | return { [name]: list }
21 | })
22 |
23 | actionBuilder.actions = {
24 | create: actionBuilder('create'),
25 | update: actionBuilder('update'),
26 | delete: actionBuilder('delete'),
27 | }
28 |
29 | return actionBuilder
30 | }
31 |
32 | export const onListChangeReducer = (prefix) => (state, action) => {
33 | switch (action.type) {
34 | case `${prefix}_create`:
35 | return [...state, { id: uuid(), key: '', value: '' }]
36 | case `${prefix}_update`:
37 | return state.map((x) => (x.id === action.data.id ? action.data : x))
38 | case `${prefix}_delete`:
39 | return state.filter((x) => x.id !== action.data.id)
40 | default:
41 | return state
42 | }
43 | }
44 |
45 | export const onListChangeActions = (prefix, dispatch) => ({
46 | create: (data) => dispatch({ type: `${prefix}_create`, data }),
47 | update: (data) => dispatch({ type: `${prefix}_update`, data }),
48 | delete: (data) => dispatch({ type: `${prefix}_delete`, data }),
49 | })
50 |
51 | export default onListChange
52 |
--------------------------------------------------------------------------------
/client/src/helpers/prompt.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react'
2 | import { useBeforeUnload, unstable_useBlocker as useBlocker } from 'react-router-dom'
3 |
4 | const usePrompt = (message, { beforeUnload } = {}) => {
5 | let blocker = useBlocker(
6 | useCallback(() => (typeof message === 'string' ? !window.confirm(message) : false), [message]),
7 | )
8 | let prevState = useRef(blocker.state)
9 | useEffect(() => {
10 | if (blocker.state === 'blocked') {
11 | blocker.reset()
12 | }
13 | prevState.current = blocker.state
14 | }, [blocker])
15 |
16 | useBeforeUnload(
17 | useCallback(
18 | (event) => {
19 | if (beforeUnload && typeof message === 'string') {
20 | event.preventDefault()
21 | event.returnValue = message
22 | }
23 | },
24 | [message, beforeUnload],
25 | ),
26 | { capture: true },
27 | )
28 | }
29 |
30 | export const Prompt = ({ when, message, ...props }) => {
31 | usePrompt(when ? message : false, props)
32 | return null
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/helpers/question.js:
--------------------------------------------------------------------------------
1 | export const nodeUrl = ({ id, question }) => `/q/${question.slug}-${id}`
2 |
--------------------------------------------------------------------------------
/client/src/helpers/safeFetch.js:
--------------------------------------------------------------------------------
1 | import routing from 'services/routing'
2 |
3 | export default async function safeFetch(path) {
4 | const response = await fetch((import.meta.env.VITE_REST_ENDPOINT || '/rest') + '/' + path, {
5 | headers: { 'prisma-service': routing.getPrismaService() },
6 | })
7 |
8 | if (!response.ok) {
9 | throw new Error(
10 | `Error response from server while retrieving ${path}: HTTP status ${
11 | response.status
12 | } : ${await response.text()}`,
13 | )
14 | }
15 |
16 | return response.json()
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/helpers/serialize.js:
--------------------------------------------------------------------------------
1 | const parseQueryString = (text) =>
2 | text
3 | ? decodeURI(text)
4 | .substr(1)
5 | .split('&')
6 | .map((s) => s.split('='))
7 | .reduce((obj, [k, v]) => ({ ...obj, [k]: v }), {})
8 | : {}
9 | const stringifyQueryString = (params) =>
10 | '?' +
11 | Object.entries(params)
12 | .map((p) => p.join('='))
13 | .join('&')
14 |
15 | export const serialize = ({ q, tags, flags, page }) => {
16 | const params = {}
17 |
18 | if (q) params.q = q.trim().replace(/\s/g, '+').replace(/%/g, '%25')
19 | if (tags && tags.length > 0) params.tags = tags.join('+').replace(/\s/g, '-')
20 | if (flags && flags.length > 0) {
21 | params.flags = tags.join('+').replace(/\s/g, '-')
22 | }
23 | if (page && page > 1) params.page = page
24 |
25 | return stringifyQueryString(params)
26 | }
27 |
28 | export const unserialize = (queryString) => {
29 | const { q, tags, flags, page } = parseQueryString(queryString)
30 | return {
31 | q: q ? q.replace(/\+/g, ' ').trim() : '',
32 | tags: tags ? tags.replace(/-/g, ' ').split('+') : [],
33 | flags: flags ? flags.replace(/-/g, ' ').split('+') : [],
34 | page: page > 1 ? +page : 1,
35 | }
36 | }
37 |
38 | export const addToQueryString = (
39 | setSearchParams,
40 | location,
41 | addedParams,
42 | options = { push: true },
43 | ) => {
44 | const params = unserialize(location.search)
45 |
46 | const qs = serialize({
47 | ...params,
48 | ...addedParams,
49 | })
50 |
51 | setSearchParams(qs, { replace: !options.push })
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/helpers/translation.js:
--------------------------------------------------------------------------------
1 | export const handleTranslation = (targetLanguage, node) => {
2 | let content = { question: '', answer: '', isTranslation: false, language: targetLanguage }
3 | if (node.question.translation?.language === targetLanguage && node.question.translation?.text) {
4 | content = { ...content, question: node.question.translation.text, isTranslation: true }
5 | } else {
6 | content = { ...content, question: node.question.title }
7 | }
8 | if (node.answer) {
9 | if (node.answer.translation?.language === targetLanguage && node.answer.translation?.text) {
10 | content = { ...content, answer: node.answer.translation.text, isTranslated: true }
11 | } else {
12 | content = { ...content, answer: node.answer.content }
13 | }
14 | }
15 | return content
16 | }
17 |
18 | export const getNavigatorLanguage = () => {
19 | const language = navigator?.language ?? 'en'
20 | const [formattedLanguage] = language.split('-')
21 | if (formattedLanguage !== 'fr' && formattedLanguage !== 'en') {
22 | return formattedLanguage === 'en'
23 | }
24 | return formattedLanguage
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/helpers/useClickOutside.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const useClickOutside = (onClickOutside) => {
4 | const ref = useRef(null)
5 |
6 | const handleClickOutside = (event) => {
7 | if (ref.current && !ref.current.contains(event.target)) {
8 | onClickOutside(event)
9 | }
10 | }
11 |
12 | useEffect(() => {
13 | document.addEventListener('click', handleClickOutside, true)
14 | return () => {
15 | document.removeEventListener('click', handleClickOutside, true)
16 | }
17 | })
18 |
19 | return ref
20 | }
21 |
22 | export default useClickOutside
23 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --font-size: 14px;
8 | --h1-font-size: 1.3rem;
9 | --h2-font-size: 1.1rem;
10 | --small-font-size: 0.7rem;
11 | --primary: #af1e3a;
12 | --secondary-font: #494949;
13 | }
14 |
15 | html,
16 | body {
17 | font-family: 'Lato', sans-serif;
18 | height: 100%;
19 | }
20 |
21 | #root {
22 | height: 100%;
23 | }
24 |
25 | h1 {
26 | font-size: var(--h1-font-size);
27 | font-weight: 700;
28 | }
29 |
30 | h2 {
31 | font-size: var(--h2-font-size);
32 | }
33 |
34 | h3 {
35 | font-size: var(--font-size);
36 | font-weight: bold;
37 | }
38 |
39 | hr {
40 | border: 0;
41 | border-top: 1px dotted #8c8b8b;
42 | }
43 |
44 | a {
45 | text-decoration: none;
46 | color: var(--primary);
47 | }
48 |
49 | a.discret {
50 | text-decoration: none !important;
51 | color: var(--secondary-font) !important;
52 | }
53 | }
54 |
55 | @layer components {
56 | .main > div {
57 | max-width: 820px;
58 | margin-left: auto;
59 | margin-right: auto;
60 | margin-top: 0.5rem;
61 | padding-left: 10px;
62 | padding-right: 10px;
63 | position: relative;
64 | }
65 |
66 | .card-text.collapsed {
67 | height: 0;
68 | padding: 0;
69 | overflow: hidden;
70 | }
71 |
72 | .card-text a:not(.btn-container) {
73 | text-decoration: underline;
74 | }
75 |
76 | .card-text img {
77 | max-width: 100%;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/client/src/index.jsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import {
3 | Route,
4 | RouterProvider,
5 | createBrowserRouter,
6 | createRoutesFromElements,
7 | } from 'react-router-dom'
8 | import './index.css'
9 | import 'styles/tooltip.css'
10 |
11 | import { PrivateRoute } from 'components'
12 | import App from 'scenes/App/App'
13 | import Auth from 'scenes/Auth/Auth'
14 | import Home from 'scenes/Home/Home'
15 | import NotFound from 'scenes/NotFound/NotFound'
16 | import QuestionRoutes from 'scenes/Question/QuestionRoutes'
17 | import Settings from 'scenes/Settings/Settings'
18 | import UserProfile from 'scenes/UserProfile/UserProfile'
19 | import ApolloWrapper from 'services/apollo'
20 |
21 | const router = createBrowserRouter(
22 | createRoutesFromElements(
23 | }>
24 | } />
25 | }>
26 | } />
27 | } />
28 | } />
29 | } admin specialist />
30 |
31 | } />
32 | ,
33 | ),
34 | )
35 |
36 | ReactDOM.createRoot(document.getElementById('root')).render(
37 |
38 |
39 | ,
40 | )
41 |
--------------------------------------------------------------------------------
/client/src/scenes/App/App.jsx:
--------------------------------------------------------------------------------
1 | // import 'styles'
2 |
3 | import { AlertProvider, AlertStack, ErrorBoundary } from 'components'
4 | import { AuthProvider, ConfigurationProvider, UserProvider } from 'contexts'
5 | import { setDefaultOptions } from 'date-fns'
6 | import { enUS, fr } from 'date-fns/locale'
7 | import { getNavigatorLanguage } from 'helpers'
8 | import { Helmet } from 'react-helmet'
9 | import { Outlet } from 'react-router'
10 |
11 | import Footer from './components/Footer'
12 | import Navbar from './components/Navbar'
13 |
14 | setDefaultOptions({
15 | locale: getNavigatorLanguage() === 'en' ? enUS : fr,
16 | })
17 |
18 | const App = () => {
19 | return (
20 |
21 |
22 | FAQ
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | export default App
45 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Footer/Footer.jsx:
--------------------------------------------------------------------------------
1 | const Footer = () => (
2 |
13 | )
14 |
15 | export default Footer
16 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Footer'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/components/GithubIcon/GithubIcon.jsx:
--------------------------------------------------------------------------------
1 | const GithubIcon = (props) => (
2 |
3 |
4 |
5 |
6 |
7 | )
8 |
9 | export default GithubIcon
10 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/components/GithubIcon/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './GithubIcon'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/components/UserMenu/UserMenu.jsx:
--------------------------------------------------------------------------------
1 | import { getIntl } from 'services'
2 | import { useUser, useConfiguration } from 'contexts'
3 |
4 | import { Authenticated, Avatar } from 'components'
5 | import Dropdown, { DropdownItem, DropdownDivider } from 'components/Dropdown'
6 |
7 | import GithubIcon from '../GithubIcon'
8 |
9 | const UserMenu = () => {
10 | const intl = getIntl(UserMenu)
11 |
12 | const me = useUser()
13 | const conf = useConfiguration()
14 |
15 | if (!me) return null
16 |
17 | return (
18 | }>
19 |
20 | {intl('profile')}
21 |
22 |
23 |
24 | {intl('settings')}
25 |
26 |
27 |
28 | }
30 | href="https://github.com/zenika-open-source/FAQ"
31 | target="_blank"
32 | >
33 | {intl('github')}
34 |
35 |
44 | {intl('bug_report')}
45 |
46 |
47 |
48 | {intl('sign_out')}
49 |
50 |
51 | )
52 | }
53 |
54 | UserMenu.translations = {
55 | en: {
56 | profile: 'Profile',
57 | settings: 'Settings',
58 | github: 'Github',
59 | bug_report: 'Bug report',
60 | sign_out: 'Sign out',
61 | },
62 | fr: {
63 | profile: 'Profil',
64 | settings: 'Paramètres',
65 | github: 'Github',
66 | bug_report: 'Signaler un bug',
67 | sign_out: 'Deconnexion',
68 | },
69 | }
70 |
71 | export default UserMenu
72 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/components/UserMenu/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './UserMenu'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as GithubIcon } from './GithubIcon'
2 | export { default as UserMenu } from './UserMenu'
3 |
--------------------------------------------------------------------------------
/client/src/scenes/App/components/Navbar/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Navbar'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/App/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './App'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Auth/Auth.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Route, Routes } from 'react-router-dom'
2 |
3 | import Callback from './Callback'
4 | import Login from './Login'
5 | import Logout from './Logout'
6 |
7 | const Auth = () => {
8 | return (
9 |
10 | } />
11 | } />
12 | } />
13 | } />
14 |
15 | )
16 | }
17 |
18 | export default Auth
19 |
--------------------------------------------------------------------------------
/client/src/scenes/Auth/Callback.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | import { useAuth } from 'contexts'
4 | import { getIntl } from 'services'
5 |
6 | import { Loading } from 'components'
7 | import { useLocation } from 'react-router'
8 |
9 | const Callback = () => {
10 | const intl = getIntl(Callback)
11 | const location = useLocation()
12 |
13 | const { parseHash } = useAuth()
14 |
15 | useEffect(() => {
16 | parseHash(location.hash)
17 | }, [parseHash, location.hash])
18 |
19 | return
20 | }
21 |
22 | Callback.translations = {
23 | en: { loading: 'Authenticating...' },
24 | fr: { loading: 'Authentification en cours...' },
25 | }
26 |
27 | export default Callback
28 |
--------------------------------------------------------------------------------
/client/src/scenes/Auth/Login.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Navigate, useLocation } from 'react-router-dom'
3 |
4 | import { useAuth } from 'contexts'
5 | import { getIntl } from 'services'
6 |
7 | import { Button, Loading } from 'components'
8 |
9 | const Login = () => {
10 | const intl = getIntl(Login)
11 | const location = useLocation()
12 |
13 | const [renewing, setRenewing] = useState(false)
14 | const redirectedFrom = location.state && location.state.from
15 |
16 | const { login, renewAuth, isAuth, wasAuth } = useAuth()
17 |
18 | if (isAuth) {
19 | return
20 | }
21 |
22 | if (wasAuth) {
23 | if (!renewing) {
24 | renewAuth(redirectedFrom)
25 | setRenewing(true)
26 | }
27 |
28 | return
29 | }
30 |
31 | return (
32 |
33 |
{intl('welcome')}
34 |
{intl('text')}
35 |
login(redirectedFrom)}
39 | intent="primary"
40 | action="raised"
41 | size="large"
42 | />
43 |
44 | )
45 | }
46 |
47 | Login.translations = {
48 | en: {
49 | loading: 'Authenticating...',
50 | welcome: 'Welcome',
51 | text: 'Please sign in to access the FAQ',
52 | sign_in: 'Sign in',
53 | },
54 | fr: {
55 | loading: 'Authentification en cours...',
56 | welcome: 'Bienvenue',
57 | text: 'Connectez-vous pour accéder à la FAQ',
58 | sign_in: 'Se connecter',
59 | },
60 | }
61 |
62 | export default Login
63 |
--------------------------------------------------------------------------------
/client/src/scenes/Auth/Logout.jsx:
--------------------------------------------------------------------------------
1 | import { Loading } from 'components'
2 | import { useAuth } from 'contexts'
3 | import { useEffect } from 'react'
4 |
5 | const Logout = () => {
6 | const { logout } = useAuth()
7 |
8 | useEffect(() => {
9 | logout()
10 | }, [logout])
11 |
12 | return
13 | }
14 |
15 | export default Logout
16 |
--------------------------------------------------------------------------------
/client/src/scenes/Auth/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Auth'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/NoResults/NoResults.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Link } from 'react-router-dom'
3 |
4 | import { getIntl } from 'services'
5 | import Button from 'components/Button'
6 |
7 | const NoResults = ({ prefill }) => {
8 | const intl = getIntl(NoResults)
9 |
10 | return (
11 |
12 |
13 | {intl('nothing')}
14 | sms_failed
15 |
16 |
17 |
18 |
25 |
26 |
27 | )
28 | }
29 |
30 | NoResults.propTypes = {
31 | prefill: PropTypes.string.isRequired,
32 | }
33 |
34 | NoResults.translations = {
35 | en: { nothing: 'Nothing found', ask_question: 'Ask the question!' },
36 | fr: { nothing: 'Aucune question trouvée', ask_question: 'Pose la question !' },
37 | }
38 |
39 | export default NoResults
40 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/NoResults/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './NoResults'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/Result/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Result'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/ResultList/ResultList.container.js:
--------------------------------------------------------------------------------
1 | import { withError, withPagination } from 'components'
2 | import { compose, unserialize } from 'helpers'
3 | import { query } from 'services/apollo'
4 |
5 | import { SEARCH_NODES } from '../../queries'
6 | import ResultList from './ResultList'
7 | import { useLocation } from 'react-router'
8 |
9 | const RESULTS_PER_PAGE = 10
10 |
11 | export default compose(
12 | query(SEARCH_NODES, {
13 | variables: (props) => {
14 | const location = useLocation()
15 | const { tags, flags, page } = unserialize(location.search)
16 | return {
17 | text: props.searchText,
18 | tags,
19 | flags,
20 | first: RESULTS_PER_PAGE,
21 | skip: RESULTS_PER_PAGE * (page - 1),
22 | }
23 | },
24 | parse: ({ search = {} }) => ({ ...search }),
25 | }),
26 | withPagination(),
27 | withError(),
28 | )(ResultList)
29 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/ResultList/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ResultList.container'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/Searchbar/Searchbar.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import cn from 'classnames'
3 |
4 | import { getIntl } from 'services'
5 | import { Input, TagPicker } from 'components'
6 |
7 | import { useConfiguration } from 'contexts'
8 |
9 | const Searchbar = ({ text, tags, loading, onTextChange, onTagsChange }) => {
10 | const intl = getIntl(Searchbar)
11 | const conf = useConfiguration()
12 |
13 | const tagLabels = tags
14 | .map((tag) =>
15 | conf.tagCategories
16 | .reduce((acc, cat) => acc.concat(cat.labels), [])
17 | .find((label) => label.name === tag),
18 | )
19 | .filter((l) => l)
20 |
21 | return (
22 |
23 |
26 |
this.input.focus()}
29 | >
30 | search
31 |
32 |
33 |
34 |
35 | >
36 | }
37 | value={text}
38 | onChange={(e) => onTextChange(e.target.value)}
39 | onClear={() => onTextChange('')}
40 | />
41 |
42 |
48 |
49 |
50 | )
51 | }
52 |
53 | Searchbar.propTypes = {
54 | text: PropTypes.string,
55 | tags: PropTypes.array,
56 | loading: PropTypes.bool,
57 | onTextChange: PropTypes.func,
58 | onTagsChange: PropTypes.func,
59 | }
60 |
61 | Searchbar.translations = {
62 | en: {
63 | filter: {
64 | tags: 'Filter by tags:',
65 | },
66 | },
67 | fr: {
68 | filter: {
69 | tags: 'Filtrer par tags:',
70 | },
71 | },
72 | }
73 |
74 | export default Searchbar
75 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/Searchbar/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Searchbar'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as NoResults } from './NoResults'
2 | export { default as Result } from './Result'
3 | export { default as ResultList } from './ResultList'
4 | export { default as Searchbar } from './Searchbar'
5 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Home'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Home/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const SEARCH_NODES = gql`
4 | query ($text: String, $tags: [String!], $flags: [String!], $first: Int!, $skip: Int!) {
5 | search(
6 | text: $text
7 | tags: $tags
8 | flags: $flags
9 | orderBy: createdAt_DESC
10 | first: $first
11 | skip: $skip
12 | ) {
13 | nodes {
14 | id
15 | question {
16 | id
17 | title
18 | slug
19 | createdAt
20 | language
21 | translation {
22 | text
23 | language
24 | }
25 | }
26 | answer {
27 | id
28 | content
29 | language
30 | translation {
31 | text
32 | language
33 | }
34 | }
35 | flags {
36 | id
37 | type
38 | }
39 | tags {
40 | id
41 | label {
42 | id
43 | name
44 | }
45 | }
46 | highlights
47 | }
48 | meta {
49 | entriesCount
50 | pagesCount
51 | pageCurrent
52 | }
53 | }
54 | }
55 | `
56 |
--------------------------------------------------------------------------------
/client/src/scenes/NotFound/NotFound.jsx:
--------------------------------------------------------------------------------
1 | import Button from 'components/Button'
2 | import { useNavigate } from 'react-router'
3 | import { getIntl } from 'services'
4 |
5 | const NotFound = () => {
6 | const navigate = useNavigate()
7 | const intl = getIntl(NotFound)
8 |
9 | return (
10 |
11 |
navigate('/')} />
12 |
13 |
{intl('title')}
14 |
15 |
16 |
17 |
18 |
19 |
{intl('subtitle')}
20 |
21 |
22 | )
23 | }
24 |
25 | NotFound.translations = {
26 | en: { title: 'Ooops! 404', subtitle: "Looks like we couldn't find what you were looking for..." },
27 | fr: {
28 | title: 'Ouups ! 404',
29 | subtitle: "Il semblerait que nous n'ayons pas trouvé ce que vous cherchiez...",
30 | },
31 | }
32 |
33 | export default NotFound
34 |
--------------------------------------------------------------------------------
/client/src/scenes/NotFound/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './NotFound'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/Question.jsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client'
2 | import { Loading } from 'components'
3 | import { Navigate, Route, Routes, useParams } from 'react-router'
4 |
5 | import { GET_NODE } from './queries'
6 | import Answer from './scenes/Answer/Answer'
7 | import Edit from './scenes/Edit/Edit'
8 | import Read from './scenes/Read/Read'
9 |
10 | const Question = () => {
11 | const params = useParams()
12 | const { data, loading } = useQuery(GET_NODE, {
13 | variables: { id: params.slug?.split('-').at(-1) },
14 | skip: !params.slug,
15 | })
16 |
17 | if (loading) return
18 |
19 | return (
20 |
21 | } />
22 | } />
23 | } />
24 | } />
25 |
26 | )
27 | }
28 |
29 | export default Question
30 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/QuestionRoutes.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Route, Routes } from 'react-router-dom'
2 |
3 | import Question from './Question'
4 |
5 | import Edit from './scenes/Edit'
6 | import Random from './scenes/Random'
7 |
8 | const QuestionRoutes = () => {
9 | return (
10 |
11 | } />
12 | } />
13 | } />
14 | } />
15 |
16 | )
17 | }
18 |
19 | export default QuestionRoutes
20 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/components/ActionMenu/ActionMenu.jsx:
--------------------------------------------------------------------------------
1 | import Button from 'components/Button'
2 | import PropTypes from 'prop-types'
3 | import { Link, useLocation, useNavigate } from 'react-router-dom'
4 | import { getIntl } from 'services'
5 |
6 | const ActionMenu = ({ backLabel, backLink, goBack, title, children }) => {
7 | const intl = getIntl(ActionMenu)
8 | const location = useLocation()
9 | const navigate = useNavigate()
10 |
11 | return (
12 |
13 |
14 | {goBack && location.state && location.state.from === 'home' ? (
15 | navigate(-1)}
22 | />
23 | ) : (
24 |
25 |
32 |
33 | )}
34 |
35 |
36 |
{title}
37 |
38 |
{children}
39 |
40 | )
41 | }
42 |
43 | ActionMenu.propTypes = {
44 | backLink: PropTypes.string.isRequired,
45 | backLabel: PropTypes.string,
46 | goBack: PropTypes.bool,
47 | title: PropTypes.string,
48 | children: PropTypes.node,
49 | }
50 |
51 | ActionMenu.translations = {
52 | en: { back: 'Back' },
53 | fr: { back: 'Retour' },
54 | }
55 |
56 | export default ActionMenu
57 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/components/ActionMenu/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ActionMenu'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as ActionMenu } from './ActionMenu'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './QuestionRoutes'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const zNodeFragment = `
4 | id
5 | question {
6 | id
7 | title
8 | language
9 | translation {
10 | id
11 | language
12 | text
13 | }
14 | slug
15 | views
16 | user {
17 | id
18 | name
19 | picture
20 | }
21 | createdAt
22 | }
23 | answer {
24 | id
25 | content
26 | language
27 | certified
28 | translation {
29 | language
30 | text
31 | }
32 | sources {
33 | id
34 | label
35 | url
36 | }
37 | user {
38 | id
39 | name
40 | picture
41 | specialties {
42 | name
43 | }
44 | }
45 | createdAt
46 | }
47 | flags {
48 | id
49 | type
50 | user {
51 | id
52 | name
53 | }
54 | createdAt
55 | }
56 | tags {
57 | id
58 | label {
59 | id
60 | name
61 | }
62 | }
63 | history {
64 | id
65 | meta
66 | action
67 | model
68 | }
69 | `
70 |
71 | export const GET_NODE = gql`
72 | query getNode($id: ID!) {
73 | zNode(where: { id: $id }) {
74 | ${zNodeFragment}
75 | }
76 | }
77 | `
78 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Answer/components/Tips/Tips.jsx:
--------------------------------------------------------------------------------
1 | import { getIntl } from 'services'
2 | import TipsComponent from 'components/Tips'
3 |
4 | const Tips = (props) => {
5 | const intl = getIntl(Tips)
6 |
7 | return (
8 |
9 | {intl('title')}
10 |
11 |
12 | {intl('tips').map((tip, i) => (
13 |
17 | {tip}
18 |
19 | ))}
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | Tips.translations = {
27 | en: {
28 | title: 'Tips to write great answers on the FAQ',
29 | tips: [
30 | 'A partial answer is better than no answer.',
31 | 'Be precise and factual.',
32 | 'The best answers are pleasant to read and well formatted.',
33 | 'Stay impersonal, an answer is neither a discussion nor a report.',
34 | 'FAQ is meant to feel like an encyclopedia more than a forum. You may leave out politeness formulas.',
35 | 'Specify the sources of your information (Internal wiki, Workplace or others).',
36 | 'Mention similar questions to complete your answer.',
37 | ],
38 | },
39 | fr: {
40 | title: 'Conseils pour écrire de super réponses sur la FAQ',
41 | tips: [
42 | 'Une réponse partielle vaut mieux que pas de réponse.',
43 | 'Soyez précis et factuel.',
44 | 'Les meilleures réponses sont agréables à lire et bien formatées.',
45 | "Restez impersonnel, une réponse n'est ni une discussion ni un rapport.",
46 | "FAQ est conçu pour ressembler à une encyclopédie plutôt qu'à un forum. Vous pouvez omettre les formules de politesse.",
47 | 'Précisez les sources de vos informations (wiki interne, Workplace ou autres).',
48 | 'Mentionnez des questions similaires pour compléter votre réponse.',
49 | ],
50 | },
51 | }
52 |
53 | export default Tips
54 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Answer/components/Tips/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Tips'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Answer/helpers.js:
--------------------------------------------------------------------------------
1 | import differenceWith from 'lodash/differenceWith'
2 | import isEqual from 'lodash/isEqual'
3 |
4 | import { isUuidV4 } from 'helpers'
5 |
6 | export const sourcesToKeyValuePairs = (sources) => {
7 | return sources.map(({ id, label, url }) => ({
8 | id,
9 | key: label,
10 | value: url,
11 | }))
12 | }
13 |
14 | export const keyValuePairsToSources = (list) => {
15 | return list
16 | .map(({ id, key, value }) => {
17 | // If the id was generated by the frontend, filter it
18 | if (isUuidV4(id)) {
19 | return { label: key, url: value }
20 | }
21 | return { id, label: key, url: value }
22 | })
23 | .filter(({ label, url }) => label !== '' && url !== '')
24 | }
25 |
26 | export const canSubmit = (state) => {
27 | const { answer, initialAnswer, sources, initialSources } = state
28 | return !(
29 | answer.length === 0 ||
30 | (answer === initialAnswer &&
31 | differenceWith(sources, initialSources, isEqual).length === 0 &&
32 | differenceWith(initialSources, sources, isEqual).length === 0)
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Answer/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Answer'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Answer/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | import { zNodeFragment } from '../../queries'
4 |
5 | export const SUBMIT_ANSWER = gql`
6 | mutation($content: String!, $sources: String!, $nodeId: ID!) {
7 | createAnswerAndSources(
8 | content: $content
9 | sources: $sources
10 | nodeId: $nodeId
11 | ) {
12 | id
13 | content
14 | translation {
15 | language
16 | text
17 | }
18 | sources {
19 | label
20 | url
21 | }
22 | node {
23 | ${zNodeFragment}
24 | }
25 | user {
26 | id
27 | }
28 | createdAt
29 | }
30 | }
31 | `
32 |
33 | export const EDIT_ANSWER = gql`
34 | mutation ($id: ID!, $content: String!, $previousContent: String!, $sources: String!) {
35 | updateAnswerAndSources(
36 | id: $id
37 | content: $content
38 | previousContent: $previousContent
39 | sources: $sources
40 | ) {
41 | id
42 | content
43 | language
44 | translation {
45 | language
46 | text
47 | }
48 | sources {
49 | label
50 | url
51 | }
52 | }
53 | }
54 | `
55 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Edit/components/Tips/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Tips'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Edit/helpers.js:
--------------------------------------------------------------------------------
1 | import difference from 'lodash/difference'
2 |
3 | export const canSubmit = ({ question, initialQuestion, tags, initialTags }) =>
4 | !(
5 | question.length === 0 ||
6 | (question === initialQuestion &&
7 | difference(tags, initialTags).length === 0 &&
8 | difference(initialTags, tags).length === 0)
9 | )
10 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Edit/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Edit'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Edit/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | import { zNodeFragment } from '../../queries'
4 |
5 | export const SUBMIT_QUESTION = gql`
6 | mutation($title: String!, $tags: [ID!]!) {
7 | createQuestionAndTags(title: $title, tags: $tags) {
8 | id
9 | title
10 | language
11 | translation {
12 | id
13 | language
14 | text
15 | }
16 | slug
17 | user {
18 | id
19 | }
20 | node {
21 | ${zNodeFragment}
22 | }
23 | createdAt
24 | }
25 | }
26 | `
27 |
28 | export const EDIT_QUESTION = gql`
29 | mutation ($questionId: ID!, $title: String!, $previousTitle: String!, $tags: [ID!]!) {
30 | updateQuestionAndTags(
31 | id: $questionId
32 | title: $title
33 | previousTitle: $previousTitle
34 | tags: $tags
35 | ) {
36 | id
37 | title
38 | language
39 | translation {
40 | id
41 | language
42 | text
43 | }
44 | slug
45 | user {
46 | id
47 | }
48 | node {
49 | id
50 | tags {
51 | id
52 | label {
53 | id
54 | name
55 | }
56 | }
57 | }
58 | }
59 | }
60 | `
61 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Random/Random.container.js:
--------------------------------------------------------------------------------
1 | import { compose } from 'helpers'
2 | import { withLoading, withError } from 'components'
3 | import { getIntl } from 'services'
4 | import { query } from 'services/apollo'
5 |
6 | import { GET_RANDOM } from './queries'
7 |
8 | import Random from './Random'
9 | import { useParams } from 'react-router'
10 |
11 | export default compose(
12 | query(GET_RANDOM, {
13 | variables: () => {
14 | const params = useParams()
15 | return {
16 | tag: params.tag,
17 | }
18 | },
19 | fetchPolicy: 'network-only',
20 | }),
21 | withLoading(getIntl(Random)('loading')),
22 | withError(),
23 | )(Random)
24 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Random/Random.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import { Navigate } from 'react-router'
3 |
4 | import { getIntl } from 'services'
5 |
6 | const Random = ({ randomNode }) => {
7 | const intl = getIntl(Random)
8 |
9 | if (randomNode.id) {
10 | return
11 | }
12 |
13 | return {intl('error')}
14 | }
15 |
16 | Random.propTypes = {
17 | randomNode: PropTypes.object.isRequired,
18 | }
19 |
20 | Random.translations = {
21 | en: {
22 | loading: 'Unleashing the randomizator...',
23 | error: 'There is no questions corresponding to your search. Try again later!',
24 | },
25 | fr: {
26 | loading: 'Déverrouillage du randomizateur...',
27 | error: "Il n'y a pas de question correspondant à votre recherche. Essayez plus tard!",
28 | },
29 | }
30 |
31 | export default Random
32 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Random/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Random.container'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Random/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const GET_RANDOM = gql`
4 | query ($tag: String) {
5 | randomNode(tag: $tag) {
6 | id
7 | question {
8 | id
9 | slug
10 | }
11 | }
12 | }
13 | `
14 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/FlagsDropdown/FlagsDropdown.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { getIntl } from 'services'
4 |
5 | import { Flag, flagMeta } from 'components/Flags'
6 | import Dropdown, { DropdownItem } from 'components/Dropdown'
7 | import Button from 'components/Button'
8 |
9 | import { answerCanBeCertified } from 'helpers'
10 | import { useUser } from 'contexts'
11 |
12 | const FlagsDropdown = ({ zNode, onSelect, onRemove }) => {
13 | const intl = getIntl(FlagsDropdown)
14 | const flagIntl = getIntl(Flag)
15 |
16 | const { flags, tags, answer } = zNode
17 | const { specialties } = useUser()
18 |
19 | const flagTypes = ['incomplete', 'outdated', 'duplicate']
20 |
21 | answerCanBeCertified(specialties, tags, answer, flagTypes)
22 |
23 | const items = flagTypes.map((type) => {
24 | const isSelected = flags.filter((f) => f.type === type).length > 0
25 | return (
26 | isSelected && onRemove(type)}
34 | >
35 | close
36 |
37 | ) : null
38 | }
39 | disabled={isSelected}
40 | onClick={() => !isSelected && onSelect(type)}
41 | >
42 | {flagIntl(type)}
43 |
44 | )
45 | })
46 |
47 | return (
48 | }
51 | >
52 | {items}
53 |
54 | )
55 | }
56 |
57 | FlagsDropdown.propTypes = {
58 | zNode: PropTypes.object.isRequired,
59 | onSelect: PropTypes.func.isRequired,
60 | onRemove: PropTypes.func.isRequired,
61 | }
62 |
63 | FlagsDropdown.translations = {
64 | en: { button: 'Flag as ...' },
65 | fr: { button: 'Signaler ...' },
66 | }
67 |
68 | export default FlagsDropdown
69 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/FlagsDropdown/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './FlagsDropdown'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/History.jsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames'
2 | import { useState } from 'react'
3 |
4 | import { getIntl } from 'services'
5 | import HistoryActions from './HistoryActions.container'
6 |
7 | const History = () => {
8 | const intl = getIntl(History)
9 | const [open, setOpen] = useState(false)
10 |
11 | return (
12 |
13 |
setOpen((st) => !st)}
22 | >
23 |
{intl('title')}
24 |
25 |
26 | {open && (
27 |
28 |
29 |
30 | )}
31 |
32 | )
33 | }
34 |
35 | History.translations = {
36 | en: { title: 'history' },
37 | fr: { title: 'historique' },
38 | }
39 |
40 | export default History
41 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/HistoryAction.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { formatHistoryAction } from 'helpers'
4 |
5 | import Avatar from 'components/Avatar'
6 |
7 | const HistoryAction = ({ action }) => {
8 | action = formatHistoryAction(action)
9 |
10 | return (
11 |
12 |
13 |
14 | {action.icon}
15 |
16 |
19 |
20 | {action.user.name} {action.sentence}.
21 |
22 |
23 |
24 | {action.date}
25 |
26 |
27 | )
28 | }
29 |
30 | HistoryAction.propTypes = {
31 | action: PropTypes.object.isRequired,
32 | }
33 |
34 | export default HistoryAction
35 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/HistoryActions.container.js:
--------------------------------------------------------------------------------
1 | import { withError, withPagination } from 'components'
2 | import { compose, unserialize } from 'helpers'
3 | import { routing } from 'services'
4 | import { query } from 'services/apollo'
5 |
6 | import { LOAD_HISTORY } from './queries'
7 |
8 | import HistoryActions from './HistoryActions'
9 | import { useLocation, useParams } from 'react-router'
10 |
11 | const ENTRIES_PER_PAGE = 10
12 |
13 | export default compose(
14 | query(LOAD_HISTORY, {
15 | variables: () => {
16 | const location = useLocation()
17 | const params = useParams()
18 | const { page } = unserialize(location.search)
19 | return {
20 | nodeId: routing.getUIDFromSlug(params),
21 | first: ENTRIES_PER_PAGE,
22 | skip: ENTRIES_PER_PAGE * (page - 1),
23 | }
24 | },
25 | parse: ({ history = {} }) => ({ ...history }),
26 | }),
27 | withPagination({ push: false }),
28 | withError(),
29 | )(HistoryActions)
30 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/HistoryActions.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { DefaultPagination, Loading } from 'components'
4 |
5 | import HistoryAction from './HistoryAction'
6 |
7 | const HistoryActions = ({
8 | historyActions = [],
9 | pagesCount,
10 | pageCurrent,
11 | onPageSelected,
12 | loading,
13 | meta,
14 | }) => {
15 | const shouldShowLoading = loading && (meta ? meta.pageCurrent !== pageCurrent : true)
16 |
17 | const actions = historyActions.map((action) => )
18 |
19 | return (
20 |
21 | {shouldShowLoading && }
22 | {!shouldShowLoading && actions}
23 |
28 |
29 | )
30 | }
31 |
32 | HistoryActions.propTypes = {
33 | historyActions: PropTypes.array,
34 | pagesCount: PropTypes.number,
35 | pageCurrent: PropTypes.number,
36 | onPageSelected: PropTypes.func,
37 | loading: PropTypes.bool,
38 | meta: PropTypes.object,
39 | }
40 |
41 | export default HistoryActions
42 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './History'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/History/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const LOAD_HISTORY = gql`
4 | query ($nodeId: ID!, $first: Int!, $skip: Int!) {
5 | history(where: { node: { id: $nodeId } }, first: $first, skip: $skip, orderBy: createdAt_DESC) {
6 | historyActions {
7 | id
8 | node {
9 | id
10 | }
11 | action
12 | model
13 | meta
14 | user {
15 | id
16 | name
17 | picture
18 | }
19 | createdAt
20 | }
21 | meta {
22 | pagesCount
23 | pageCurrent
24 | }
25 | }
26 | }
27 | `
28 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/LanguageDropdown/LanguageDropdown.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { getIntl } from 'services'
4 |
5 | import Dropdown, { DropdownItem } from 'components/Dropdown'
6 |
7 | import Button from 'components/Button'
8 |
9 | const LanguageDropdown = ({ onLanguageChanged = () => {}, primary = false, originalLanguage }) => {
10 | const intl = getIntl(LanguageDropdown)
11 |
12 | return (
13 |
14 | }
16 | >
17 | onLanguageChanged('fr')}>
18 | {intl('french')} {originalLanguage === 'fr' && '(original)'}
19 |
20 | onLanguageChanged('en')}>
21 | {intl('english')} {originalLanguage === 'en' && '(original)'}
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | LanguageDropdown.propTypes = {
29 | onLanguageChanged: PropTypes.func,
30 | primary: PropTypes.bool,
31 | originalLanguage: PropTypes.string,
32 | }
33 |
34 | LanguageDropdown.translations = {
35 | en: { french: 'French', english: 'English' },
36 | fr: { french: 'Français', english: 'Anglais' },
37 | }
38 |
39 | export default LanguageDropdown
40 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/LanguageDropdown/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './LanguageDropdown'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Meta/Meta.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import format from 'date-fns/format'
3 |
4 | import { getIntl } from 'services'
5 |
6 | import Avatar from 'components/Avatar'
7 |
8 | const Meta = ({ node }) => {
9 | const intl = getIntl(Meta)
10 |
11 | return (
12 |
13 |
14 |
15 |
16 | {intl('asked')} {node.question.user.name}
17 |
18 | {format(new Date(node.question.createdAt), 'P')}
19 |
20 |
21 | {node.answer && (
22 |
23 |
24 | {intl('answered')} {node.answer.user.name}
25 |
26 | {format(new Date(node.answer.createdAt), 'P')}
27 |
28 |
29 |
30 | )}
31 |
32 | )
33 | }
34 |
35 | Meta.propTypes = {
36 | node: PropTypes.object.isRequired,
37 | }
38 |
39 | Meta.translations = {
40 | en: { asked: 'Asked by', answered: 'Answer by' },
41 | fr: { asked: 'Posée par', answered: 'Répondue par' },
42 | }
43 |
44 | export default Meta
45 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Meta/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Meta'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Share/Share.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import copy from 'copy-to-clipboard'
3 |
4 | import { useConfiguration } from 'contexts'
5 | import { routing, getIntl } from 'services'
6 |
7 | import Dropdown, { DropdownItem } from 'components/Dropdown'
8 |
9 | import Button from 'components/Button'
10 |
11 | const Share = ({ node }) => {
12 | const intl = getIntl(Share)
13 |
14 | const conf = useConfiguration()
15 | const shareUrl = routing.getShareUrl(node.id)
16 |
17 | return (
18 |
19 | }>
20 | {conf.workplaceSharing ? (
21 |
24 | }
25 | onClick={() => {
26 | let url =
27 | 'https://work.facebook.com/sharer.php?display=popup&u=' +
28 | shareUrl +
29 | '"e=' +
30 | encodeURI(node.question.title)
31 | let options = 'toolbar=0,status=0,resizable=1,width=626,height=436'
32 | window.open(url, 'sharer', options)
33 | }}
34 | >
35 | Workplace
36 |
37 | ) : null}
38 | copy(shareUrl)}>
39 | {intl('copy')}
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | Share.propTypes = {
47 | node: PropTypes.object.isRequired,
48 | }
49 |
50 | Share.translations = {
51 | en: { copy: 'Copy link' },
52 | fr: { copy: 'Copier lien' },
53 | }
54 |
55 | export default Share
56 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Share/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Share'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Sources/Sources.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { getIntl } from 'services'
4 |
5 | import List, { ListItem } from 'components/List'
6 |
7 | const Sources = ({ sources }) => {
8 | const intl = getIntl(Sources)
9 |
10 | if (sources.length === 0) return ''
11 |
12 | return (
13 |
14 |
{intl('sources')}
15 |
16 | {sources.map((source) => (
17 |
18 | ))}
19 |
20 |
21 | )
22 | }
23 |
24 | Sources.propTypes = {
25 | sources: PropTypes.array.isRequired,
26 | }
27 |
28 | Sources.translations = {
29 | en: { sources: 'Sources:' },
30 | fr: { sources: 'Sources:' },
31 | }
32 |
33 | export default Sources
34 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Sources/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Sources'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Views/Views.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 |
3 | import { getIntl } from 'services'
4 |
5 | const Views = ({ value }) => {
6 | const intl = getIntl(Views)
7 | let formattedValue = value || 0
8 | if (value > 1000) {
9 | const locale = window.navigator.language
10 | const formatter = new Intl.NumberFormat(locale, { maximumSignificantDigits: 2 })
11 | formattedValue = `${formatter.format(value / 1000)}k`
12 | }
13 |
14 | return (
15 |
16 | {formattedValue} {formattedValue > 1 ? intl('views') : intl('view')}
17 |
18 | )
19 | }
20 |
21 | Views.propTypes = {
22 | value: PropTypes.number,
23 | }
24 |
25 | Views.translations = {
26 | en: {
27 | view: 'view',
28 | views: 'views',
29 | },
30 | fr: {
31 | view: 'vue',
32 | views: 'vues',
33 | },
34 | }
35 |
36 | export default Views
37 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/Views/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Views'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as FlagsDropdown } from './FlagsDropdown'
2 | export { default as History } from './History'
3 | export { default as Meta } from './Meta'
4 | export { default as Share } from './Share'
5 | export { default as Sources } from './Sources'
6 | export { default as LanguageDropdown } from './LanguageDropdown'
7 | export { default as Views } from './Views'
8 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Read'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Question/scenes/Read/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const CREATE_FLAG = gql`
4 | mutation ($type: String!, $nodeId: ID!) {
5 | addFlag(type: $type, nodeId: $nodeId) {
6 | id
7 | flags {
8 | id
9 | type
10 | user {
11 | id
12 | name
13 | }
14 | createdAt
15 | }
16 | }
17 | }
18 | `
19 |
20 | export const REMOVE_FLAG = gql`
21 | mutation ($type: String!, $nodeId: ID!) {
22 | removeFlag(type: $type, nodeId: $nodeId) {
23 | id
24 | flags {
25 | id
26 | type
27 | user {
28 | id
29 | name
30 | }
31 | createdAt
32 | }
33 | }
34 | }
35 | `
36 |
37 | export const INCREMENT_VIEWS_COUNTER = gql`
38 | mutation ($questionId: ID!) {
39 | incrementQuestionViewsCounter(id: $questionId) {
40 | id
41 | views
42 | }
43 | }
44 | `
45 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/components/TagsEditor/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './TagsEditor'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as TagsEditor } from './TagsEditor'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/helpers.js:
--------------------------------------------------------------------------------
1 | import { isUuidV4 } from 'helpers'
2 | import { onListChangeReducer } from 'helpers/onListChange'
3 |
4 | export const reducer = (state, action) => {
5 | switch (action.type) {
6 | case 'reset':
7 | return action.data
8 | case 'change_title':
9 | return { ...state, title: action.data }
10 | case 'toggle_workplace':
11 | return { ...state, workplaceSharing: action.data }
12 | case 'change_domains':
13 | return { ...state, authorizedDomains: action.data }
14 | case 'change_bug_reporting':
15 | return { ...state, bugReporting: action.data }
16 | case 'change_slack_channelhook':
17 | return { ...state, slackChannelHook: action.data }
18 | case 'change_slack_commandkey':
19 | return { ...state, slackCommandKey: action.data }
20 | case 'change_tags':
21 | return { ...state, tagCategories: action.data }
22 | default:
23 | return {
24 | ...state,
25 | synonyms: onListChangeReducer('synonyms')(state.synonyms, action),
26 | }
27 | }
28 | }
29 |
30 | export const synonymsToList = (synonyms) => {
31 | if (!synonyms) {
32 | return []
33 | }
34 | const actualSynonyms = Array.isArray(synonyms) ? synonyms : [synonyms]
35 | return actualSynonyms.map(({ objectID, synonyms }, id) => ({
36 | id,
37 | key: objectID,
38 | value: synonyms.join(', '),
39 | }))
40 | }
41 |
42 | export const listToSynonyms = (list) =>
43 | list.map((item) => ({
44 | objectID: item.key,
45 | type: 'synonym',
46 | synonyms: item.value.split(',').map((x) => x.trim()),
47 | }))
48 |
49 | export const serializeTags = (categories) =>
50 | JSON.stringify(
51 | categories.map(({ id, name, order, labels }) => {
52 | const serializedLabels = labels.map(({ id, name, order }) => {
53 | if (isUuidV4(id)) {
54 | return { name, order }
55 | } else {
56 | return { id, name, order }
57 | }
58 | })
59 |
60 | if (isUuidV4(id)) {
61 | return { name, order, labels: serializedLabels }
62 | } else {
63 | return { id, name, order, labels: serializedLabels }
64 | }
65 | }),
66 | )
67 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Settings.container'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | const CONFIGURATION_FRAGMENT = `
4 | id
5 | title
6 | tagCategories {
7 | id
8 | order
9 | name
10 | labels {
11 | id
12 | order
13 | name
14 | }
15 | }
16 | algoliaSynonyms
17 | workplaceSharing
18 | authorizedDomains
19 | bugReporting
20 | slackChannelHook
21 | slackCommandKey
22 | `
23 |
24 | export const GET_CONFIGURATION = gql`
25 | query {
26 | configuration {
27 | ${CONFIGURATION_FRAGMENT}
28 | }
29 | }
30 | `
31 |
32 | export const UPDATE_CONFIGURATION = gql`
33 | mutation updateConfiguration(
34 | $title: String!
35 | $tagCategories: Json!
36 | $algoliaSynonyms: Json!
37 | $workplaceSharing: Boolean!
38 | $authorizedDomains: [String!]!
39 | $bugReporting: BugReporting!
40 | $slackChannelHook: String
41 | ) {
42 | updateConfiguration(
43 | title: $title
44 | tagCategories: $tagCategories
45 | algoliaSynonyms: $algoliaSynonyms
46 | workplaceSharing: $workplaceSharing
47 | authorizedDomains: $authorizedDomains
48 | bugReporting: $bugReporting
49 | slackChannelHook: $slackChannelHook
50 | ) {
51 | ${CONFIGURATION_FRAGMENT}
52 | }
53 | }
54 | `
55 |
56 | export const REGENERATE_SLACK_COMMAND_KEY = gql`
57 | mutation regenerateSlackCommandKey {
58 | regenerateSlackCommandKey {
59 | slackCommandKey
60 | }
61 | }
62 | `
63 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/scenes/Specialists.jsx:
--------------------------------------------------------------------------------
1 | import { Tab, UsersList } from 'components'
2 |
3 | import { getIntl } from 'services'
4 |
5 | const Specialists = () => {
6 | const intl = getIntl(Specialists)
7 |
8 | return (
9 |
10 | {intl('title')}
11 |
12 |
13 | )
14 | }
15 |
16 | Specialists.translations = {
17 | en: {
18 | tab: 'Specialists',
19 | title: 'Users',
20 | },
21 | fr: {
22 | tab: 'Spécialistes',
23 | title: 'Utilisateurs',
24 | },
25 | }
26 |
27 | export default Specialists
28 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/scenes/Synonyms.jsx:
--------------------------------------------------------------------------------
1 | import { Tab, PairInputList } from 'components'
2 |
3 | import { getIntl } from 'services'
4 |
5 | import { onListChangeActions } from 'helpers/onListChange'
6 |
7 | const Synonyms = ({ state, dispatch, loading }) => {
8 | const intl = getIntl(Synonyms)
9 |
10 | return (
11 |
12 |
21 |
22 | )
23 | }
24 |
25 | Synonyms.translations = {
26 | en: {
27 | tab: 'Synonyms',
28 | labels: {
29 | add: 'Add a synonym',
30 | more: 'More synonyms',
31 | key: 'ID',
32 | value: 'Synonyms',
33 | },
34 | },
35 | fr: {
36 | tab: 'Synonymes',
37 | labels: {
38 | add: 'Ajouter un synonyme',
39 | more: 'Plus de synonymes',
40 | key: 'ID',
41 | value: 'Synonymes',
42 | },
43 | },
44 | }
45 |
46 | export default Synonyms
47 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/scenes/Tags.jsx:
--------------------------------------------------------------------------------
1 | import { getIntl } from 'services'
2 |
3 | import { Tab } from 'components'
4 |
5 | import { TagsEditor } from '../components'
6 |
7 | const Tags = ({ state, onTagsChange }) => {
8 | const intl = getIntl(Tags)
9 |
10 | return (
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | Tags.translations = {
18 | en: {
19 | tab: 'Tags',
20 | labels: {
21 | add: 'Add tags',
22 | more: 'More tags',
23 | key: 'Category',
24 | value: 'Tags',
25 | },
26 | },
27 | fr: {
28 | tab: 'Tags',
29 | labels: {
30 | add: 'Ajouter un tags',
31 | more: 'Plus de tags',
32 | key: 'Categorie',
33 | value: 'Tags',
34 | },
35 | },
36 | }
37 |
38 | export default Tags
39 |
--------------------------------------------------------------------------------
/client/src/scenes/Settings/scenes/index.js:
--------------------------------------------------------------------------------
1 | export { default as General } from './General'
2 | export { default as Tags } from './Tags'
3 | export { default as Specialists } from './Specialists'
4 | export { default as Synonyms } from './Synonyms'
5 | export { default as Integrations } from './Integrations'
6 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/UserProfile.container.jsx:
--------------------------------------------------------------------------------
1 | import { compose } from 'helpers'
2 | import { query } from 'services/apollo'
3 | import { withLoading, withError } from 'components'
4 |
5 | import { GET_ME } from './queries'
6 |
7 | import UserProfile from './UserProfile'
8 |
9 | export default compose(query(GET_ME), withLoading(), withError())(UserProfile)
10 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Logs/Logs.container.js:
--------------------------------------------------------------------------------
1 | import { withError, withPagination } from 'components'
2 | import { compose, unserialize } from 'helpers'
3 | import { useLocation } from 'react-router'
4 | import { query } from 'services/apollo'
5 |
6 | import Logs from './Logs'
7 | import { meHistory } from './queries'
8 |
9 | const ENTRIES_PER_PAGE = 15
10 |
11 | export default compose(
12 | query(meHistory, {
13 | variables: (props) => {
14 | const location = useLocation()
15 | const { page } = unserialize(location.search)
16 |
17 | return {
18 | id: props.userId,
19 | first: ENTRIES_PER_PAGE,
20 | skip: ENTRIES_PER_PAGE * (page - 1),
21 | }
22 | },
23 | parse: ({ history = {} }) => ({
24 | logs: history.historyActions,
25 | meta: history.meta,
26 | }),
27 | }),
28 | withPagination({ push: false }),
29 | withError(),
30 | )(Logs)
31 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Logs/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Logs.container'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Logs/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const meHistory = gql`
4 | query ($id: ID!, $first: Int!, $skip: Int!) {
5 | history(where: { user: { id: $id } }, orderBy: createdAt_DESC, first: $first, skip: $skip) {
6 | historyActions {
7 | id
8 | action
9 | model
10 | meta
11 | createdAt
12 | user {
13 | id
14 | name
15 | }
16 | node {
17 | id
18 | question {
19 | id
20 | title
21 | slug
22 | }
23 | }
24 | }
25 | meta {
26 | pagesCount
27 | pageCurrent
28 | }
29 | }
30 | }
31 | `
32 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Specialties/Specialties.container.js:
--------------------------------------------------------------------------------
1 | import { withError } from 'components'
2 | import { compose } from 'helpers'
3 | import { query } from 'services/apollo'
4 | import { GET_SPECIALTIES } from './queries'
5 |
6 | import Specialties from './Specialties'
7 |
8 | export default compose(
9 | query(GET_SPECIALTIES, {
10 | parse: ({ me = {} }) => ({
11 | specialties: me.specialties,
12 | }),
13 | }),
14 | withError(),
15 | )(Specialties)
16 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Specialties/Specialties.jsx:
--------------------------------------------------------------------------------
1 | import Card, { CardText } from 'components/Card'
2 | import PropTypes from 'prop-types'
3 | import { getIntl } from 'services'
4 | import { Loading } from 'components'
5 |
6 | const Specialties = ({ specialties }) => {
7 | const intl = getIntl(Specialties)
8 |
9 | if (specialties === undefined) return
10 |
11 | return (
12 |
13 |
14 | {intl('title')}
15 |
16 | {specialties.length > 0 ? (
17 |
18 | {specialties.map((specialty) => (
19 |
23 | verified
24 | {specialty.name}
25 |
26 | ))}
27 |
28 | ) : (
29 | {intl('empty')}
30 | )}
31 |
32 |
33 | )
34 | }
35 |
36 | Specialties.propTypes = {
37 | specialties: PropTypes.array,
38 | }
39 |
40 | Specialties.translations = {
41 | en: {
42 | title: 'Specialties',
43 | empty: 'No specialties yet',
44 | },
45 | fr: {
46 | title: 'Spécialités',
47 | empty: 'Pas encore de spécialités',
48 | },
49 | }
50 |
51 | export default Specialties
52 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Specialties/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Specialties.container'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/components/Specialties/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const GET_SPECIALTIES = gql`
4 | query {
5 | me {
6 | id
7 | specialties {
8 | id
9 | name
10 | }
11 | }
12 | }
13 | `
14 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './UserProfile'
2 |
--------------------------------------------------------------------------------
/client/src/scenes/UserProfile/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const GET_ME = gql`
4 | query {
5 | me {
6 | id
7 | email
8 | name
9 | picture
10 | }
11 | }
12 | `
13 |
14 | export const UPDATE_INDENTITY = gql`
15 | mutation updateIdentity($name: String!, $email: String!, $picture: String!) {
16 | updateMe(name: $name, email: $email, picture: $picture) {
17 | id
18 | }
19 | }
20 | `
21 |
22 | export const DELETE_IDENTITY = gql`
23 | mutation deleteIdentity {
24 | forgetMe {
25 | id
26 | }
27 | }
28 | `
29 |
--------------------------------------------------------------------------------
/client/src/services/alert.jsx:
--------------------------------------------------------------------------------
1 | import { AlertProvider } from 'components'
2 |
3 | import { getIntl as intl } from './intl'
4 |
5 | const alert = {
6 | push: (alert) => AlertProvider.pushAlert(alert),
7 | pushError: (message, err) =>
8 | alert.push({
9 | message,
10 | type: 'error',
11 | raw: err,
12 | }),
13 | pushDefaultError: (err) =>
14 | alert.push({
15 | message: (
16 | <>
17 | {err.message || intl(alert)('unknown')}
18 | {intl(alert)('try_again')}
19 | >
20 | ),
21 | type: 'error',
22 | raw: err,
23 | }),
24 | pushSuccess: (message) => alert.push({ message, type: 'success' }),
25 | }
26 |
27 | alert.translations = {
28 | en: { unknown: 'An unknown error occured.', try_again: 'Please, try again' },
29 | fr: { unknown: 'Une erreur inconnue est survenue.', try_again: 'Réessayez de nouveau' },
30 | }
31 |
32 | export default alert
33 |
--------------------------------------------------------------------------------
/client/src/services/index.js:
--------------------------------------------------------------------------------
1 | export { default as alert } from './alert'
2 | export { default as apollo, apolloCache } from './apollo'
3 | export { default as auth } from './auth'
4 | export * from './intl'
5 | export { default as markdown } from './markdown'
6 | export { default as routing } from './routing'
7 |
--------------------------------------------------------------------------------
/client/src/services/intl.js:
--------------------------------------------------------------------------------
1 | import get from 'lodash/get'
2 |
3 | const getLanguage = () => {
4 | const raw = window.navigator.language
5 | if (raw.includes('-')) return raw.split('-')[0]
6 | return raw
7 | }
8 |
9 | export const getIntl = (component) => {
10 | let context = (component && component.translations) || {}
11 | const language = getLanguage()
12 |
13 | context = context[language] || context['en']
14 |
15 | return (path) => get(context, path)
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/services/markdown.jsx:
--------------------------------------------------------------------------------
1 | import { Converter } from 'showdown'
2 | import XSSFilter from 'showdown-xss-filter'
3 |
4 | class Markdown {
5 | constructor() {
6 | this.showdown = new Converter({
7 | openLinksInNewWindow: true,
8 | backslashEscapesHTMLTags: true,
9 | extensions: [XSSFilter],
10 | })
11 |
12 | this.showdown.setFlavor('github')
13 | }
14 |
15 | title(title) {
16 | return title
17 | }
18 |
19 | text(text) {
20 | text = this.removeEmTagInLink(text)
21 |
22 | return this.showdown.makeHtml(text)
23 | }
24 |
25 | html(text) {
26 | return (
27 |
35 | )
36 | }
37 |
38 | removeEmTagInLink(text) {
39 | const sanitize = (txt) => txt.replace(//g, '').replace(/<\/em>/g, '')
40 |
41 | text = text.replace(/(\[.*\]\()?(https?:\/\/\S*)/gim, (link, a, b) => {
42 | const endOfB = b.split('').reduce(
43 | (acc, c, i) => {
44 | if (acc.stack === 0) return acc
45 | return { i, stack: acc.stack + (c === '(' ? 1 : c === ')' ? -1 : 0) }
46 | },
47 | { i: 0, stack: 1 },
48 | )
49 |
50 | if (a && endOfB.stack === 0) {
51 | // Means it's a markdown link
52 | return a + sanitize(b.substr(0, endOfB.i)) + b.substr(endOfB.i)
53 | }
54 |
55 | return `${link} `
56 | })
57 |
58 | return text
59 | }
60 | }
61 |
62 | const markdown = new Markdown()
63 |
64 | export default markdown
65 |
--------------------------------------------------------------------------------
/client/src/services/routing.js:
--------------------------------------------------------------------------------
1 | const routing = {
2 | getQueryParam(location, name) {
3 | const queryParams = new URLSearchParams(location.search)
4 | return queryParams.get(name)
5 | },
6 | setQueryParam(location, history, name, value, replace) {
7 | const queryParams = new URLSearchParams(location.search)
8 | queryParams.set(name, value)
9 | const arg = {
10 | search: '?' + queryParams.toString(),
11 | }
12 | if (replace) {
13 | history.replace(arg)
14 | } else {
15 | history.push(arg)
16 | }
17 | },
18 | getUIDFromSlug(params) {
19 | return params.slug.split('-').pop()
20 | },
21 | getShareUrl(UID) {
22 | return `${window.location.origin}/q/${UID}`
23 | },
24 | getPrismaService() {
25 | const { VITE_PRISMA_SERVICE, NODE_ENV, VITE_FAQ_URL } = import.meta.env
26 |
27 | // You can override the service with VITE_PRISMA_SERVICE
28 | if (VITE_PRISMA_SERVICE) return VITE_PRISMA_SERVICE
29 |
30 | // If you are in production, we retrieve the prisma service from the url
31 | if (NODE_ENV === 'production' && !!VITE_FAQ_URL) {
32 | const url = new URL(window.location.href).hostname
33 | if (url.endsWith(VITE_FAQ_URL)) {
34 | const match = url.replace(VITE_FAQ_URL, '').match(/(?:(?:([^.]*)\.)?([^.]*)\.)?/)
35 | const name = match[2] || 'default'
36 | const stage = match[1] || 'prod'
37 | return name + '/' + stage
38 | }
39 | }
40 |
41 | // Otherwise, it's default/default
42 | return 'default/default'
43 | },
44 | }
45 |
46 | export default routing
47 |
--------------------------------------------------------------------------------
/client/src/styles/index.js:
--------------------------------------------------------------------------------
1 | import './tooltip.css'
2 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import tailwindTypography from '@tailwindcss/typography';
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | export default {
5 | content: [
6 | "./index.html",
7 | "./src/**/*.{js,ts,jsx,tsx}",
8 | ],
9 | theme: {
10 | colors: {
11 | transparent: 'transparent',
12 | "primary": "#af1e3a",
13 | "primary-light": "#e65564",
14 | "primary-dark": "#790015",
15 | "primary-font": "#ffffff",
16 | "primary-font-dark": "#000000",
17 | "secondary": "#d0d0d0",
18 | "secondary-light": "#fff",
19 | "secondary-dark": "#9f9f9f",
20 | "secondary-font": "#494949",
21 | "secondary-font-light": "#586069",
22 | "secondary-font-dark": "#000000",
23 | "background": "#edf2f5",
24 | "info": "#209cee",
25 | "success": "#23d160",
26 | "warning": "#ff9900",
27 | "error": "#ff3860",
28 | },
29 | fontSize: {
30 | sm: "0.7rem",
31 | base: "14px",
32 | lg: "1.1rem",
33 | xl: "1.3rem"
34 | },
35 | extend: {
36 | spacing: {
37 | 'navbar': "64px"
38 | }
39 | }
40 | },
41 | plugins: [
42 | tailwindTypography,
43 | ],
44 | }
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react'
4 | import dns from 'dns'
5 |
6 | dns.setDefaultResultOrder('verbatim')
7 |
8 | export default defineConfig(() => {
9 | const serverUrl = 'http://localhost:4000'
10 | return {
11 | plugins: [react()],
12 | resolve: {
13 | alias: {
14 | components: path.resolve(__dirname, 'src/components'),
15 | contexts: path.resolve(__dirname, 'src/contexts'),
16 | helpers: path.resolve(__dirname, 'src/helpers'),
17 | scenes: path.resolve(__dirname, 'src/scenes'),
18 | services: path.resolve(__dirname, 'src/services'),
19 | styles: path.resolve(__dirname, 'src/styles')
20 | }
21 | },
22 | build: {
23 | target: ['es2022'],
24 | outDir: 'build'
25 | },
26 | server: {
27 | open: process.env.CI !== 'true',
28 | port: 3000,
29 | proxy: {
30 | '/rest': {
31 | target: serverUrl,
32 | changeOrigin: true,
33 | secure: false
34 | },
35 | '/gql': {
36 | target: serverUrl,
37 | changeOrigin: true,
38 | secure: false
39 | }
40 | }
41 | }
42 | }
43 | })
44 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | The documentation is generated using [docz](https://docz.site).
4 |
5 | **Do not edit the generated files.**
6 |
7 | You can edit the source files of the documentation available in the `/src` folder.
8 |
--------------------------------------------------------------------------------
/docs/doczrc.js:
--------------------------------------------------------------------------------
1 | import { css } from 'docz-plugin-css'
2 |
3 | export default {
4 | title: '🤔 FAQ Documentation',
5 | description: 'Documentation for the FAQ project',
6 | base: '/FAQ/',
7 | codeSandbox: false,
8 | hashRouter: true,
9 | wrapper: 'src/Wrapper',
10 | plugins: [
11 | css({
12 | preprocessor: 'postcss'
13 | })
14 | ],
15 | dest: 'dist',
16 | htmlContext: {
17 | lang: 'en',
18 | favicon: 'https://cdn-std.dprcdn.net/files/acc_649651/LUKiMl',
19 | head: {
20 | links: [
21 | {
22 | rel: 'stylesheet',
23 | href: 'https://fonts.googleapis.com/icon?family=Material+Icons'
24 | }
25 | ],
26 | scripts: [
27 | {
28 | src: 'https://use.fontawesome.com/releases/v5.0.6/js/all.js',
29 | type: 'text/javascript',
30 | defer: true
31 | }
32 | ]
33 | }
34 | },
35 | repository: 'https://github.com/zenika-open-source/FAQ',
36 | menu: [
37 | 'Introduction',
38 | 'Getting started',
39 | {
40 | name: 'Advanced',
41 | menu: ['Configuration', 'Backing services', 'Integrations', 'Multi-tenancy', 'Contributing']
42 | }
43 | // 'Components' TODO: find a way to re-enable them
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 | 🤔 FAQ Documentation
--------------------------------------------------------------------------------
/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Introduction
3 | route: /
4 | ---
5 |
6 | 
7 |
8 | # FAQ Zenika
9 |
10 | Internal Knowledge Database for your organization.
11 |
12 | ## What is FAQ?
13 |
14 | **FAQ** is an **internal knowledge database** for your organization's members. It aims to be the **"single source of truth"** for most of your company-oriented information: _"How does the variable part of the salary work?"_, _"Can the client ask me to stay late at work?"_, ...
15 |
16 | ## Philosophy
17 |
18 | - **Single source of truth:** Regrouping useful information
19 | - **Intuitive:** Not only developer-oriented
20 | - **Integrated:** Works well with your existing tool suite
21 | - **Internationalized:** Open to the world
22 |
23 | ## Technologies
24 |
25 | #### Frontend
26 |
27 | - JS _(React, Apollo)_
28 | - CSS _(Custom-made)_
29 |
30 | #### Backend
31 |
32 | - Node JS
33 | - Prisma
34 | - PostgreSQL
35 |
36 | #### Backing services
37 |
38 | - Algolia _(Search-as-a-Service)_
39 | - Mailgun _(Mail-as-a-Service)_
40 | - Auth0 _(Auth-as-a-Service)_
41 |
42 | #### Integrations
43 |
44 | - Slack: Sending new questions into a dedicated channel and `/faq` command
45 | - Workplace: Button to share questions
46 | - Public API: Write your own integration to query the FAQ
47 |
48 | ## Documentation
49 |
50 | New to this project ?
51 |
52 | - [Step-by-step installation](https://zenika-open-source.github.io/FAQ/#/getting-started) with only the required backing services
53 |
54 | The full documentation is available here:
55 |
56 | - https://zenika-open-source.github.io/FAQ/
57 |
58 | ## License
59 |
60 | This project is under the Apache License 2.0 - See the [LICENSE.md](https://github.com/zenika-open-source/FAQ/blob/main/LICENSE.md) file for details
61 |
62 | 
63 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "faq_docs",
3 | "version": "1.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "docz": "^0.13.7",
7 | "docz-plugin-css": "^0.11.0",
8 | "docz-theme-default": "^0.13.7",
9 | "webpack": "^4.28.4"
10 | },
11 | "scripts": {
12 | "start": "docz dev",
13 | "build": "sh ./src/build.sh"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/docs/src/Wrapper.css:
--------------------------------------------------------------------------------
1 | .docs-wrapper img[alt='FAQ Zenika'] {
2 | max-width: 100%;
3 | }
4 |
--------------------------------------------------------------------------------
/docs/src/Wrapper.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import '../../client/src/styles'
4 | import './Wrapper.css'
5 |
6 | export default ({ children }) => {children}
7 |
--------------------------------------------------------------------------------
/docs/src/advanced/contributing.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Contributing
3 | route: /advanced/contributing
4 | menu: Advanced
5 | ---
6 |
7 | # Contributing
8 |
9 | So you want to contribute to this project? Awesome!
10 |
11 | Below are some useful information in order to get you started.
12 |
13 | You will need to follow the [installation process](/advanced/getting-started) first.
14 |
15 | ## Contributing process
16 |
17 | - You found a bug? You have a suggestion? Great! [Open an issue](https://github.com/zenika-open-source/FAQ/issues) so we can discuss it
18 | - You want to implement changes to the code? Awesome! [Open a pull request](https://github.com/zenika-open-source/FAQ/pulls) so we can review it
19 |
20 | ## Documentation
21 |
22 | The documentation is generated using [docz](https://docz.site).
23 |
24 | Run the development script:
25 |
26 | ```bash
27 | # Path: ./FAQ/docs
28 | npm run start
29 | ```
30 |
31 | Build the documentation:
32 |
33 | ```bash
34 | # Path: ./FAQ/docs
35 | npm run build
36 | ```
37 |
38 | > Remember to rebuild the documentation before pushing
39 |
40 | ## Tests
41 |
42 | > There is currently no tests for this project
43 |
44 | ## Linting
45 |
46 | You can use the following commands either in the frontend (`./client`) or the backend (`./server`)
47 |
48 | ```bash
49 | npm run lint #Check with eslint
50 | npm run lint:fix #Fix with eslint
51 | npm run prettier:check #Check with prettier
52 | npm run prettier:write #Fix with prettier
53 | ```
54 |
55 | ## Bundle size
56 |
57 | We try to minimize the size of the js bundle. You can use the webpack analyzer to check if new dependencies are lightweight or not.
58 |
59 | ```bash
60 | npm run stats
61 | ```
62 |
63 | This commands generate a `stats.json` file which you can vizualize on this [website](https://chrisbateman.github.io/webpack-visualizer/)
64 |
--------------------------------------------------------------------------------
/docs/src/advanced/multi_tenancy.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Multi-tenancy
3 | route: /advanced/multi-tenancy
4 | menu: Advanced
5 | ---
6 |
7 | # Multi-tenancy
8 |
9 | FAQ is a [multi-tenancy](https://en.wikipedia.org/wiki/Multitenancy) application. You can have multiple services served with only instance of the stack (Frontend / Backend / Prisma / DB).
10 |
11 | If you do not specify a service during the installation, you will have a default service name and stage (default/default).
12 |
13 | ## How to create a new service
14 |
15 | Execute the following command:
16 |
17 | ```bash
18 | # Path: ./FAQ/server/
19 | npm run new_service [service_name] [service_stage]
20 |
21 | # Example: npm run new_service zenika pre-prod
22 | ```
23 |
24 | You now have a new service!
25 |
26 | > For the complete configuration of your service, see the [configuration documentation](/advanced/configuration). Don't forget to redeploy and restart the server after you changed the configuration!
27 |
28 | ## Service routing in production
29 |
30 | While in production, the service accessed will depend on the url you are at:
31 |
32 | ```
33 | https://[service_stage?].[service_name?].[FAQ_URL]/
34 | ```
35 |
36 | | name | value |
37 | | ------------- | ------------------------------------------- |
38 | | FAQ_URL | Provided from environment variables |
39 | | service_name | The name of the service. Default: `default` |
40 | | service_stage | The stage of the service. Default: `prod` |
41 |
42 | Examples with FAQ_URL=faq.team (`name / stage`):
43 |
44 | - faq.team => `default / prod`
45 | - demo.faq.team => `demo / prod`
46 | - dev.demo.faq.team => `demo / dev`
47 |
48 | > Note 1: If NODE_ENV!=production or if no FAQ_URL is found in your frontend environment variables, the default routing will return `default/default`
49 |
50 | > Note 2: The routing can be overrided using VITE_PRISMA_SERVICE=name/stage in your frontend
51 |
--------------------------------------------------------------------------------
/docs/src/advanced/testing.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Testing
3 | route: /advanced/testing
4 | menu: Advanced
5 | ---
6 |
7 | # Testing
8 |
9 | The project uses Playwright to run end-to-end tests.
10 | Here are the steps to make the tests work.
11 |
12 | ## 1/ Install the dependencies
13 |
14 | ```bash
15 | # Path: ./FAQ/e2e
16 | npm install
17 | ```
18 |
19 | ## 2/ Update your environment files
20 |
21 | - In the `.env.local` of the `client` folder add
22 |
23 | ```bash
24 | REACT_APP_DISABLE_AUTH=true
25 | ```
26 |
27 | - In the `.env.local` of the `server` folder add
28 |
29 | ```bash
30 | DISABLE_AUTH=true
31 | ```
32 |
33 | These will make Playwright skip the auth0 authentication and avoid the redirection
34 |
35 | ## 3/ Launch the tests
36 |
37 | - In your terminal, go in the `e2e` folder
38 | - Use `npm run test` to start the tests
39 | - Use `npm run test:headed` to start the tests with a browser opening and closing for each test
40 | - Use `npm run test:trace` to start the tests with the trace on. The trace is a record of every action made by Playwright in a test.
41 | Docs here: https://playwright.dev/docs/trace-viewer
42 |
--------------------------------------------------------------------------------
/docs/src/banner_img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zenika-open-source/FAQ/0af0e99b98f96e0549a0ca51a1ff96ff03fc74ac/docs/src/banner_img.png
--------------------------------------------------------------------------------
/docs/src/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Remove previously generated files
4 | rm -rf static/ public/ assets.json index.html index.mdx
5 |
6 | # Create index.mdx based on README.md
7 | (cat src/index.prefix.txt && cat ../README.md) > index.mdx
8 |
9 | # Generate docz files
10 | docz build
11 |
12 | # Move into docs/
13 | mv dist/* ./
14 |
15 | # Delete empty directory
16 | rmdir dist/
--------------------------------------------------------------------------------
/docs/src/index.prefix.txt:
--------------------------------------------------------------------------------
1 | ---
2 | name: Introduction
3 | route: /
4 | ---
5 |
6 |
--------------------------------------------------------------------------------
/docs/static/js/11.5791dc59.js:
--------------------------------------------------------------------------------
1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[11],{"./.docz/app/imports.js":function(n,t,d){"use strict";d.r(t),d.d(t,"imports",(function(){return e}));var e={"index.mdx":function(){return d.e(1).then(d.bind(null,"./index.mdx"))},"src/getting-started.mdx":function(){return d.e(9).then(d.bind(null,"./src/getting-started.mdx"))},"src/advanced/backing_services.mdx":function(){return d.e(3).then(d.bind(null,"./src/advanced/backing_services.mdx"))},"src/advanced/contributing.mdx":function(){return d.e(5).then(d.bind(null,"./src/advanced/contributing.mdx"))},"src/advanced/integrations.mdx":function(){return d.e(6).then(d.bind(null,"./src/advanced/integrations.mdx"))},"src/advanced/configuration.mdx":function(){return d.e(4).then(d.bind(null,"./src/advanced/configuration.mdx"))},"src/advanced/multi_tenancy.mdx":function(){return d.e(7).then(d.bind(null,"./src/advanced/multi_tenancy.mdx"))},"src/advanced/testing.mdx":function(){return d.e(8).then(d.bind(null,"./src/advanced/testing.mdx"))}}}},0,[1,3,4,5,6,7,8,9]]);
2 | //# sourceMappingURL=11.c37236e96cca911241e2.js.map
--------------------------------------------------------------------------------
/docs/static/js/11.c37236e96cca911241e2.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["webpack:///./.docz/app/imports.js"],"names":["imports"],"mappings":"gHAAA,+CAAO,IAAMA,EAAU,CACrB,YAAa,WAAF,OACT,yCACF,0BAA2B,WAAF,OACvB,uDAGF,oCAAqC,WAAF,OACjC,iEAGF,gCAAiC,WAAF,OAC7B,6DAGF,gCAAiC,WAAF,OAC7B,6DAGF,iCAAkC,WAAF,OAC9B,8DAGF,iCAAkC,WAAF,OAC9B,8DAGF,2BAA4B,WAAF,OACxB,2D","file":"static/js/11.5791dc59.js","sourcesContent":["export const imports = {\n 'index.mdx': () =>\n import(/* webpackPrefetch: true, webpackChunkName: \"index\" */ 'index.mdx'),\n 'src/getting-started.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-getting-started\" */ 'src/getting-started.mdx'\n ),\n 'src/advanced/backing_services.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-backing-services\" */ 'src/advanced/backing_services.mdx'\n ),\n 'src/advanced/contributing.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-contributing\" */ 'src/advanced/contributing.mdx'\n ),\n 'src/advanced/integrations.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-integrations\" */ 'src/advanced/integrations.mdx'\n ),\n 'src/advanced/configuration.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-configuration\" */ 'src/advanced/configuration.mdx'\n ),\n 'src/advanced/multi_tenancy.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-multi-tenancy\" */ 'src/advanced/multi_tenancy.mdx'\n ),\n 'src/advanced/testing.mdx': () =>\n import(\n /* webpackPrefetch: true, webpackChunkName: \"src-advanced-testing\" */ 'src/advanced/testing.mdx'\n ),\n}\n"],"sourceRoot":""}
--------------------------------------------------------------------------------
/docs/static/js/app.c37236e96cca911241e2.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["webpack:///./.docz/app/index.jsx","webpack:///./.docz/app/root.jsx","webpack:///./src/Wrapper.jsx"],"names":["_onPreRenders","_onPostRenders","onPreRender","forEach","f","onPostRender","root","document","querySelector","Component","arguments","length","undefined","Root","ReactDOM","render","React","createElement","Theme","wrapper","Wrapper","hot","module","__docgenInfo","_ref","children","className"],"mappings":"o0MAAA,uIAIMA,EAAgB,GAChBC,EAAiB,GAEjBC,EAAc,WAAH,OAASF,EAAcG,SAAQ,SAAAC,GAAC,OAAIA,GAAKA,QACpDC,EAAe,WAAH,OAASJ,EAAeE,SAAQ,SAAAC,GAAC,OAAIA,GAAKA,QAEtDE,EAAOC,SAASC,cAAc,UACrB,WAAuB,IAAtBC,EAASC,UAAAC,OAAA,QAAAC,IAAAF,UAAA,GAAAA,UAAA,GAAGG,IAC1BX,IACAY,IAASC,OAAOC,IAAAC,cAACR,EAAS,MAAKH,EAAMD,GAGvCU,CAAOF,M,qDChBP,gMAMMA,EAAO,WAAH,OAASG,IAAAC,cAACC,IAAK,CAACC,QAASC,OAEpBC,kBAAIC,EAAJD,CAAYR,GAAKA,EAAAU,aAAA,iD,2QCHjB,aAAAC,GAAA,IAAGC,EAAQD,EAARC,SAAQ,OAAOT,IAAAC,cAAA,OAAKS,UAAU,sBAAsBD,K","file":"static/js/app.0f57e38e.js","sourcesContent":["import React from 'react'\nimport ReactDOM from 'react-dom'\nimport Root from './root'\n\nconst _onPreRenders = []\nconst _onPostRenders = []\n\nconst onPreRender = () => _onPreRenders.forEach(f => f && f())\nconst onPostRender = () => _onPostRenders.forEach(f => f && f())\n\nconst root = document.querySelector('#root')\nconst render = (Component = Root) => {\n onPreRender()\n ReactDOM.render( , root, onPostRender)\n}\n\nrender(Root)\n","import React from 'react'\nimport { hot } from 'react-hot-loader'\nimport Theme from 'docz-theme-default'\n\nimport Wrapper from 'src/Wrapper'\n\nconst Root = () => \n\nexport default hot(module)(Root)\n","import React from 'react'\n\nimport '../../client/src/styles'\nimport './Wrapper.css'\n\nexport default ({ children }) => {children}
\n"],"sourceRoot":""}
--------------------------------------------------------------------------------
/e2e/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "node": true
5 | },
6 | "extends": ["standard", "prettier", "plugin:playwright/playwright-test"],
7 | "parserOptions": {
8 | "ecmaVersion": 2019,
9 | "sourceType": "module"
10 | },
11 | "rules": {
12 | "no-console": 1,
13 | "linebreak-style": ["error", "unix"],
14 | "space-before-function-paren": [
15 | "error",
16 | {
17 | "anonymous": "never",
18 | "named": "never",
19 | "asyncArrow": "always"
20 | }
21 | ],
22 | "playwright/no-wait-for-timeout": "off",
23 | "playwright/no-page-pause": "error"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/e2e/.gitignore:
--------------------------------------------------------------------------------
1 | results.xml
--------------------------------------------------------------------------------
/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "test": "dotenv -e ../server/.env.local -- npx playwright test",
4 | "test:headed": "dotenv -e ../server/.env.local -- npx playwright test --headed",
5 | "test:trace": "dotenv -e ../server/.env.local -- npx playwright test --trace on",
6 | "lint": "eslint ./*.test.js",
7 | "lint:fix": "npm run lint -- --fix",
8 | "prettier": "prettier ./*.js",
9 | "prettier:write": "npm run prettier -- --write",
10 | "prettier:check": "npm run prettier -- --check"
11 | },
12 | "devDependencies": {
13 | "@playwright/test": "^1.34.3",
14 | "dotenv": "^16.0.3",
15 | "dotenv-cli": "^7.1.0",
16 | "eslint": "^8.36.0",
17 | "eslint-config-prettier": "^8.8.0",
18 | "eslint-config-standard": "^17.0.0",
19 | "eslint-plugin-node": "^11.1.0",
20 | "eslint-plugin-playwright": "^0.12.0",
21 | "eslint-plugin-promise": "^6.1.1",
22 | "husky": "^8.0.3",
23 | "prettier": "^1.19.1",
24 | "pretty-quick": "^1.11.1"
25 | },
26 | "husky": {
27 | "hooks": {
28 | "pre-commit": "pretty-quick --staged"
29 | }
30 | },
31 | "prettier": {
32 | "semi": false,
33 | "printWidth": 100,
34 | "singleQuote": true,
35 | "endOfLine": "lf"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/e2e/playwright.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@playwright/test'
2 |
3 | export default defineConfig({
4 | reporter: process.env.CI && [['junit', { outputFile: 'results.xml' }]],
5 | use: {
6 | trace: process.env.CI && 'on',
7 | screenshot: process.env.CI && 'on',
8 | video: process.env.CI && 'on',
9 | headless: true,
10 | locale: 'fr-FR',
11 | baseURL: 'http://localhost:3000'
12 | }
13 | })
14 |
--------------------------------------------------------------------------------
/e2e/translation_mocks.json:
--------------------------------------------------------------------------------
1 | {
2 | "question": {
3 | "title": "Ceci est une question",
4 | "language": "fr",
5 | "translation": {
6 | "language": "en",
7 | "text": "This is a question"
8 | }
9 | },
10 | "answer": {
11 | "content": "Ceci est une réponse",
12 | "language": "fr",
13 | "translation": {
14 | "language": "en",
15 | "text": "This is an answer"
16 | }
17 | },
18 | "newQuestion": {
19 | "title": "Ceci est une question différente",
20 | "language": "fr",
21 | "translation": {
22 | "language": "en",
23 | "text": "This is a different question"
24 | }
25 | },
26 | "newAnswer": {
27 | "content": "Ceci est une réponse différente",
28 | "language": "fr",
29 | "translation": {
30 | "language": "en",
31 | "text": "This is a different answer"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/.env.local.example:
--------------------------------------------------------------------------------
1 | PRISMA_URL=http://localhost:4466
2 | PRISMA_API_SECRET=secret-42
3 | PRISMA_MANAGEMENT_API_SECRET=my-secret-42
4 |
5 | CLOUD_TRANSLATION_API_KEY=
6 | TRANSLATION_MOCK_FILE=../e2e/translation_mocks.json
7 |
8 | DISABLE_AUTH=
9 |
10 | SERVICE_NAME=default
11 | SERVICE_STAGE=default
--------------------------------------------------------------------------------
/server/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "babel-eslint",
4 | "env": {
5 | "node": true
6 | },
7 | "globals": {
8 | "fetch": false
9 | },
10 | "extends": ["standard", "prettier", "prettier/standard"],
11 | "parserOptions": {
12 | "ecmaVersion": 2019,
13 | "sourceType": "module"
14 | },
15 | "rules": {
16 | "no-console": 1,
17 | "linebreak-style": ["error", "unix"],
18 | "space-before-function-paren": [
19 | "error",
20 | { "anonymous": "never", "named": "never", "asyncArrow": "always" }
21 | ]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/.graphqlconfig.yml:
--------------------------------------------------------------------------------
1 | projects:
2 | app:
3 | schemaPath: src/schema.graphql
4 | extensions:
5 | endpoint: http://localhost:4000
6 | endpoints:
7 | default: http://localhost:4000
8 | prisma:
9 | schemaPath: src/generated/prisma.graphql
10 | extensions:
11 | prisma: prisma/prisma.yml
12 |
--------------------------------------------------------------------------------
/server/.prettierignore:
--------------------------------------------------------------------------------
1 | src/generated/
2 |
--------------------------------------------------------------------------------
/server/prisma/Dockerfile.clever-cloud:
--------------------------------------------------------------------------------
1 | FROM prismagraphql/prisma:1.34.0
2 |
3 | ARG POSTGRESQL_ADDON_DB
4 | ARG POSTGRESQL_ADDON_HOST
5 | ARG POSTGRESQL_ADDON_PASSWORD
6 | ARG POSTGRESQL_ADDON_PORT
7 | ARG POSTGRESQL_ADDON_USER
8 | ARG MANAGEMENT_API_SECRET
9 |
10 | ENV PRISMA_CONFIG {port: 8080, managementApiSecret: "$MANAGEMENT_API_SECRET", databases: {default: {connector: postgres, database: "$POSTGRESQL_ADDON_DB", host: "$POSTGRESQL_ADDON_HOST", port: "$POSTGRESQL_ADDON_PORT", user: "$POSTGRESQL_ADDON_USER", password: "$POSTGRESQL_ADDON_PASSWORD", migrations: true, ssl: true}}}
11 |
12 | RUN apk add postgresql-client
13 |
14 | ENTRYPOINT PGPASSWORD=$POSTGRESQL_ADDON_PASSWORD psql -d $POSTGRESQL_ADDON_DB -h $POSTGRESQL_ADDON_HOST -p $POSTGRESQL_ADDON_PORT -U $POSTGRESQL_ADDON_USER -W -c "select pg_terminate_backend(locks.pid) from (select pid from pg_locks where locktype = 'advisory' and objid = 1000 and granted = true) as locks" && /app/start.sh
15 |
--------------------------------------------------------------------------------
/server/prisma/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | prisma:
4 | image: prismagraphql/prisma:1.34.10
5 | restart: always
6 | ports:
7 | - '4466:4466'
8 | environment:
9 | PRISMA_CONFIG: |
10 | port: 4466
11 | managementApiSecret: ${PRISMA_MANAGEMENT_API_SECRET}
12 | databases:
13 | default:
14 | connector: postgres
15 | host: postgres
16 | port: 5432
17 | user: prisma
18 | password: prisma
19 | migrations: true
20 |
21 | postgres:
22 | image: postgres
23 | restart: always
24 | environment:
25 | POSTGRES_USER: prisma
26 | POSTGRES_PASSWORD: prisma
27 | volumes:
28 | - postgres:/var/lib/postgresql/data
29 | ports:
30 | - '5432:5432'
31 | volumes:
32 | postgres:
33 |
--------------------------------------------------------------------------------
/server/prisma/prisma.yml:
--------------------------------------------------------------------------------
1 | endpoint: ${env:PRISMA_URL}
2 | datamodel: datamodel.graphql
3 |
4 | secret: ${env:PRISMA_API_SECRET}
5 |
6 | generate:
7 | - generator: graphql-schema
8 | output: ../src/generated/prisma.graphql
9 |
--------------------------------------------------------------------------------
/server/scripts/algolia_resync/README.md:
--------------------------------------------------------------------------------
1 | # Resync Algolia from Prisma
2 |
3 | - Place your `datamodel.graphql` and `prisma.yml` in this folder
4 |
5 | ```bash
6 | PRISMA_URL=the_url_with_service prisma export
7 | ```
8 |
9 | - Manually unzip the export into `data/`
10 |
11 | ```bash
12 | ALGOLIA_APP_ID=... ALGOLIA_API_KEY_ADMIN=... ALGOLIA_INDEX=... node index.js
13 | ```
14 |
--------------------------------------------------------------------------------
/server/scripts/graphcool_migration/README.md:
--------------------------------------------------------------------------------
1 | # Migration from Graphcool to Prisma
2 |
3 | ## Issues:
4 |
5 | The flags "unanswered" were not stored in the DB in graphcool, but they are now stored in Prisma.
6 | This script forgot to add theses flags into Prisma. (See issue #96)
7 |
8 | ## Steps:
9 |
10 | - Place your `datamodel.graphql` and `prisma.yml` in this folder
11 | - Set the correct URL and TOKEN in `export.sh`
12 | - Set the correct URL in `prisma.yml`
13 |
14 | ```bash
15 | bash export.sh
16 | node transform.js
17 | PRISMA_MANAGEMENT_API_SECRET=the_secret bash import.sh
18 | ```
19 |
20 | > Be sure to have an empty DB
21 |
22 | ```graphql
23 | mutation {
24 | deleteManyHistoryActions {
25 | count
26 | }
27 | deleteManyTags {
28 | count
29 | }
30 | deleteManyFlags {
31 | count
32 | }
33 | deleteManySources {
34 | count
35 | }
36 | deleteManyAnswers {
37 | count
38 | }
39 | deleteManyQuestions {
40 | count
41 | }
42 | deleteManyZNodes {
43 | count
44 | }
45 | deleteManyUsers {
46 | count
47 | }
48 | }
49 | ```
50 |
--------------------------------------------------------------------------------
/server/scripts/graphcool_migration/export.sh:
--------------------------------------------------------------------------------
1 | URL='https://api.graph.cool/simple/v1/cjdop7oh32ywh0122r6t651j0/export'
2 | TOKEN='A_WORKING_TOKEN'
3 |
4 | curl $URL -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -d '{"fileType":"nodes","cursor":{"table":0,"row":0,"field":0,"array":0}}' -sSv -o export.nodes.json
5 | curl $URL -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -d '{"fileType":"lists","cursor":{"table":0,"row":0,"field":0,"array":0}}' -sSv -o export.lists.json
6 | curl $URL -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -d '{"fileType":"relations","cursor":{"table":0,"row":0,"field":0,"array":0}}' -sSv -o export.relations.json
--------------------------------------------------------------------------------
/server/scripts/graphcool_migration/import.sh:
--------------------------------------------------------------------------------
1 | rm -rf data
2 | mkdir -p data/lists data/nodes data/relations
3 | mv export.lists.json data/lists/1.json
4 | mv export.nodes.json data/nodes/1.json
5 | mv export.relations.json data/relations/1.json
6 | prisma import --data data
--------------------------------------------------------------------------------
/server/scripts/graphcool_migration/transform.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 |
3 | const readJson = path =>
4 | new Promise((resolve, reject) =>
5 | fs.readFile(require.resolve(path), (err, data) => {
6 | if (err) reject(err)
7 | resolve(JSON.parse(data))
8 | })
9 | )
10 |
11 | const writeJson = path => data =>
12 | new Promise((resolve, reject) =>
13 | fs.writeFile(require.resolve(path), JSON.stringify(data), err => {
14 | if (err) reject(err)
15 | resolve()
16 | })
17 | )
18 |
19 | const transformers = {
20 | lists: data => ({
21 | valueType: 'lists',
22 | values: data.out.jsonElements
23 | }),
24 | nodes: data => {
25 | let values = data.out.jsonElements
26 |
27 | values = values.map(n => {
28 | switch (n._typeName) {
29 | case 'ZNode': {
30 | // Remove "dummy" attributes from ZNodes
31 | let { dummy, ...rest } = n
32 | return rest
33 | }
34 | case 'User': {
35 | // Change "auth0UserId" to "auth0Id" in Users
36 | let { auth0UserId, ...rest } = n
37 | return { ...rest, auth0Id: auth0UserId.split('|')[1] }
38 | }
39 | }
40 | return n
41 | })
42 |
43 | return {
44 | valueType: 'nodes',
45 | values
46 | }
47 | },
48 | relations: data => ({
49 | valueType: 'relations',
50 | values: data.out.jsonElements
51 | })
52 | }
53 |
54 | const transformAll = async () => {
55 | const types = ['lists', 'nodes', 'relations']
56 | const transformed = types.map(type =>
57 | readJson(`./export.${type}.json`)
58 | .then(transformers[type])
59 | .then(writeJson(`./export.${type}.json`))
60 | )
61 | await Promise.all(transformed)
62 | console.log('Transformation complete!')
63 | }
64 |
65 | transformAll()
66 |
--------------------------------------------------------------------------------
/server/scripts/prisma_deploy_all/index.js:
--------------------------------------------------------------------------------
1 | const { env, deployPrismaService, deployAlgoliaIndex, queryManagement } = require('../helpers')
2 |
3 | env([
4 | 'PRISMA_URL', // Implicitely required
5 | 'PRISMA_API_SECRET', // Implicitely required
6 | 'PRISMA_MANAGEMENT_API_SECRET' // Implicitely required
7 | ])
8 |
9 | const getServices = () =>
10 | queryManagement(`
11 | {
12 | listProjects {
13 | name
14 | stage
15 | }
16 | }
17 | `).then(d => d.listProjects)
18 |
19 | const main = async () => {
20 | const services = await getServices()
21 |
22 | services.map(({ name, stage }) => {
23 | deployPrismaService(name, stage)
24 | deployAlgoliaIndex(name, stage)
25 | })
26 | }
27 |
28 | main()
29 |
--------------------------------------------------------------------------------
/server/scripts/prisma_token/README.md:
--------------------------------------------------------------------------------
1 | # Generate prisma tokens
2 |
3 | ## For a service
4 |
5 | ```
6 | # bash ./token.sh service_name/service_stage
7 | bash ./token.sh default/default
8 | ```
9 |
10 | ## For the management API
11 |
12 | ```
13 | bash ./token.sh management
14 | ```
15 |
--------------------------------------------------------------------------------
/server/scripts/prisma_token/token.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [ $1 = "management" ]
4 | then
5 | prisma cluster-token
6 | else
7 | if [[ $1 =~ .*/.* ]]
8 | then
9 | PRISMA_URL="$PRISMA_URL/$1" prisma token
10 | else
11 | echo You need to use either \"service_name/service_stage\" or \"management\"
12 | fi
13 | fi
--------------------------------------------------------------------------------
/server/scripts/prisma_transfer_service/README.md:
--------------------------------------------------------------------------------
1 | # Transfer data from one service to another
2 |
3 | ## Steps:
4 |
5 | - Place your `datamodel.graphql` and `prisma.yml` in this folder
6 |
7 | ```bash
8 | node index.js --from=url_service_to_export_from --to=url_service_to_import_to
9 | # Example:
10 | node index.js --from=http://localhost:4466/default/default --to=http://localhost:4466/demo/dev
11 | ```
12 |
13 | You need to set the following environment variables:
14 |
15 | | name | value |
16 | | ----------------- | ---------------------------------------- |
17 | | PRISMA_API_SECRET | Prisma secret used for **both** services |
18 |
--------------------------------------------------------------------------------
/server/scripts/prisma_transfer_service/index.js:
--------------------------------------------------------------------------------
1 | const { execFileSync } = require('child_process')
2 |
3 | const getArgs = () =>
4 | process.argv
5 | .slice(2)
6 | .map(a => a.split('='))
7 | .reduce((acc, a) => {
8 | acc[a[0].replace('--', '')] = a[1]
9 | return acc
10 | }, {})
11 |
12 | const exportService = url =>
13 | execFileSync('prisma', ['export', '--path', './data.zip'], {
14 | env: {
15 | ...process.env,
16 | PRISMA_URL: url
17 | }
18 | })
19 |
20 | const unzipData = () => execFileSync('unzip', ['-d', 'data/', './data.zip'])
21 |
22 | const importService = url =>
23 | execFileSync('prisma', ['import', '--data', 'data'], {
24 | env: {
25 | ...process.env,
26 | PRISMA_URL: url
27 | }
28 | })
29 |
30 | const main = async () => {
31 | const args = getArgs()
32 | if (!args.from || !args.to) {
33 | console.error('You forgot either --from or --to. Check the doc!')
34 | return
35 | }
36 |
37 | exportService(args.from)
38 | unzipData()
39 | importService(args.to)
40 | }
41 |
42 | main()
43 |
--------------------------------------------------------------------------------
/server/src/directives/admin.js:
--------------------------------------------------------------------------------
1 | const { SchemaDirectiveVisitor } = require('graphql-tools')
2 | const { defaultFieldResolver } = require('graphql')
3 |
4 | class AdminDirective extends SchemaDirectiveVisitor {
5 | visitFieldDefinition(field) {
6 | const { resolve = defaultFieldResolver } = field
7 | field.resolve = (...args) => {
8 | const [, , ctx] = args
9 | if (ctx.request.user.admin || process.env.DISABLE_AUTH === 'true') {
10 | return resolve.apply(this, args)
11 | }
12 |
13 | throw new Error('You are not authorized to execute this operation.')
14 | }
15 | }
16 | }
17 |
18 | module.exports = AdminDirective
19 |
--------------------------------------------------------------------------------
/server/src/directives/index.js:
--------------------------------------------------------------------------------
1 | const AdminDirective = require('./admin.js')
2 |
3 | module.exports = { admin: AdminDirective }
4 |
--------------------------------------------------------------------------------
/server/src/endpoints/configuration.js:
--------------------------------------------------------------------------------
1 | const { getConfiguration } = require('../middlewares/configuration')
2 | const logger = require('../helpers/logger')
3 |
4 | const configurationEndpoint = multiTenant => async (req, res) =>
5 | getConfiguration(multiTenant, req, err => {
6 | res.header('Access-Control-Allow-Origin', '*')
7 |
8 | if (err) {
9 | logger.error('Error while retrieving configuration', err)
10 | res.status(500).send(`Avez-vous bien fait la commande 'npm run new_service' ?`)
11 | return
12 | }
13 |
14 | // TMP_TAGS
15 | const {
16 | title,
17 | auth0Domain,
18 | auth0ClientId,
19 | tagCategories,
20 | workplaceSharing,
21 | bugReporting
22 | } = multiTenant.current(req)._meta.configuration
23 |
24 | // An unauthenticated user can only access this part of the configuration
25 | res.json({
26 | title,
27 | auth0Domain,
28 | auth0ClientId,
29 | tagCategories,
30 | workplaceSharing,
31 | bugReporting
32 | })
33 | })
34 |
35 | module.exports = configurationEndpoint
36 |
--------------------------------------------------------------------------------
/server/src/endpoints/index.js:
--------------------------------------------------------------------------------
1 | const configuration = require('./configuration')
2 | const integrations = require('./integrations')
3 |
4 | module.exports = { configuration, integrations }
5 |
--------------------------------------------------------------------------------
/server/src/endpoints/integrations.js:
--------------------------------------------------------------------------------
1 | const slack = require('../integrations/slack')
2 |
3 | const integrationsEndpoint = multiTenant => async (req, res) => {
4 | if (req.params.name === 'slack') {
5 | return slack.respondToCommand(multiTenant.current(req), req, res)
6 | }
7 |
8 | res.status(404).send('Unknown integration')
9 | }
10 |
11 | module.exports = integrationsEndpoint
12 |
--------------------------------------------------------------------------------
/server/src/helpers/certified.js:
--------------------------------------------------------------------------------
1 | const {
2 | deleteFlagAndUpdateHistoryAndAlgolia,
3 | createFlagAndUpdateHistoryAndAlgolia
4 | } = require('./updateHistoryAndAlgolia')
5 |
6 | const type = 'certified'
7 |
8 | const deleteCertifedFlagIfNoLongerApplicable = async (history, node, tags, ctx) => {
9 | const certifiedFlag = node.flags.find(flag => flag.type === type)
10 | if (certifiedFlag) {
11 | const specialties = certifiedFlag && certifiedFlag.user.specialties
12 | const isUserSpecialist =
13 | specialties && Boolean(specialties.find(specialty => tags.includes(specialty.id)))
14 |
15 | if (!isUserSpecialist) {
16 | await deleteFlagAndUpdateHistoryAndAlgolia(history, type, ctx, node.id, certifiedFlag.id)
17 | }
18 | }
19 | }
20 |
21 | const addCertifiedFlagWhenSpecialist = async (history, user, node, nodeId, ctx) => {
22 | const tags = node.tags.map(tag => tag.label.id)
23 | const specialties = user.specialties
24 | const isUserSpecialist = Boolean(specialties.find(specialty => tags.includes(specialty.id)))
25 |
26 | if (isUserSpecialist) {
27 | await createFlagAndUpdateHistoryAndAlgolia(history, type, ctx, nodeId, user.id)
28 | }
29 | }
30 |
31 | const refreshCertifiedFlag = async (history, answer, user, ctx) => {
32 | const certifiedFlag = answer.node.flags.find(flag => flag.type === type)
33 | let isCertified = Boolean(certifiedFlag)
34 | const wasCertified = isCertified
35 | const tags = answer.node.tags.map(tag => tag.label.id)
36 | const specialties = user.specialties
37 | const isUserSpecialist = Boolean(specialties.find(specialty => tags.includes(specialty.id)))
38 |
39 | if (isUserSpecialist && !certifiedFlag) {
40 | await createFlagAndUpdateHistoryAndAlgolia(history, type, ctx, answer.node.id, user.id)
41 | isCertified = true
42 | } else if (!isUserSpecialist && certifiedFlag) {
43 | await deleteFlagAndUpdateHistoryAndAlgolia(history, type, ctx, answer.node.id, certifiedFlag.id)
44 | isCertified = false
45 | }
46 | return { isCertified, wasCertified }
47 | }
48 |
49 | module.exports = {
50 | addCertifiedFlagWhenSpecialist,
51 | deleteCertifedFlagIfNoLongerApplicable,
52 | refreshCertifiedFlag
53 | }
54 |
--------------------------------------------------------------------------------
/server/src/helpers/history.js:
--------------------------------------------------------------------------------
1 | const history = {
2 | push: (ctx, { action, model, meta, nodeId, userId }) =>
3 | ctx.prisma.mutation.createHistoryAction({
4 | data: {
5 | action,
6 | model,
7 | meta,
8 | node: { connect: { id: nodeId } },
9 | user: { connect: { id: ctx.request.user.id } }
10 | }
11 | })
12 | }
13 |
14 | module.exports = history
15 |
--------------------------------------------------------------------------------
/server/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | const slug = require('slugify')
2 |
3 | const {
4 | deleteCertifedFlagIfNoLongerApplicable,
5 | addCertifiedFlagWhenSpecialist,
6 | refreshCertifiedFlag
7 | } = require('./certified')
8 | const diffTags = require('./diffTags')
9 | const history = require('./history')
10 | const randomString = require('./randomString')
11 | const {
12 | detectLanguage,
13 | getTranslatedText,
14 | storeTranslation,
15 | translateContentAndSave
16 | } = require('./translation')
17 | const {
18 | createFlagAndUpdateHistoryAndAlgolia,
19 | deleteFlagAndUpdateHistoryAndAlgolia
20 | } = require('./updateHistoryAndAlgolia')
21 | const validateAndParseIdToken = require('./validateAndParseIdToken')
22 |
23 | const ctxUser = ctx => ctx.request.user
24 | const slugify = s => slug(s).toLowerCase()
25 |
26 | module.exports = {
27 | ctxUser,
28 | addCertifiedFlagWhenSpecialist,
29 | deleteCertifedFlagIfNoLongerApplicable,
30 | refreshCertifiedFlag,
31 | diffTags,
32 | history,
33 | randomString,
34 | slugify,
35 | detectLanguage,
36 | getTranslatedText,
37 | storeTranslation,
38 | translateContentAndSave,
39 | createFlagAndUpdateHistoryAndAlgolia,
40 | deleteFlagAndUpdateHistoryAndAlgolia,
41 | validateAndParseIdToken
42 | }
43 |
--------------------------------------------------------------------------------
/server/src/helpers/logger.js:
--------------------------------------------------------------------------------
1 | module.exports = console
2 |
--------------------------------------------------------------------------------
/server/src/helpers/randomString.js:
--------------------------------------------------------------------------------
1 | const randomString = length => {
2 | let str = ''
3 | const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
4 | for (let i = length; i > 0; i--) str += chars[Math.floor(Math.random() * chars.length)]
5 | return str
6 | }
7 |
8 | module.exports = randomString
9 |
--------------------------------------------------------------------------------
/server/src/helpers/requireText.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 |
3 | module.exports = (name, require, variables = {}) => {
4 | const txt = fs.readFileSync(require.resolve(name), 'utf8').toString()
5 | return txt.replace(/\${\n?(\w*)\n?}/gm, (_, name) => variables[name])
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/helpers/updateHistoryAndAlgolia.js:
--------------------------------------------------------------------------------
1 | const { algolia } = require('../integrations')
2 |
3 | const createFlagAndUpdateHistoryAndAlgolia = async (history, type, ctx, nodeId, userId) => {
4 | await ctx.prisma.mutation.createFlag({
5 | data: {
6 | type: type,
7 | node: { connect: { id: nodeId } },
8 | user: { connect: { id: userId } }
9 | }
10 | })
11 | await history.push(ctx, {
12 | action: 'CREATED',
13 | model: 'Flag',
14 | meta: {
15 | type
16 | },
17 | nodeId
18 | })
19 |
20 | algolia.updateNode(ctx, nodeId)
21 | }
22 |
23 | const deleteFlagAndUpdateHistoryAndAlgolia = async (history, type, ctx, nodeId, flagId) => {
24 | await ctx.prisma.mutation.deleteFlag({
25 | where: {
26 | id: flagId
27 | }
28 | })
29 | await history.push(ctx, {
30 | action: 'DELETED',
31 | model: 'Flag',
32 | meta: {
33 | type
34 | },
35 | nodeId
36 | })
37 |
38 | algolia.updateNode(ctx, nodeId)
39 | }
40 |
41 | module.exports = {
42 | createFlagAndUpdateHistoryAndAlgolia,
43 | deleteFlagAndUpdateHistoryAndAlgolia
44 | }
45 |
--------------------------------------------------------------------------------
/server/src/helpers/validateAndParseIdToken.js:
--------------------------------------------------------------------------------
1 | const jwksClient = require('jwks-rsa')
2 | const jwt = require('jsonwebtoken')
3 |
4 | const validateAndParseIdToken = (idToken, conf) =>
5 | new Promise((resolve, reject) => {
6 | const { header, payload } = jwt.decode(idToken, { complete: true })
7 | if (!header || !header.kid || !payload) reject(new Error('Invalid Token'))
8 | jwksClient({
9 | cache: true,
10 | rateLimit: true,
11 | jwksRequestsPerMinute: 1,
12 | jwksUri: `https://${conf.auth0Domain}/.well-known/jwks.json`
13 | }).getSigningKey(header.kid, (err, key) => {
14 | if (err) reject(new Error('Error getting signing key: ' + err.message))
15 | jwt.verify(idToken, key.publicKey, { algorithms: ['RS256'] }, (err, decoded) => {
16 | if (err) reject(new Error('jwt verify error: ' + err.message))
17 | resolve(decoded)
18 | })
19 | })
20 | })
21 |
22 | module.exports = validateAndParseIdToken
23 |
--------------------------------------------------------------------------------
/server/src/integrations/index.js:
--------------------------------------------------------------------------------
1 | const algolia = require('./algolia')
2 | const mailgun = require('./mailgun')
3 | const slack = require('./slack')
4 |
5 | module.exports = { algolia, mailgun, slack }
6 |
--------------------------------------------------------------------------------
/server/src/integrations/mailgun/answer/answer.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | body {
5 | background-color: white;
6 | }
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Hi ${user_name}!
16 |
17 | ${answerer_name} just answered your question:
18 |
19 |
20 |
21 | ${title_md}
22 |
23 |
24 |
25 |
26 | ${content_md}
27 |
28 |
29 |
30 |
31 | You can read the answer and its sources on the FAQ
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/server/src/integrations/mailgun/answer/answer.txt:
--------------------------------------------------------------------------------
1 | Hi ${user_name}!
2 |
3 | ${answerer_name} answered your question titled "${title}":
4 |
5 | #####
6 |
7 | ${content}
8 |
9 | #####
10 |
11 | You can read the answer and its sources on ${question_url}
12 |
13 |
--------------------------------------------------------------------------------
/server/src/integrations/mailgun/answer/index.js:
--------------------------------------------------------------------------------
1 | const mjml2html = require('mjml')
2 | const Converter = require('showdown').Converter
3 | const XSSFilter = require('showdown-xss-filter')
4 | const requireText = require('../../../helpers/requireText')
5 |
6 | const answer = {
7 | generateHtml: variables => mjml2html(requireText('./answer.mjml', require, variables)).html,
8 | generateText: variables => requireText('./answer.txt', require, variables),
9 | generateMail(node, conf, ctx) {
10 | const showdown = new Converter({
11 | openLinksInNewWindow: true,
12 | backslashEscapesHTMLTags: true,
13 | extensions: [XSSFilter]
14 | })
15 | showdown.setFlavor('github')
16 |
17 | const { origin } = ctx.request.headers
18 |
19 | const variables = {
20 | content: node.answer.content,
21 | content_md: showdown.makeHtml(node.answer.content),
22 | base_url: origin,
23 | user_name: node.question.user.name,
24 | answerer_name: node.answer.user.name,
25 | title: node.question.title,
26 | title_md: node.question.title,
27 | question_url: origin + '/q/' + node.question.slug + '-' + node.id
28 | }
29 |
30 | return {
31 | from: `FAQ Zenika `,
32 | to: node.question.user.email,
33 | subject: node.answer.user.name + ' answered your question on the FAQ!',
34 | html: this.generateHtml(variables),
35 | text: this.generateText(variables)
36 | }
37 | }
38 | }
39 |
40 | module.exports = answer
41 |
--------------------------------------------------------------------------------
/server/src/integrations/mailgun/index.js:
--------------------------------------------------------------------------------
1 | const fetch = require('isomorphic-fetch')
2 | const FormData = require('form-data')
3 | const logger = require('../../helpers/logger')
4 | const answer = require('./answer')
5 |
6 | class Mailgun {
7 | async sendNewAnswer(ctx, nodeId) {
8 | const {
9 | service: { name, stage },
10 | configuration: conf
11 | } = ctx.prisma._meta
12 |
13 | if (!conf.mailgunDomain || !conf.mailgunApiKey) {
14 | logger.warn(`Please provide a mailgun domain and api key for service ${name}/${stage}`)
15 | return null
16 | }
17 |
18 | const node = await this.getNode(ctx, nodeId)
19 |
20 | const mail = answer.generateMail(node, conf, ctx)
21 |
22 | return mail.to
23 | ? this.sendMail(mail, conf)
24 | : Promise.reject(new Error('Email not sent, no address found in user'))
25 | }
26 |
27 | async sendMail({ from, to, subject, text, html }, conf) {
28 | const token = Buffer.from(`api:${conf.mailgunApiKey}`).toString('base64')
29 | const endpoint = `https://api.eu.mailgun.net/v3/${conf.mailgunDomain}/messages`
30 |
31 | const form = new FormData()
32 | form.append('from', from)
33 | form.append('to', to)
34 | form.append('subject', subject)
35 | form.append('text', text)
36 | form.append('html', html)
37 |
38 | const response = await fetch(endpoint, {
39 | headers: {
40 | Authorization: `Basic ${token}`
41 | },
42 | method: 'POST',
43 | body: form
44 | }).then(response => response.json())
45 |
46 | return response
47 | }
48 |
49 | getNode(ctx, nodeId) {
50 | return ctx.prisma.query.zNode(
51 | { where: { id: nodeId } },
52 | `
53 | {
54 | id
55 | question {
56 | title
57 | slug
58 | user {
59 | name
60 | email
61 | }
62 | }
63 | answer {
64 | content
65 | user {
66 | name
67 | }
68 | }
69 | }
70 | `
71 | )
72 | }
73 | }
74 |
75 | const mailgun = new Mailgun()
76 |
77 | module.exports = mailgun
78 |
--------------------------------------------------------------------------------
/server/src/middlewares/configuration.js:
--------------------------------------------------------------------------------
1 | const getConfiguration = async (multiTenant, req, next) => {
2 | const tenant = multiTenant.current(req)
3 |
4 | if (tenant._meta.configuration) {
5 | next()
6 | return
7 | }
8 |
9 | try {
10 | await refreshConfiguration(tenant)
11 | } catch (err) {
12 | next(err)
13 | return
14 | }
15 | next()
16 | }
17 |
18 | const refreshConfiguration = async tenant => {
19 | const conf = await tenant.query.configuration(
20 | {
21 | where: { name: 'default' }
22 | },
23 | `{
24 | id
25 | name
26 | title
27 | auth0Domain
28 | auth0ClientId
29 | authorizedDomains
30 | algoliaAppId
31 | algoliaApiKey
32 | algoliaSynonyms
33 | mailgunDomain
34 | mailgunApiKey
35 | slackChannelHook
36 | tagCategories {
37 | id
38 | order
39 | name
40 | labels {
41 | id
42 | order
43 | name
44 | }
45 | }
46 | workplaceSharing
47 | bugReporting
48 | }`
49 | )
50 | if (!conf) {
51 | throw new TypeError(
52 | `could not find configuration with name "default" for service "${tenant._meta.service.name}" and stage "${tenant._meta.service.stage}"`
53 | )
54 | }
55 | // TMP_TAGS
56 | tenant._meta.configuration = conf
57 | }
58 |
59 | module.exports = { getConfiguration, refreshConfiguration }
60 |
--------------------------------------------------------------------------------
/server/src/middlewares/error.js:
--------------------------------------------------------------------------------
1 | const logger = require('../helpers/logger')
2 |
3 | const handling = (err, req, res, next) => {
4 | if (err) {
5 | if (process.env.NODE_ENV !== 'production') {
6 | logger.error(err)
7 | }
8 | return res.status(err.status || 500).json(err)
9 | }
10 | next()
11 | }
12 |
13 | module.exports = { handling }
14 |
--------------------------------------------------------------------------------
/server/src/middlewares/first-user.js:
--------------------------------------------------------------------------------
1 | const getFirstUserFlag = async (multiTenant, req, next) => {
2 | const tenant = multiTenant.current(req)
3 |
4 | if (tenant._meta.isFirstUser) {
5 | next()
6 | return
7 | }
8 |
9 | await refreshFirstUserFlag(tenant).catch(next)
10 |
11 | next()
12 | }
13 |
14 | const refreshFirstUserFlag = async tenant => {
15 | const count = (await tenant.query.usersConnection({}, '{ aggregate { count } }')).aggregate.count
16 | tenant._meta.isFirstUser = count === 0
17 | }
18 |
19 | module.exports = { getFirstUserFlag, refreshFirstUserFlag }
20 |
--------------------------------------------------------------------------------
/server/src/middlewares/index.js:
--------------------------------------------------------------------------------
1 | const auth = require('./auth')
2 | const error = require('./error')
3 | const { getConfiguration } = require('./configuration')
4 | const { getFirstUserFlag } = require('./first-user')
5 |
6 | module.exports = { auth, error, getConfiguration, getFirstUserFlag }
7 |
--------------------------------------------------------------------------------
/server/src/multiTenant.js:
--------------------------------------------------------------------------------
1 | const { Prisma } = require('prisma-binding')
2 | const { MultiTenant } = require('prisma-multi-tenant')
3 | const path = require('path')
4 | const logger = require('./helpers/logger')
5 |
6 | const multiTenant = new MultiTenant({
7 | instanciate: (name, stage) => {
8 | const endpoint = process.env.PRISMA_URL + '/' + name + '/' + stage
9 | logger.info(
10 | `instanciating prisma client for service "${name}", stage "${stage}", and endpoint "${endpoint}"`
11 | )
12 | return new Prisma({
13 | typeDefs: path.join(__dirname, '/generated/prisma.graphql'),
14 | endpoint,
15 | secret: process.env.PRISMA_API_SECRET
16 | })
17 | },
18 | nameStageFromReq: req => {
19 | // Env vars take precedence
20 | if (process.env.SERVICE_NAME) {
21 | const [name, stage] = [process.env.SERVICE_NAME, process.env.SERVICE_STAGE || 'prod']
22 | return [name, stage]
23 | }
24 |
25 | // Prefered header: faq-tenant
26 | if (req.headers['faq-tenant']) {
27 | const [name, stage] = req.headers['faq-tenant'].match(/([^/]+)\/([^/]+)/).splice(1, 2)
28 | return [name, stage]
29 | }
30 |
31 | // Alternative header (legacy): prisma-service
32 | if (req.headers['prisma-service']) {
33 | const [name, stage] = req.headers['prisma-service'].match(/([^/]+)\/([^/]+)/).splice(1, 2)
34 | return [name, stage]
35 | }
36 |
37 | // If no header found, try to guess using the host
38 | const hostParts = req.hostname.split('.')
39 |
40 | const [, , name = 'default', stage = 'prod'] = hostParts.reverse()
41 | return [name, stage]
42 | }
43 | })
44 |
45 | module.exports = multiTenant
46 |
--------------------------------------------------------------------------------
/server/src/resolvers/configuration.js:
--------------------------------------------------------------------------------
1 | const { algolia } = require('../integrations')
2 | const { refreshConfiguration } = require('../middlewares/configuration')
3 | const { randomString, diffTags } = require('../helpers')
4 |
5 | module.exports = {
6 | Query: {
7 | configuration: (_, args, ctx, info) =>
8 | ctx.prisma.query.configuration({ where: { name: 'default' } }, info)
9 | },
10 | Mutation: {
11 | updateConfiguration: async (_, { authorizedDomains, tagCategories, ...args }, ctx, info) => {
12 | const oldTagCategories = await ctx.prisma.query.tagCategories(
13 | null,
14 | `
15 | {
16 | id
17 | name
18 | order
19 | labels {
20 | id
21 | name
22 | order
23 | }
24 | }
25 | `
26 | )
27 |
28 | await diffTags(
29 | ctx,
30 | oldTagCategories,
31 | JSON.parse(tagCategories),
32 | ctx.prisma._meta.configuration.id
33 | )
34 |
35 | const configuration = await ctx.prisma.mutation.updateConfiguration(
36 | {
37 | where: { name: 'default' },
38 | data: { authorizedDomains: { set: authorizedDomains }, ...args }
39 | },
40 | info
41 | )
42 |
43 | algolia.resyncSynonyms(ctx, args.algoliaSynonyms)
44 |
45 | refreshConfiguration(ctx.prisma)
46 |
47 | return configuration
48 | },
49 | regenerateSlackCommandKey: (_, args, ctx, info) => {
50 | return ctx.prisma.mutation.updateConfiguration(
51 | {
52 | where: { name: 'default' },
53 | data: {
54 | slackCommandKey: randomString(20)
55 | }
56 | },
57 | info
58 | )
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/server/src/resolvers/flag.js:
--------------------------------------------------------------------------------
1 | const {
2 | ctxUser,
3 | createFlagAndUpdateHistoryAndAlgolia,
4 | deleteFlagAndUpdateHistoryAndAlgolia,
5 | history
6 | } = require('../helpers')
7 |
8 | module.exports = {
9 | Mutation: {
10 | addFlag: async (_, { type, nodeId }, ctx, info) => {
11 | const flag = await ctx.prisma.exists.Flag({ node: { id: nodeId }, type })
12 |
13 | if (!flag) {
14 | await createFlagAndUpdateHistoryAndAlgolia(history, type, ctx, nodeId, ctxUser(ctx).id)
15 | }
16 |
17 | if (type === 'certified') {
18 | const node = await ctx.prisma.query.zNode(
19 | {
20 | where: { id: nodeId }
21 | },
22 | `{
23 | id
24 | answer {
25 | id
26 | }
27 | }`
28 | )
29 | await ctx.prisma.mutation.updateAnswer({
30 | where: { id: node.answer.id },
31 | data: { certified: '' }
32 | })
33 | }
34 |
35 | return ctx.prisma.query.zNode({ where: { id: nodeId } }, info)
36 | },
37 | removeFlag: async (_, { type, nodeId }, ctx, info) => {
38 | const flags = await ctx.prisma.query.flags(
39 | {
40 | where: { node: { id: nodeId }, type }
41 | },
42 | '{ id }'
43 | )
44 |
45 | if (flags) {
46 | await deleteFlagAndUpdateHistoryAndAlgolia(history, type, ctx, nodeId, flags[0].id)
47 | }
48 |
49 | return ctx.prisma.query.zNode({ where: { id: nodeId } }, info)
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/resolvers/history.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | Query: {
3 | history: async (_, { first, skip, ...args }, ctx, info) => {
4 | const entriesCount = (
5 | await ctx.prisma.query.historyActionsConnection(args, '{ aggregate { count } }')
6 | ).aggregate.count
7 |
8 | const meta = {
9 | entriesCount,
10 | pageCurrent: skip / first + 1,
11 | pagesCount: Math.ceil(entriesCount / first)
12 | }
13 |
14 | return { meta, first, skip, ...args }
15 | }
16 | },
17 | History: {
18 | historyActions: (parent, args, ctx, info) => ctx.prisma.query.historyActions(parent, info)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/resolvers/index.js:
--------------------------------------------------------------------------------
1 | const resolvers = [
2 | 'answer',
3 | 'configuration',
4 | 'flag',
5 | 'history',
6 | 'question',
7 | 'random',
8 | 'search',
9 | 'user'
10 | ]
11 |
12 | const mergeResolvers = resolvers =>
13 | resolvers.reduce((acc, res) => {
14 | Object.keys(res).map(type => {
15 | if (!acc[type]) acc[type] = {}
16 |
17 | acc[type] = { ...acc[type], ...res[type] }
18 | })
19 |
20 | return acc
21 | }, {})
22 |
23 | module.exports = mergeResolvers(resolvers.map(path => require('./' + path)))
24 |
--------------------------------------------------------------------------------
/server/src/resolvers/random.js:
--------------------------------------------------------------------------------
1 | // TMP_TAGS
2 |
3 | module.exports = {
4 | Query: {
5 | randomNode: async (_, { tag }, ctx, info) => {
6 | let id
7 |
8 | if (tag) {
9 | const { count } = (
10 | await ctx.prisma.query.tagsConnection(
11 | {
12 | where: { label: { name: tag } }
13 | },
14 | `
15 | {
16 | aggregate {
17 | count
18 | }
19 | }
20 | `
21 | )
22 | ).aggregate
23 |
24 | const randomIndex = Math.floor(Math.random() * count)
25 |
26 | const randomTag = (
27 | await ctx.prisma.query.tags(
28 | { skip: randomIndex, take: 1, where: { label: { name: tag } } },
29 | '{ node { id } }'
30 | )
31 | )[0]
32 |
33 | id = randomTag.node.id
34 | } else {
35 | const { count } = (
36 | await ctx.prisma.query.questionsConnection(
37 | {},
38 | `
39 | {
40 | aggregate {
41 | count
42 | }
43 | }
44 | `
45 | )
46 | ).aggregate
47 |
48 | const randomIndex = Math.floor(Math.random() * count)
49 |
50 | const randomQuestion = (
51 | await ctx.prisma.query.questions({ skip: randomIndex, take: 1 }, '{ node { id } }')
52 | )[0]
53 |
54 | id = randomQuestion.node.id
55 | }
56 |
57 | return ctx.prisma.query.zNode({ where: { id } }, info)
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/server/src/resolvers/search.js:
--------------------------------------------------------------------------------
1 | const { algolia } = require('../integrations')
2 |
3 | module.exports = {
4 | Query: {
5 | search: async (_, args, ctx) => {
6 | const { text, tags = [], flags = [], skip, first, ...params } = args
7 |
8 | let results = {
9 | meta: {
10 | pageCurrent: skip / first + 1
11 | },
12 | skip,
13 | first,
14 | ...params
15 | }
16 |
17 | if (!text && tags.length === 0 && flags.length === 0) {
18 | const count = (await ctx.prisma.query.zNodesConnection(params, '{ aggregate { count } }'))
19 | .aggregate.count
20 |
21 | results = {
22 | ...results,
23 | ids: null,
24 | count
25 | }
26 | } else {
27 | const { ids, highlights, nbHits } = await algolia.search(ctx, args)
28 |
29 | results = {
30 | ...results,
31 | ids,
32 | highlights,
33 | count: nbHits
34 | }
35 | }
36 |
37 | return {
38 | ...results,
39 | meta: {
40 | ...results.meta,
41 | entriesCount: results.count,
42 | pagesCount: Math.ceil(results.count / first)
43 | }
44 | }
45 | }
46 | },
47 | SearchResult: {
48 | nodes: async ({ ids, highlights, ...params }, args, ctx, info) => {
49 | if (!ids) {
50 | return ctx.prisma.query.zNodes({ orderBy: 'createdAt_DESC', ...params }, info)
51 | }
52 |
53 | let nodes = await ctx.prisma.query.zNodes(
54 | {
55 | where: {
56 | id_in: ids
57 | }
58 | },
59 | info
60 | )
61 |
62 | nodes = nodes
63 | .map(node => ({ ...node, highlights: highlights[node.id] }))
64 | .sort((a, b) => {
65 | return ids.indexOf(a.id) - ids.indexOf(b.id)
66 | })
67 |
68 | return nodes
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------