├── .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 | ![FAQ Zenika](https://raw.githubusercontent.com/zenika-open-source/FAQ/main/docs/src/banner_img.png) 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 | ![with love by zenika](https://img.shields.io/badge/With%20%E2%9D%A4%EF%B8%8F%20by-Zenika-b51432.svg?link=https://oss.zenika.com) 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 => ) 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 | {alt 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 | 20 | 21 | 22 |
23 | 26 | 29 | 32 |
33 | 36 | 39 | 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 | 40 | 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 | }> 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 |
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 | 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 |
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 |
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 |
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 |
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 |
17 | 18 |
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 |
28 |
34 |
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 | ![FAQ Zenika](https://raw.githubusercontent.com/zenika-open-source/FAQ/main/docs/src/banner_img.png) 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 | ![with love by zenika](https://img.shields.io/badge/With%20%E2%9D%A4%EF%B8%8F%20by-Zenika-b51432.svg?link=https://oss.zenika.com) 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 | --------------------------------------------------------------------------------