Merge pull request #29 from boazpoolman/develop

Develop
pull/31/head 1.0.0-alpha.1
Boaz Poolman 2021-12-08 14:06:01 +01:00 committed by GitHub
commit 75197b9070
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 15249 additions and 1278 deletions

View File

@ -1,7 +1,12 @@
root = true root = true
[*] [*]
end_of_line = lf
insert_final_newline = false
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
end_of_line = LF
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
**/node_modules
**/public
**/build
**/config
**/scripts
**/xsl

505
.eslintrc Normal file
View File

@ -0,0 +1,505 @@
{
"root": true,
"extends": ["react-app", "airbnb"],
"parser": "babel-eslint",
"plugins": [
"babel",
"react",
"jsx-a11y",
"import",
"react-hooks"
],
"env": {
"browser": true,
"es6": true,
"commonjs": true,
"node": true
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"globals": {
"strapi": true
},
"rules": {
"template-curly-spacing" : "off",
"indent" : "off",
"react/jsx-props-no-spreading": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "off",
"react/no-unused-prop-types": "warn",
"react/jsx-no-target-blank": "error",
"no-invalid-this": "off",
"babel/no-invalid-this": "error",
"arrow-spacing": "warn",
"implicit-arrow-linebreak": "warn",
"react/no-unused-state": "warn",
"react/boolean-prop-naming": "off",
"react/destructuring-assignment": ["warn", "always", { "ignoreClassFields": true }],
"react/no-access-state-in-setstate": "warn",
"operator-linebreak": "warn",
"no-useless-constructor": "warn",
"react/no-danger": "off",
"react/jsx-indent-props": "warn",
"react/jsx-curly-brace-presence": "warn",
"react/jsx-key": "error",
"react/jsx-boolean-value": "warn",
"react/jsx-closing-tag-location": "warn",
"import/extensions": "error",
"newline-per-chained-call": "warn",
"prefer-arrow-callback": "warn",
"block-spacing": "warn",
"one-var-declaration-per-line": "warn",
"prefer-const": "warn",
"import/first": "off",
"react/jsx-max-props-per-line": 1,
"react/jsx-first-prop-new-line": "warn",
"react/jsx-equals-spacing": "warn",
"react/jsx-indent": "warn",
"react/jsx-closing-bracket-location": "off",
"import/no-mutable-exports": "error",
"import/no-extraneous-dependencies": "off",
"object-shorthand": ["off", "never"],
"object-curly-newline": "off",
"arrow-body-style": "off",
"comma-dangle": ["warn", "always-multiline"],
"import/prefer-default-export": "off",
"no-cond-assign": "warn",
"no-confusing-arrow": "off",
"no-console": "off",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-continue": "warn",
"react/forbid-prop-types": "warn",
"no-debugger": "warn",
"no-dupe-args": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "warn",
"no-empty-character-class": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "warn",
"no-extra-semi": "warn",
"no-func-assign": "error",
"no-inner-declarations": "error",
"no-invalid-regexp": "error",
"no-mixed-operators": "off",
"no-irregular-whitespace": "error",
"no-negated-in-lhs": "error",
"no-obj-calls": "error",
"no-regex-spaces": "warn",
"no-sparse-arrays": "error",
"no-unreachable": "warn",
"use-isnan": "error",
"valid-jsdoc": "warn",
"valid-typeof": "error",
"array-callback-return": "off",
"block-scoped-var": "off",
"prefer-destructuring": "warn",
"complexity": "off",
"consistent-return": "off",
"curly": "warn",
"default-case": "warn",
"dot-notation": "off",
"eqeqeq": "warn",
"guard-for-in": "off",
"no-alert": "warn",
"no-caller": "error",
"no-div-regex": "off",
"no-else-return": "off",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-fallthrough": "error",
"no-floating-decimal": "error",
"no-implied-eval": "error",
"no-iterator": "error",
"no-labels": "off",
"no-lone-blocks": "warn",
"no-loop-func": "error",
"no-multi-spaces": "warn",
"no-multi-str": "error",
"no-native-reassign": "error",
"no-new": "warn",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-octal": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-process-env": "off",
"no-proto": "error",
"no-redeclare": "error",
"no-return-assign": "off",
"arrow-parens": ["warn", "always", { "requireForBlockBody": false }],
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"no-unused-expressions": "warn",
"no-void": "error",
"no-with": "error",
"radix": "off",
"vars-on-top": "off",
"wrap-iife": "error",
"yoda": "warn",
"strict": "off",
"no-catch-shadow": "error",
"no-delete-var": "error",
"no-label-var": "error",
"no-shadow": "warn",
"no-shadow-restricted-names": "error",
"no-undef": "error",
"no-undef-init": "error",
"no-multi-assign": "warn",
"no-undefined": "error",
"no-unused-vars": ["warn", { "args": "none", "ignoreRestSiblings": true }],
"no-use-before-define": [
"error",
{ "functions": false, "classes": true, "variables": true }
],
"no-restricted-properties": "warn",
"no-restricted-syntax": "warn",
"brace-style": "off",
"camelcase": "warn",
"comma-spacing": ["warn", { "before": false, "after": true }],
"comma-style": ["warn", "last"],
"consistent-this": ["off", "_this"],
"eol-last": "warn",
"func-names": "off",
"func-style": ["warn", "declaration", { "allowArrowFunctions": true }],
"key-spacing": ["warn", { "beforeColon": false, "afterColon": true }],
"max-nested-callbacks": ["warn", 5],
"new-cap": ["warn", { "newIsCap": true, "capIsNew": false }],
"new-parens": "warn",
"newline-after-var": "off",
"no-array-constructor": "off",
"no-inline-comments": "off",
"no-lonely-if": "warn",
"no-mixed-spaces-and-tabs": "warn",
"no-multiple-empty-lines": ["warn", { "max": 2 }],
"no-nested-ternary": "warn",
"no-new-object": "off",
"no-spaced-func": "warn",
"no-ternary": "off",
"no-trailing-spaces": "warn",
"no-underscore-dangle": "off",
"no-extra-parens": "off",
"padding-line-between-statements": "off",
"one-var": ["warn", "never"],
"operator-assignment": ["off", "never"],
"class-methods-use-this": "off",
"padded-blocks": ["off", "never"],
"lines-between-class-members": ["warn", "always"],
"quote-props": ["warn", "as-needed"],
"quotes": ["off", "single"],
"semi": ["warn", "always"],
"semi-spacing": ["warn", { "before": false, "after": true }],
"sort-vars": "off",
"keyword-spacing": ["warn", { "before": true, "after": true }],
"space-before-blocks": ["warn", "always"],
"function-paren-newline": "off",
"space-before-function-paren": ["warn", { "anonymous": "never", "named": "never" }],
"object-curly-spacing": ["warn", "always"],
"array-bracket-spacing": ["warn", "never"],
"computed-property-spacing": ["warn", "never"],
"space-in-parens": ["warn", "never"],
"space-infix-ops": "warn",
"space-unary-ops": ["warn", { "words": true, "nonwords": false }],
"spaced-comment": ["warn", "always"],
"wrap-regex": "off",
"no-var": "error",
"generator-star-spacing": ["error", "before"],
"max-depth": ["warn", 4],
"max-len": ["off", 80, 2],
"max-params": ["off", 99],
"max-statements": "off",
"no-bitwise": "off",
"no-plusplus": "off",
"react/display-name": "off",
"react/jsx-tag-spacing": "warn",
"jsx-quotes": ["warn", "prefer-double"],
"react/jsx-no-undef": "error",
"react/jsx-sort-props": "off",
"react/jsx-uses-react": "error",
"react/prefer-stateless-function": "warn",
"react/jsx-uses-vars": "error",
"react/jsx-no-bind": "error",
"react/no-did-mount-set-state": "warn",
"react/no-will-update-set-state": "warn",
"react/no-did-update-set-state": "warn",
"react/no-multi-comp": "off",
"react/no-unknown-property": "warn",
"react/prop-types": "off",
"react/react-in-jsx-scope": "error",
"react/self-closing-comp": "warn",
"react/jsx-wrap-multilines": "warn",
"react/no-array-index-key": "warn",
"react/no-unescaped-entities": "warn",
"react/sort-comp": "off",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"react/jsx-one-expression-per-line": "off",
"jsx-a11y/anchor-is-valid": "off",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/label-has-for": [
"warn",
{
"required": {
"some": ["nesting", "id"]
}
}
],
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/iframe-has-title": "warn",
"jsx-a11y/anchor-has-content": "off",
"jsx-a11y/label-has-associated-control": "warn",
"jsx-a11y/mouse-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/no-distracting-elements": "warn",
"jsx-a11y/heading-has-content": "warn",
"jsx-a11y/html-has-lang": "warn",
"jsx-a11y/href-no-hash": "off",
"react/jsx-filename-extension": "off",
"jsx-a11y/no-noninteractive-tabindex": "warn",
"jsx-a11y/media-has-caption": "off"
}
}

46
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,46 @@
---
name: 🐛 Bug Report
about: Create a report to help improve this plugin
---
<!--
Hello 👋 Thank you for submitting an issue.
-->
## Bug report
### Describe the bug
A clear and concise description of what the bug is.
### Steps to reproduce the behavior
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
### Expected behavior
A clear and concise description of what you expected to happen.
### Screenshots
If applicable, add screenshots to help explain your problem.
### Code snippets
If applicable, add code samples to help explain your problem.
### System
- Node.js version: <!-- Please ensure you are using the Node LTS version (v12 / v14) -->
- NPM version:
- Strapi version:
- Plugin version:
- Database:
- Operating system:
### Additional context
Add any other context about the problem here.

View File

@ -0,0 +1,26 @@
---
name: 🚀 Feature Request
about: Suggest an idea to help make this plugin even better!
---
<!--
Hello 👋 Thank you for submitting a feature request.
-->
## Feature request
### Summary
Quick summary what's this feature request about.
### Why is it needed?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
### Suggested solution(s)
A clear and concise description of what you want to happen.
### Related issue(s)/PR(s)
Let us know if this is related to any issue/pull request.

26
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,26 @@
<!--
Hello 👋 Thank you for submitting a pull request.
To help us merge your PR, make sure to follow the instructions below:
- Create or update the documentation.
- Create or update the tests.
- Refer to the issue you are closing in the PR description - fix #issue
- Specify if the PR is in WIP (work in progress) state or ready to be merged
-->
### What does it do?
Describe the technical changes you did.
### Why is it needed?
Describe the issue you are solving.
### How to test it?
Provide information about the environment and the path to verify the behaviour.
### Related issue(s)/PR(s)
Let us know if this is related to any issue/pull request

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 325 KiB

37
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Tests
on:
push:
branches:
- master
- develop
pull_request:
jobs:
lint:
name: 'lint'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
cache: 'yarn'
- name: Install dependencies
run: yarn --ignore-scripts --frozen-lockfile
- name: Run eslint
run: yarn run eslint
# unit:
# name: 'unit'
# needs: [lint]
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v2
# - uses: actions/setup-node@v2
# with:
# node-version: '14'
# cache: 'yarn'
# - name: Install dependencies
# run: yarn --ignore-scripts --frozen-lockfile
# - name: Run test
# run: yarn run -s test:unit

1
.gitignore vendored
View File

@ -3,7 +3,6 @@ coverage
node_modules node_modules
stats.json stats.json
package-lock.json package-lock.json
yarn.lock
files files
# Cruft # Cruft

1
.husky/.gitignore vendored
View File

@ -1 +0,0 @@
_

View File

@ -1,39 +0,0 @@
#!/bin/bash
Color_Off='\033[0m'
BRed="\033[1;31m" # Red
BGreen="\033[1;32m" # Green
BYellow="\033[1;33m" # Yellow
BBlue="\033[1;34m" # Blue
MSG_FILE=$1
FILE_CONTENT="$(cat $MSG_FILE)"
REGEX='(feat: |fix: |docs: |style: |refactor: |test: |chore: )'
ERROR_MSG="Commit message format must match regex \"${REGEX}\""
if [[ $FILE_CONTENT =~ $REGEX ]]; then
if [[ $FILE_CONTENT =~ (feat: ) ]]; then
printf '%s %s' ":sparkles:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (fix: ) ]]; then
printf '%s %s' ":bug:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (docs: ) ]]; then
printf '%s %s' ":memo:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (style: ) ]]; then
printf '%s %s' ":art:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (refactor: ) ]]; then
printf '%s %s' ":recycle:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (test: ) ]]; then
printf '%s %s' ":white_check_mark:" "$(cat $MSG_FILE)" >$MSG_FILE
elif [[ $FILE_CONTENT =~ (chore: ) ]]; then
printf '%s %s' ":wrench:" "$(cat $MSG_FILE)" >$MSG_FILE
fi
printf "${BGreen}Good commit!${Color_Off}"
else
printf "${BRed}Bad commit ${BBlue}\"$FILE_CONTENT\"\n"
printf "${BYellow}$ERROR_MSG\n"
printf "The semantic git commit patten is expected, for details see https://gist.github.com/boazpoolman/42629d941b5f747734d296e02ecf737d\n"
printf "commit-msg hook failed (add --no-verify to bypass)\n"
exit 1
fi
exit 0

View File

@ -1,21 +0,0 @@
#!/bin/bash
Color_Off='\033[0m'
BRed="\033[1;31m" # Red
BGreen="\033[1;32m" # Green
BYellow="\033[1;33m" # Yellow
BBlue="\033[1;34m" # Blue
LOCAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
REGEX="^(main|master|develop)|(hotfix|release|feature|test|fix|chore|docs|refactor)\/[a-z0-9._-]+$"
ERROR_MSG="Branch name must match regex \"${REGEX}\""
if [[ ! $LOCAL_BRANCH =~ $REGEX ]]; then
printf "${BRed}Bad branch name ${BBlue}\"$LOCAL_BRANCH\"\n"
printf "${BYellow}$ERROR_MSG\n"
printf "The git-flow branch patten is expected, for details see https://gist.github.com/boazpoolman/42629d941b5f747734d296e02ecf737d\n"
printf "pre-commit hook failed (add --no-verify to bypass)\n"
exit 1
fi
exit 0

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[@boazpoolman](https://twitter.com/boazpoolman) on twitter.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

97
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,97 @@
# Contributing
We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project.
## Development Workflow
To get started with the project, make sure you have a local instance of Strapi running.
See the [Strapi docs](https://github.com/strapi/strapi#getting-started) on how to setup a Strapi project.
#### 1. Fork the [repository](https://github.com/boazpoolman/strapi-plugin-config-sync)
[Go to the repository](https://github.com/boazpoolman/strapi-plugin-config-sync) and fork it to your own GitHub account.
#### 2. Clone from your repository into the plugins folder
```bash
cd YOUR_STRAPI_PROJECT/src/plugins
git clone git@github.com:YOUR_USERNAME/strapi-plugin-config-sync.git config-sync
```
#### 3. Install the dependencies
Go to the plugin and install it's dependencies.
```bash
cd YOUR_STRAPI_PROJECT/src/plugins/config-sync/ && yarn install
```
#### 4. Enable the plugin
Add the following lines to the `config/plugins.js` file in your Strapi project.
```
const path = require('path');
// ...
{
'config-sync': {
enabled: true,
resolve: path.resolve(__dirname, '../src/plugins/config-sync'),
},
}
```
#### 5. Rebuild your Strapi project
Rebuild your strapi project to build the admin part of the plugin.
```bash
cd YOUR_STRAPI_PROJECT && yarn build --clean
```
#### 6. Running the administration panel in development mode
**Start the administration panel server for development**
```bash
cd YOUR_STRAPI_PROJECT && yarn develop --watch-admin
```
The administration panel will be available at http://localhost:8080/admin
### Commit message convention
We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages:
- `fix`: bug fixes, e.g. fix crash due to deprecated method.
- `feat`: new features, e.g. add new method to the module.
- `refactor`: code refactor, e.g. migrate from class components to hooks.
- `docs`: changes into documentation, e.g. add usage example for the module..
- `test`: adding or updating tests, eg add integration tests using detox.
- `chore`: tooling changes, e.g. change CI config.
### Linting and tests
[ESLint](https://eslint.org/)
We use [ESLint](https://eslint.org/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing.
### Scripts
The `package.json` file contains various scripts for common tasks:
- `yarn eslint`: lint files with ESLint.
- `yarn eslint:fix`: auto-fix ESLint issues.
- `yarn test:unit`: run unit tests with Jest.
### Sending a pull request
> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github).
When you're sending a pull request:
- Prefer small pull requests focused on one change.
- Verify that linters and tests are passing.
- Review the documentation to make sure it looks good.
- Follow the pull request template when opening a pull request.
- For pull requests that change the API or implementation, discuss with maintainers first by opening an issue.

304
README.md
View File

@ -1,41 +1,252 @@
# Strapi Plugin Config Sync <div align="center">
<h1>Strapi config-sync plugin</h1>
A lot of configuration of your Strapi project is stored in the database. Like core_store, user permissions, user roles & webhooks. Things you might want to have the same on all environments. But when you update them locally, you will have to manually update them on all other environments too. <p style="margin-top: 0;">CLI & GUI for syncing config data across environments.</p>
That's where this plugin comes in to play. It allows you to export these configs as individual JSON files for each config, and write them somewhere in your project. With the configs written in your filesystem you can keep track of them through version control (git), and easily pull and import them across environments. <p>
<a href="https://www.npmjs.org/package/strapi-plugin-config-sync">
<img src="https://img.shields.io/npm/v/strapi-plugin-config-sync/latest.svg" alt="NPM Version" />
</a>
<a href="https://www.npmjs.org/package/strapi-plugin-config-sync">
<img src="https://img.shields.io/npm/dm/strapi-plugin-config-sync" alt="Monthly download on NPM" />
</a>
<a href="https://codecov.io/gh/boazpoolman/strapi-plugin-config-sync">
<img src="https://img.shields.io/github/workflow/status/boazpoolman/strapi-plugin-config-sync/Tests/master" alt="CI build status" />
</a>
<a href="https://codecov.io/gh/boazpoolman/strapi-plugin-config-sync">
<img src="https://codecov.io/gh/boazpoolman/strapi-plugin-config-sync/coverage.svg?branch=master" alt="codecov.io" />
</a>
</p>
</div>
Importing, exporting and keeping track of config changes is done in the admin page of the plugin. ## ✨ Features
**THIS PLUGIN IS NOT STABLE** - **CLI** (`config-sync` CLI for syncing the config from the command line)
- **GUI** (Settings page for syncing the config in Strapi admin)
- **Partial sync** (Import or export only specific portions of config)
- **Exclude configs** (Exclude specific config from the sync)
**PLEASE USE WITH CARE** ## ⏳ Installation
<img src=".github/config-diff.png" alt="Strapi config-sync changes" /> Install the plugin in your Strapi project.
## Installation ```bash
# using yarn
yarn add strapi-plugin-config-sync
Use `npm` or `yarn` to install and build the plugin. # using npm
npm install strapi-plugin-config-sync --save
```
yarn add strapi-plugin-config-sync Add the export path to the `watchIgnoreFiles` list in the `config/admin.js` file.
yarn build
yarn develop
Add the export path to the `watchIgnoreFiles` list in `config/server.js`.
This way your app won't reload when you export the config in development. This way your app won't reload when you export the config in development.
##### `config/server.js`: ##### `config/admin.js`:
```
admin: { module.exports = ({ env }) => ({
auth: {
// ... // ...
},
watchIgnoreFiles: [ watchIgnoreFiles: [
'**/config-sync/files/**', '**/config-sync/files/**',
], ],
}, });
```
After successful installation you have to rebuild the admin UI so it'll include this plugin. To rebuild and restart Strapi run:
## Settings ```bash
# using yarn
yarn build --clean
yarn develop
# using npm
npm run build --clean
npm run develop
```
The **Config Sync** plugin should appear in the **Plugins** section of Strapi sidebar after you run app again.
Enjoy 🎉
## 🖐 Requirements
Complete installation requirements are the exact same as for Strapi itself and can be found in the [Strapi documentation](https://strapi.io/documentation).
**Supported Strapi versions**:
- Strapi 4.0.0 (recently tested)
- Strapi ^4.x
- Strapi ^3.4.x (use `strapi-plugin-config-sync@0.1.6`)
(This plugin may work with older Strapi versions, but these are not tested nor officially supported at this time.)
**We recommend always using the latest version of Strapi to start your new projects**.
## 💡 Motivation
In Strapi we come across what I would call config types. These are models of which the records are stored in our database, just like content types. Though the big difference here is that your code ofter relies on the database records of these types.
Having said that, it makes sense that these records can be exported, added to git, and be migrated across environments. This way we can make sure we have all the data our code relies on, on each environment.
Examples of these types are:
- Admin roles _(admin::role)_
- User roles _(plugin::users-permissions.role)_
- Admin settings _(strapi::core-store)_
- I18n locale _(plugin::i18n.locale)_
This plugin gives you the tools to sync this data. You can export the data as JSON files on one env, and import them on every other env. By writing this data as JSON files you can easily track them in your version control system (git).
_With great power comes great responsibility - Spider-Man_
## 🔌 Command line interface (CLI)
Add the `config-sync` command as a script to the `package.json` of your Strapi project:
```
"scripts": {
// ...
"cs": "config-sync"
},
```
You can now run all the `config-sync` commands like this:
```bash
# using yarn
yarn cs --help
# using npm
npm run cs --help
```
### ⬆️ Import ⬇️ Export
> _Command:_ `import` _Alias:_ `i`
>
> _Command:_ `export` _Alias:_ `e`
These commands are used to sync the config in your Strapi project.
_Example:_
```bash
# using yarn
yarn cs import
yarn cs export
# using npm
npm run cs import
npm run cs export
```
##### Flag: `--yes` `-y`
Use this flag to skip the confirm prompt and go straight to syncing the config.
```bash
[command] --yes
```
##### Flag: `--type` `-t`
Use this flag to specify the type of config you want to sync.
```bash
[command] --type user-role
```
##### Flag: `--partial` `-p`
Use this flag to sync a specific set of configs by giving the CLI a comma-separated string of config names.
```bash
[command] --partial user-role.public,i18n-locale.en
```
### ↔️ Diff
> _Command:_ `diff` | _Alias:_ `d`
This command is used to see the difference between the config as found in the sync directory, and the config as found in the database.
_Example:_
```bash
# using yarn
yarn cs diff
# using npm
npm run cs diff
```
## 🖥️ Admin panel (GUI)
This plugin ships with a settings page which can be accessed from the admin panel of Strapi. On this page you can pretty much do the same as you can from the CLI. You can import, export and see the difference between the config as found in the sync directory, and the config as found in the database.
**Pro tip:**
By clicking on one of the items in the diff table you can see the exact difference between sync dir and database in a git-style diff viewer.
<img src="https://raw.githubusercontent.com/boazpoolman/strapi-plugin-config-sync/master/.github/config-diff.png" alt="Config diff in admin" />
## ⌨️ Usage / Workflow
This plugin works best when you use `git` for the version control of your Strapi project. When you do so, with this plugin you are able to version control your config data through files.
_The following workflows are assuming you're using `git`._
### Local development
When building a new feature locally for your Strapi project you'd use the following workflow:
- Build the feature.
- Export the config.
- Commit and push the files to git.
### Deployment
When deploying the newly created feature - to either a server, or a co-worker's machine - you'd use the following workflow:
- Pull the latest file changes to the environment.
- (Re)start your Strapi instance.
- Import the config.
### Production deployment
When deploying to production you'd use the same deployment workflow as described above. But before you do, you have to take some extra precautions to ensure no data will be lost:
- Run `yarn cs diff` to verify there are no config changes that could be overwritten.
- If there have been changes made;
- Export these before you pull the new config.
- Commit and push the exported files to git.
- If needed; merge into the branch you were about to pull.
- Continue with the regular deployment workflow.
Try to avoid making config changes directly on production. You wouldn't want to change something like API permissions (roles) on production without it being in your version control.
## 🚀 Config types
### Admin role
> Prefix: `admin-role` | UID: `code` | Query string: `admin::role`
### User role
> Prefix: `user-role` | UID: `type` | Query string: `plugin::users-permissions.role`
### Core store
> Prefix: `core-store` | UID: `key` | Query string: `strapi::core-store`
### I18n locale
> Prefix: `i81n-locale` | UID: `code` | Query string: `plugin::i18n.locale`
## 🔍 Naming convention
All the config files written in the sync directory have the same naming convention. It goes as follows:
[config-type].[config-name].json
- `config-type` - Corresponds to the `prefix` of the config type.
- `config-name` - The unique identifier of the config.
- For `core-store` config this is the `key` value.
- For `user-role` config this is the `type` value.
- For `admin-role` config this is the `code` value.
- For `i18n-locale` config this is the `code` value
## 🔧 Settings
The settings of the plugin can be overridden in the `config/plugins.js` file. The settings of the plugin can be overridden in the `config/plugins.js` file.
In the example below you can see how, and also what the default settings are. In the example below you can see how, and also what the default settings are.
@ -43,18 +254,22 @@ In the example below you can see how, and also what the default settings are.
module.exports = ({ env }) => ({ module.exports = ({ env }) => ({
// ... // ...
'config-sync': { 'config-sync': {
enabled: true,
config: {
destination: "extensions/config-sync/files/", destination: "extensions/config-sync/files/",
minify: false, minify: false,
importOnBootstrap: false, importOnBootstrap: false,
include: [ include: [
"core-store", "core-store",
"role-permissions" "user-role"
"admin-role"
"i18n-locale"
], ],
exclude: [ exclude: [
"core-store.plugin_users-permissions_grant" "core-store.plugin_users-permissions_grant"
] ],
},
}, },
// ...
}); });
| Property | Type | Description | | Property | Type | Description |
@ -62,42 +277,27 @@ In the example below you can see how, and also what the default settings are.
| destination | string | The path for reading and writing the sync files. | | destination | string | The path for reading and writing the sync files. |
| minify | bool | When enabled all the exported JSON files will be minified. | | minify | bool | When enabled all the exported JSON files will be minified. |
| importOnBootstrap | bool | Allows you to let the config be imported automaticly when strapi is bootstrapping (on `strapi start`). This setting should only be used in production, and should be handled very carefully as it can unintendedly overwrite the changes in your database. PLEASE USE WITH CARE. | | importOnBootstrap | bool | Allows you to let the config be imported automaticly when strapi is bootstrapping (on `strapi start`). This setting should only be used in production, and should be handled very carefully as it can unintendedly overwrite the changes in your database. PLEASE USE WITH CARE. |
| include | array | Configs types you want to include in the syncing process. Allowed values: `core-store`, `role-permissions`, `webhooks`. | | include | array | Types you want to include in the syncing process. Allowed values: `core-store`, `user-role`, `admin-role`, `i18n-locale`. |
| exclude | array | Specify the names of configs you want to exclude from the syncing process. By default the API tokens for users-permissions, which are stored in core_store, are excluded. This setting expects the config names to comply with the naming convention. | | exclude | array | Specify the names of configs you want to exclude from the syncing process. By default the API tokens for users-permissions, which are stored in core_store, are excluded. This setting expects the config names to comply with the naming convention. |
## Naming convention ## 🤝 Contributing
All the config files written in the file destination have the same naming convention. It goes as follows:
[config-type].[config-name].json Feel free to fork and make a pull request of this plugin. All the input is welcome!
- `config-type` - Corresponds to the value in from the include setting.
- `config-name` - The unique identifier of the config.
- For `core-store` config this is the `key` value.
- For `role-permissions` config this is the `type` value.
- For `webhooks` config this is the `id` value
## TODOs
- ~~Exporting of user roles & permissions~~
- ~~Exporting of webhooks~~
- ~~Specify which tables you want to track in the plugin configurations~~
- Exporting of EE roles & permissions
- Add partial import/export functionality
- Add CLI commands for importing/exporting
- ~~Track config deletions~~
## ⭐️ Show your support ## ⭐️ Show your support
Give a star if this project helped you. Give a star if this project helped you.
## Credits ## 🔗 Links
Shout out to [@ScottAgirs](https://github.com/ScottAgirs) for making [strapi-plugin-migrate](https://github.com/ijsto/strapi-plugin-migrate) as it was a big help while making the config-sync plugin.
## Resources
- [MIT License](LICENSE.md)
## Links
- [NPM package](https://www.npmjs.com/package/strapi-plugin-config-sync) - [NPM package](https://www.npmjs.com/package/strapi-plugin-config-sync)
- [GitHub repository](https://github.com/boazpoolman/strapi-plugin-config-sync) - [GitHub repository](https://github.com/boazpoolman/strapi-plugin-config-sync)
## 🌎 Community support
- For general help using Strapi, please refer to [the official Strapi documentation](https://strapi.io/documentation/).
- For support with this plugin you can DM me in the Strapi Discord [channel](https://discord.strapi.io/).
## 📝 Resources
- [MIT License](LICENSE.md)

View File

@ -1,15 +1,20 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { Button } from '@buffetjs/core'; import { Button } from '@strapi/design-system/Button';
import { Map } from 'immutable';
import { useNotification } from '@strapi/helper-plugin';
import ConfirmModal from '../ConfirmModal'; import ConfirmModal from '../ConfirmModal';
import { exportAllConfig, importAllConfig } from '../../state/actions/Config'; import { exportAllConfig, importAllConfig } from '../../state/actions/Config';
const ActionButtons = ({ diff }) => { const ActionButtons = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const toggleNotification = useNotification();
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
const [actionType, setActionType] = useState(''); const [actionType, setActionType] = useState('');
const partialDiff = useSelector((state) => state.getIn(['config', 'partialDiff'], Map({}))).toJS();
const closeModal = () => { const closeModal = () => {
setActionType(''); setActionType('');
@ -23,23 +28,25 @@ const ActionButtons = ({ diff }) => {
return ( return (
<ActionButtonsStyling> <ActionButtonsStyling>
<Button disabled={isEmpty(diff.diff)} color="primary" label="Import" onClick={() => openModal('import')} /> <Button disabled={isEmpty(partialDiff)} onClick={() => openModal('import')}>Import</Button>
<Button disabled={isEmpty(diff.diff)} color="primary" label="Export" onClick={() => openModal('export')} /> <Button disabled={isEmpty(partialDiff)} onClick={() => openModal('export')}>Export</Button>
{!isEmpty(diff.diff) && ( {!isEmpty(partialDiff) && (
<h4 style={{ display: 'inline' }}>{Object.keys(diff.diff).length} {Object.keys(diff.diff).length === 1 ? "config change" : "config changes"}</h4> <h4 style={{ display: 'inline' }}>{Object.keys(partialDiff).length} {Object.keys(partialDiff).length === 1 ? "config change" : "config changes"}</h4>
)} )}
<ConfirmModal <ConfirmModal
isOpen={modalIsOpen} isOpen={modalIsOpen}
onClose={closeModal} onClose={closeModal}
type={actionType} type={actionType}
onSubmit={() => actionType === 'import' ? dispatch(importAllConfig()) : dispatch(exportAllConfig())} onSubmit={() => actionType === 'import' ? dispatch(importAllConfig(partialDiff, toggleNotification)) : dispatch(exportAllConfig(partialDiff, toggleNotification))}
/> />
</ActionButtonsStyling> </ActionButtonsStyling>
); );
} };
const ActionButtonsStyling = styled.div` const ActionButtonsStyling = styled.div`
padding: 10px 0 20px 0; padding: 10px 0 20px 0;
display: flex;
align-items: center;
> button { > button {
margin-right: 10px; margin-right: 10px;

View File

@ -1,49 +1,44 @@
import React from 'react'; import React from 'react';
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer';
import { AttributeIcon } from '@buffetjs/core';
import {
HeaderModal,
HeaderModalTitle,
Modal,
ModalBody,
ModalFooter,
} from 'strapi-helper-plugin';
const ConfigDiff = ({ isOpen, onClose, onToggle, oldValue, newValue, configName }) => { import { ModalLayout, ModalBody, ModalHeader } from '@strapi/design-system/ModalLayout';
import { ButtonText } from '@strapi/design-system/Text';
import { Grid, GridItem } from '@strapi/design-system/Grid';
import { Typography } from '@strapi/design-system/Typography';
const ConfigDiff = ({ isOpen, onClose, oldValue, newValue, configName }) => {
if (!isOpen) {
return null;
}
return ( return (
<Modal <ModalLayout
isOpen={isOpen} onClose={onClose}
onClosed={onClose} labelledBy="title"
onToggle={onToggle}
> >
<HeaderModal> <ModalHeader>
<section style={{ alignItems: 'center' }}> <ButtonText textColor="neutral800" as="h2" id="title">
<AttributeIcon type='enum' /> Config changes for {configName}
<HeaderModalTitle style={{ marginLeft: 15 }}>Config changes for {configName}</HeaderModalTitle> </ButtonText>
</section> </ModalHeader>
</HeaderModal> <ModalBody>
<ModalBody style={{ <Grid paddingBottom={4} style={{ textAlign: 'center' }}>
paddingTop: '0.5rem', <GridItem col={6}>
paddingBottom: '3rem' <Typography variant="delta">Sync directory</Typography>
}}> </GridItem>
<div className="container-fluid"> <GridItem col={6}>
<section style={{ marginTop: 20 }}> <Typography variant="delta">Database</Typography>
</GridItem>
</Grid>
<ReactDiffViewer <ReactDiffViewer
oldValue={JSON.stringify(oldValue, null, 2)} oldValue={JSON.stringify(oldValue, null, 2)}
newValue={JSON.stringify(newValue, null, 2)} newValue={JSON.stringify(newValue, null, 2)}
splitView={true} splitView
compareMethod={DiffMethod.WORDS} compareMethod={DiffMethod.WORDS}
/> />
</section>
</div>
</ModalBody> </ModalBody>
<ModalFooter> </ModalLayout>
<section style={{ alignItems: 'center' }}>
</section>
</ModalFooter>
</Modal>
); );
} };
export default ConfigDiff; export default ConfigDiff;

View File

@ -1,27 +1,12 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import { Tr, Td } from '@strapi/design-system/Table';
import { BaseCheckbox } from '@strapi/design-system/BaseCheckbox';
const CustomRow = ({ row }) => { const CustomRow = ({ row, checked, updateValue }) => {
const { config_name, config_type, state, onClick } = row; const { configName, configType, state, onClick } = row;
const stateStyle = (stateStr) => {
return ( const style = {
<tr onClick={() => onClick(config_type, config_name)}>
<td>
<p>{config_name}</p>
</td>
<td>
<p>{config_type}</p>
</td>
<td>
<p style={stateStyle(state)}>{state}</p>
</td>
</tr>
);
};
const stateStyle = (state) => {
let style = {
display: 'inline-flex', display: 'inline-flex',
padding: '0 10px', padding: '0 10px',
borderRadius: '12px', borderRadius: '12px',
@ -30,23 +15,51 @@ const stateStyle = (state) => {
fontWeight: '500', fontWeight: '500',
}; };
if (state === 'Only in DB') { if (stateStr === 'Only in DB') {
style.backgroundColor = '#cbf2d7'; style.backgroundColor = '#cbf2d7';
style.color = '#1b522b'; style.color = '#1b522b';
} }
if (state === 'Only in sync dir') { if (stateStr === 'Only in sync dir') {
style.backgroundColor = '#f0cac7'; style.backgroundColor = '#f0cac7';
style.color = '#3d302f'; style.color = '#3d302f';
} }
if (state === 'Different') { if (stateStr === 'Different') {
style.backgroundColor = '#e8e6b7'; style.backgroundColor = '#e8e6b7';
style.color = '#4a4934'; style.color = '#4a4934';
} }
return style; return style;
};
return (
<Tr
onClick={(e) => {
if (e.target.type !== 'checkbox') {
onClick(configType, configName);
}
}}
style={{ cursor: 'pointer' }}
>
<Td>
<BaseCheckbox
aria-label={`Select ${configName}`}
value={checked}
onValueChange={updateValue}
/>
</Td>
<Td>
<p>{configName}</p>
</Td>
<Td>
<p>{configType}</p>
</Td>
<Td>
<p style={stateStyle(state)}>{state}</p>
</Td>
</Tr>
);
}; };
export default CustomRow;
export default CustomRow

View File

@ -1,48 +1,43 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table } from '@buffetjs/core';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useDispatch } from 'react-redux';
import { Table, Thead, Tbody, Tr, Th } from '@strapi/design-system/Table';
import { TableLabel } from '@strapi/design-system/Text';
import { BaseCheckbox } from '@strapi/design-system/BaseCheckbox';
import { Loader } from '@strapi/design-system/Loader';
import ConfigDiff from '../ConfigDiff'; import ConfigDiff from '../ConfigDiff';
import FirstExport from '../FirstExport'; import FirstExport from '../FirstExport';
import NoChanges from '../NoChanges';
import ConfigListRow from './ConfigListRow'; import ConfigListRow from './ConfigListRow';
import { setConfigPartialDiffInState } from '../../state/actions/Config';
const headers = [
{
name: 'Config name',
value: 'config_name',
},
{
name: 'Config type',
value: 'config_type',
},
{
name: 'State',
value: 'state',
},
];
const ConfigList = ({ diff, isLoading }) => { const ConfigList = ({ diff, isLoading }) => {
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [originalConfig, setOriginalConfig] = useState({}); const [originalConfig, setOriginalConfig] = useState({});
const [newConfig, setNewConfig] = useState({}); const [newConfig, setNewConfig] = useState({});
const [configName, setConfigName] = useState(''); const [cName, setCname] = useState('');
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [checkedItems, setCheckedItems] = useState([]);
const dispatch = useDispatch();
const getConfigState = (configName) => { const getConfigState = (configName) => {
if ( if (
diff.fileConfig[configName] && diff.fileConfig[configName]
diff.databaseConfig[configName] && diff.databaseConfig[configName]
) { ) {
return 'Different' return 'Different';
} else if ( } else if (
diff.fileConfig[configName] && diff.fileConfig[configName]
!diff.databaseConfig[configName] && !diff.databaseConfig[configName]
) { ) {
return 'Only in sync dir' return 'Only in sync dir';
} else if ( } else if (
!diff.fileConfig[configName] && !diff.fileConfig[configName]
diff.databaseConfig[configName] && diff.databaseConfig[configName]
) { ) {
return 'Only in DB' return 'Only in DB';
} }
}; };
@ -52,38 +47,65 @@ const ConfigList = ({ diff, isLoading }) => {
return; return;
} }
let formattedRows = []; const formattedRows = [];
Object.keys(diff.diff).map((configName) => { const newCheckedItems = [];
const type = configName.split('.')[0]; // Grab the first part of the filename. Object.keys(diff.diff).map((name) => {
const name = configName.split(/\.(.+)/)[1]; // Grab the rest of the filename minus the file extension. const type = name.split('.')[0]; // Grab the first part of the filename.
const formattedName = name.split(/\.(.+)/)[1]; // Grab the rest of the filename minus the file extension.
newCheckedItems.push(true);
formattedRows.push({ formattedRows.push({
config_name: name, configName: formattedName,
config_type: type, configType: type,
state: getConfigState(configName), state: getConfigState(name),
onClick: (config_type, config_name) => { onClick: (configType, configName) => {
setOriginalConfig(diff.fileConfig[`${config_type}.${config_name}`]); setOriginalConfig(diff.fileConfig[`${configType}.${configName}`]);
setNewConfig(diff.databaseConfig[`${config_type}.${config_name}`]); setNewConfig(diff.databaseConfig[`${configType}.${configName}`]);
setConfigName(`${config_type}.${config_name}`); setCname(`${configType}.${configName}`);
setOpenModal(true); setOpenModal(true);
} },
}); });
}); });
setCheckedItems(newCheckedItems);
setRows(formattedRows); setRows(formattedRows);
}, [diff]); }, [diff]);
useEffect(() => {
const newPartialDiff = [];
checkedItems.map((item, index) => {
if (item && rows[index]) newPartialDiff.push(`${rows[index].configType}.${rows[index].configName}`);
});
dispatch(setConfigPartialDiffInState(newPartialDiff));
}, [checkedItems]);
const closeModal = () => { const closeModal = () => {
setOriginalConfig({}); setOriginalConfig({});
setNewConfig({}); setNewConfig({});
setConfigName(''); setCname('');
setOpenModal(false); setOpenModal(false);
}; };
if (!isLoading && !isEmpty(diff.message)) { if (isLoading) {
return <FirstExport /> return (
<div style={{ textAlign: 'center', marginTop: 40 }}>
<Loader>Loading content...</Loader>
</div>
);
} }
if (!isLoading && !isEmpty(diff.message)) {
return <FirstExport />;
}
if (!isLoading && isEmpty(diff.diff)) {
return <NoChanges />;
}
const allChecked = checkedItems && checkedItems.every(Boolean);
const isIndeterminate = checkedItems.some(Boolean) && !allChecked;
return ( return (
<div> <div>
<ConfigDiff <ConfigDiff
@ -91,18 +113,46 @@ const ConfigList = ({ diff, isLoading }) => {
oldValue={originalConfig} oldValue={originalConfig}
newValue={newConfig} newValue={newConfig}
onClose={closeModal} onClose={closeModal}
onToggle={closeModal} configName={cName}
configName={configName}
/> />
<Table <Table colCount={4} rowCount={rows.length + 1}>
headers={headers} <Thead>
customRow={ConfigListRow} <Tr>
rows={!isLoading ? rows : []} <Th>
isLoading={isLoading} <BaseCheckbox
tableEmptyText="No config changes. You are up to date!" aria-label="Select all entries"
indeterminate={isIndeterminate}
onValueChange={(value) => setCheckedItems(checkedItems.map(() => value))}
value={allChecked}
/> />
</Th>
<Th>
<TableLabel>Config name</TableLabel>
</Th>
<Th>
<TableLabel>Config type</TableLabel>
</Th>
<Th>
<TableLabel>State</TableLabel>
</Th>
</Tr>
</Thead>
<Tbody>
{rows.map((row, index) => (
<ConfigListRow
key={row.configName}
row={row}
checked={checkedItems[index]}
updateValue={() => {
checkedItems[index] = !checkedItems[index];
setCheckedItems([...checkedItems]);
}}
/>
))}
</Tbody>
</Table>
</div> </div>
); );
} };
export default ConfigList; export default ConfigList;

View File

@ -1,34 +1,58 @@
import React from 'react'; import React from 'react';
import { import { useIntl } from 'react-intl';
ModalConfirm,
} from 'strapi-helper-plugin';
import getTrad from '../../helpers/getTrad'; import { Dialog, DialogBody, DialogFooter } from '@strapi/design-system/Dialog';
import { Flex } from '@strapi/design-system/Flex';
import { Text } from '@strapi/design-system/Text';
import { Stack } from '@strapi/design-system/Stack';
import { Button } from '@strapi/design-system/Button';
import ExclamationMarkCircle from '@strapi/icons/ExclamationMarkCircle';
const ConfirmModal = ({ isOpen, onClose, onSubmit, type }) => { const ConfirmModal = ({ isOpen, onClose, onSubmit, type }) => {
const { formatMessage } = useIntl();
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<ModalConfirm <Dialog
confirmButtonLabel={{ onClose={onClose}
id: getTrad(`popUpWarning.button.${type}`), title="Confirmation"
}}
isOpen={isOpen} isOpen={isOpen}
toggle={onClose} >
onClosed={onClose} <DialogBody icon={<ExclamationMarkCircle />}>
onConfirm={() => { <Stack size={2}>
<Flex justifyContent="center">
<Text id="confirm-description" style={{ textAlign: 'center' }}>
{formatMessage({ id: `config-sync.popUpWarning.warning.${type}_1` })}<br />
{formatMessage({ id: `config-sync.popUpWarning.warning.${type}_2` })}
</Text>
</Flex>
</Stack>
</DialogBody>
<DialogFooter
startAction={(
<Button
onClick={() => {
onClose();
}}
variant="tertiary"
>
{formatMessage({ id: 'config-sync.popUpWarning.button.cancel' })}
</Button>
)}
endAction={(
<Button
variant="secondary"
onClick={() => {
onClose(); onClose();
onSubmit(); onSubmit();
}} }}
type="success" >
content={{ {formatMessage({ id: `config-sync.popUpWarning.button.${type}` })}
id: getTrad(`popUpWarning.warning.${type}`), </Button>
values: { )} />
br: () => <br />, </Dialog>
},
}}
/>
); );
} };
export default ConfirmModal; export default ConfirmModal;

View File

@ -1,24 +0,0 @@
import styled from 'styled-components';
const ContainerFluid = styled.div`
padding: 18px 30px;
> div:first-child {
max-height: 33px;
}
.buttonOutline {
height: 30px;
padding: 0 15px;
border: 1px solid #dfe0e1;
font-weight: 500;
font-size: 13px;
&:before {
margin-right: 10px;
content: '\f08e';
font-family: 'FontAwesome';
font-size: 10px;
}
}
`;
export default ContainerFluid;

View File

@ -1,33 +1,34 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Button } from '@buffetjs/core'; import { NoContent, useNotification } from '@strapi/helper-plugin';
import { Button } from '@strapi/design-system/Button';
import { exportAllConfig } from '../../state/actions/Config'; import { exportAllConfig } from '../../state/actions/Config';
import ConfirmModal from '../ConfirmModal'; import ConfirmModal from '../ConfirmModal';
const FirstExport = () => { const FirstExport = () => {
const toggleNotification = useNotification();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
return ( return (
<div style={{ <div>
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
textAlign: 'center',
height: '300px',
}}>
<ConfirmModal <ConfirmModal
isOpen={modalIsOpen} isOpen={modalIsOpen}
onClose={() => setModalIsOpen(false)} onClose={() => setModalIsOpen(false)}
type={'export'} type="export"
onSubmit={() => dispatch(exportAllConfig())} onSubmit={() => dispatch(exportAllConfig([], toggleNotification))}
/>
<NoContent
content={{
id: 'emptyState',
defaultMessage:
'Looks like this is your first time using config-sync for this project.',
}}
action={<Button onClick={() => setModalIsOpen(true)}>Make the initial export</Button>}
/> />
<h3>Looks like this is your first time using config-sync for this project.</h3>
<p>Make the initial export!</p>
<Button color="primary" onClick={() => setModalIsOpen(true)}>Initial export</Button>
</div> </div>
); );
} };
export default FirstExport; export default FirstExport;

View File

@ -5,21 +5,22 @@
*/ */
import React, { memo } from 'react'; import React, { memo } from 'react';
import { Header } from '@buffetjs/custom'; import { useIntl } from 'react-intl';
import { useGlobalContext } from 'strapi-helper-plugin';
import { HeaderLayout } from '@strapi/design-system/Layout';
import { Box } from '@strapi/design-system/Box';
const HeaderComponent = () => { const HeaderComponent = () => {
const { formatMessage } = useGlobalContext(); const { formatMessage } = useIntl();
const headerProps = {
title: {
label: formatMessage({ id: 'config-sync.Header.Title' }),
},
content: formatMessage({ id: 'config-sync.Header.Description' }),
};
return ( return (
<Header {...headerProps} /> <Box background="neutral100">
<HeaderLayout
title={formatMessage({ id: 'config-sync.Header.Title' })}
subtitle={formatMessage({ id: 'config-sync.Header.Description' })}
as="h2"
/>
</Box>
); );
}; };

View File

@ -0,0 +1,14 @@
import React from 'react';
import { NoContent } from '@strapi/helper-plugin';
const NoChanges = () => (
<NoContent
content={{
id: 'emptyState',
defaultMessage:
'No differences between DB and sync directory. You are up-to-date!',
}}
/>
);
export default NoChanges;

View File

@ -0,0 +1,15 @@
/**
*
* PluginIcon
*
*/
import React from 'react';
import { Icon } from '@strapi/design-system/Icon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import pluginPkg from '../../../../package.json';
const PluginIcon = () => <Icon as={() => <FontAwesomeIcon icon={pluginPkg.strapi.icon} />} width="16px" />;
export default PluginIcon;

View File

@ -1 +1 @@
export const __DEBUG__ = strapi.env === 'development'; export const __DEBUG__ = true; // TODO: set actual env.

0
admin/src/config/logger.js Executable file → Normal file
View File

View File

@ -7,20 +7,21 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import ContainerFluid from '../../components/Container'; import { CheckPagePermissions } from '@strapi/helper-plugin';
import Header from '../../components/Header';
import pluginPermissions from '../../permissions';
import Header from '../../components/Header';
import { store } from "../../helpers/configureStore"; import { store } from "../../helpers/configureStore";
import ConfigPage from '../ConfigPage'; import ConfigPage from '../ConfigPage';
const App = () => { const App = () => {
return ( return (
<CheckPagePermissions permissions={pluginPermissions.settings}>
<Provider store={store}> <Provider store={store}>
<ContainerFluid>
<Header /> <Header />
<ConfigPage /> <ConfigPage />
</ContainerFluid>
</Provider> </Provider>
</CheckPagePermissions>
); );
}; };

View File

@ -1,26 +1,40 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Map } from 'immutable'; import { Map } from 'immutable';
import { Box } from '@strapi/design-system/Box';
import { useNotification } from '@strapi/helper-plugin';
import { Alert } from '@strapi/design-system/Alert';
import { Typography } from '@strapi/design-system/Typography';
import { getAllConfigDiff } from '../../state/actions/Config'; import { getAllConfigDiff } from '../../state/actions/Config';
import ConfigList from '../../components/ConfigList'; import ConfigList from '../../components/ConfigList';
import ActionButtons from '../../components/ActionButtons'; import ActionButtons from '../../components/ActionButtons';
const ConfigPage = () => { const ConfigPage = () => {
const toggleNotification = useNotification();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLoading = useSelector((state) => state.getIn(['config', 'isLoading'], Map({}))); const isLoading = useSelector((state) => state.getIn(['config', 'isLoading'], Map({})));
const configDiff = useSelector((state) => state.getIn(['config', 'configDiff'], Map({}))); const configDiff = useSelector((state) => state.getIn(['config', 'configDiff'], Map({})));
useEffect(() => { useEffect(() => {
dispatch(getAllConfigDiff()); dispatch(getAllConfigDiff(toggleNotification));
}, []); }, []);
return ( return (
<div> <Box paddingLeft={8} paddingRight={8} paddingBottom={8}>
<ActionButtons diff={configDiff.toJS()} /> {process.env.NODE_ENV === 'production' && (
<Box paddingBottom={4}>
<Alert variant="danger">
<Typography variant="omega" fontWeight="bold">You&apos;re in the production environment</Typography><br />
Please be careful when syncing your config in production.<br />
Make sure you are not overriding critical config changes on import.
</Alert>
</Box>
)}
<ActionButtons />
<ConfigList isLoading={isLoading} diff={configDiff.toJS()} /> <ConfigList isLoading={isLoading} diff={configDiff.toJS()} />
</div> </Box>
); );
} };
export default ConfigPage; export default ConfigPage;

View File

@ -1,14 +1,12 @@
import { createStore, applyMiddleware, compose } from 'redux'; import { createStore, applyMiddleware, compose } from 'redux';
import { createLogger } from 'redux-logger';
import thunkMiddleware from 'redux-thunk'; import thunkMiddleware from 'redux-thunk';
import { Map } from 'immutable'; import { Map } from 'immutable';
import rootReducer from '../state/reducers'; import rootReducer from '../state/reducers';
import loggerConfig from '../config/logger';
import { __DEBUG__ } from '../config/constants'; import { __DEBUG__ } from '../config/constants';
const configureStore = () => { const configureStore = () => {
let initialStoreState = Map(); const initialStoreState = Map();
const enhancers = []; const enhancers = [];
const middlewares = [ const middlewares = [
@ -27,24 +25,6 @@ const configureStore = () => {
if (devtools) { if (devtools) {
console.info('[setup] ✓ Enabling Redux DevTools Extension'); console.info('[setup] ✓ Enabling Redux DevTools Extension');
} }
console.info('[setup] ✓ Enabling state logger');
const loggerMiddleware = createLogger({
level: 'info',
collapsed: true,
stateTransformer: (state) => state.toJS(),
predicate: (getState, action) => {
const state = getState();
const showBlacklisted = state.getIn(['debug', 'logs', 'blacklisted']);
if (loggerConfig.blacklist.indexOf(action.type) !== -1 && !showBlacklisted) {
return false;
}
return state.getIn(['debug', 'logs', 'enabled']);
},
});
middlewares.push(loggerMiddleware);
} }
const composedEnhancers = devtools || compose; const composedEnhancers = devtools || compose;

View File

@ -1,5 +1,5 @@
import pluginId from './pluginId'; import pluginId from './pluginId';
const getTrad = id => `${pluginId}.${id}`; const getTrad = (id) => `${pluginId}.${id}`;
export default getTrad; export default getTrad;

View File

@ -1,4 +1,5 @@
const pluginPkg = require('../../../package.json'); const pluginPkg = require('../../../package.json');
const pluginId = pluginPkg.name.replace( const pluginId = pluginPkg.name.replace(
/^strapi-plugin-/i, /^strapi-plugin-/i,
'' ''

View File

@ -1,53 +1,62 @@
import React from 'react'; import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginPkg from '../../package.json'; import pluginPkg from '../../package.json';
import pluginId from './helpers/pluginId'; import pluginId from './helpers/pluginId';
import App from './containers/App'; import pluginIcon from './components/PluginIcon';
import Initializer from './containers/Initializer'; import pluginPermissions from './permissions';
import trads from './translations'; // import getTrad from './helpers/getTrad';
function Comp(props) { const pluginDescription = pluginPkg.strapi.description || pluginPkg.description;
return <App {...props} />; const { name } = pluginPkg.strapi;
}
export default strapi => { export default {
const pluginDescription = register(app) {
pluginPkg.strapi.description || pluginPkg.description; app.registerPlugin({
const icon = pluginPkg.strapi.icon;
const name = pluginPkg.strapi.name;
const plugin = {
icon,
name,
destination: `/plugins/${pluginId}`,
blockerComponent: null,
blockerComponentProps: {},
description: pluginDescription, description: pluginDescription,
id: pluginId, id: pluginId,
initializer: Initializer, isReady: true,
injectedComponents: [], isRequired: pluginPkg.strapi.required || false,
isReady: false,
layout: null,
leftMenuLinks: [],
leftMenuSections: [],
mainComponent: Comp,
name: pluginPkg.strapi.name,
preventComponentRendering: false,
trads,
menu: {
pluginsSectionLinks: [
{
destination: `/plugins/${pluginId}`, // Endpoint of the link
icon,
name, name,
label: { });
id: `${pluginId}.plugin.name`, // Refers to a i18n
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: pluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Config Sync', defaultMessage: 'Config Sync',
}, },
}, Component: async () => {
], const component = await import(
}, /* webpackChunkName: "config-sync-settings-page" */ './containers/App'
}; );
return strapi.registerPlugin(plugin); return component;
},
permissions: pluginPermissions['menu-link'],
});
},
bootstrap(app) {},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map((locale) => {
return import(
/* webpackChunkName: "config-sync-translation-[request]" */ `./translations/${locale}.json`
)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
}; };

10
admin/src/permissions.js Normal file
View File

@ -0,0 +1,10 @@
const pluginPermissions = {
// This permission regards the main component (App) and is used to tell
// If the plugin link should be displayed in the menu
// And also if the plugin is accessible. This use case is found when a user types the url of the
// plugin directly in the browser
'menu-link': [{ action: 'plugin::config-sync.menu-link', subject: null }],
settings: [{ action: 'plugin::config-sync.settings.read', subject: null }],
};
export default pluginPermissions;

View File

@ -4,21 +4,21 @@
* *
*/ */
import { request } from 'strapi-helper-plugin'; import { request } from '@strapi/helper-plugin';
import { Map } from 'immutable';
export function getAllConfigDiff() { export function getAllConfigDiff(toggleNotification) {
return async function(dispatch) { return async function(dispatch) {
dispatch(setLoadingState(true)); dispatch(setLoadingState(true));
try { try {
const configDiff = await request('/config-sync/diff', { method: 'GET' }); const configDiff = await request('/config-sync/diff', { method: 'GET' });
dispatch(setConfigPartialDiffInState([]));
dispatch(setConfigDiffInState(configDiff)); dispatch(setConfigDiffInState(configDiff));
dispatch(setLoadingState(false)); dispatch(setLoadingState(false));
} catch(err) { } catch (err) {
strapi.notification.error('notification.error'); toggleNotification({ type: 'warning', message: { id: 'notification.error' } });
dispatch(setLoadingState(false)); dispatch(setLoadingState(false));
} }
} };
} }
export const SET_CONFIG_DIFF_IN_STATE = 'SET_CONFIG_DIFF_IN_STATE'; export const SET_CONFIG_DIFF_IN_STATE = 'SET_CONFIG_DIFF_IN_STATE';
@ -29,36 +29,48 @@ export function setConfigDiffInState(config) {
}; };
} }
export function exportAllConfig() { export const SET_CONFIG_PARTIAL_DIFF_IN_STATE = 'SET_CONFIG_PARTIAL_DIFF_IN_STATE';
return async function(dispatch) { export function setConfigPartialDiffInState(config) {
dispatch(setLoadingState(true)); return {
try { type: SET_CONFIG_PARTIAL_DIFF_IN_STATE,
const { message } = await request('/config-sync/export', { method: 'GET' }); config,
dispatch(setConfigDiffInState(Map({}))); };
strapi.notification.success(message);
dispatch(setLoadingState(false));
} catch(err) {
strapi.notification.error('notification.error');
dispatch(setLoadingState(false));
}
}
} }
export function importAllConfig() { export function exportAllConfig(partialDiff, toggleNotification) {
return async function(dispatch) { return async function(dispatch) {
dispatch(setLoadingState(true)); dispatch(setLoadingState(true));
try { try {
const { message } = await request('/config-sync/import', { method: 'GET' }); const { message } = await request('/config-sync/export', {
dispatch(setConfigDiffInState(Map({}))); method: 'POST',
body: partialDiff,
});
toggleNotification({ type: 'success', message });
dispatch(getAllConfigDiff(toggleNotification));
dispatch(setLoadingState(false));
} catch (err) {
toggleNotification({ type: 'warning', message: { id: 'notification.error' } });
dispatch(setLoadingState(false));
}
};
}
strapi.notification.success(message); export function importAllConfig(partialDiff, toggleNotification) {
return async function(dispatch) {
dispatch(setLoadingState(true));
try {
const { message } = await request('/config-sync/import', {
method: 'POST',
body: partialDiff,
});
toggleNotification({ type: 'success', message });
dispatch(getAllConfigDiff(toggleNotification));
dispatch(setLoadingState(false)); dispatch(setLoadingState(false));
} catch(err) { } catch (err) {
strapi.notification.error('notification.error'); toggleNotification({ type: 'warning', message: { id: 'notification.error' } });
dispatch(setLoadingState(false)); dispatch(setLoadingState(false));
} }
} };
} }
export const SET_LOADING_STATE = 'SET_LOADING_STATE'; export const SET_LOADING_STATE = 'SET_LOADING_STATE';

View File

@ -4,11 +4,12 @@
* *
*/ */
import { fromJS, Map } from 'immutable'; import { fromJS, Map, List } from 'immutable';
import { SET_CONFIG_DIFF_IN_STATE, SET_LOADING_STATE } from '../../actions/Config'; import { SET_CONFIG_DIFF_IN_STATE, SET_CONFIG_PARTIAL_DIFF_IN_STATE, SET_LOADING_STATE } from '../../actions/Config';
const initialState = fromJS({ const initialState = fromJS({
configDiff: Map({}), configDiff: Map({}),
partialDiff: List([]),
isLoading: false, isLoading: false,
}); });
@ -16,10 +17,13 @@ export default function configReducer(state = initialState, action) {
switch (action.type) { switch (action.type) {
case SET_CONFIG_DIFF_IN_STATE: case SET_CONFIG_DIFF_IN_STATE:
return state return state
.update('configDiff', () => fromJS(action.config)) .update('configDiff', () => fromJS(action.config));
case SET_CONFIG_PARTIAL_DIFF_IN_STATE:
return state
.update('partialDiff', () => fromJS(action.config));
case SET_LOADING_STATE: case SET_LOADING_STATE:
return state return state
.update('isLoading', () => fromJS(action.value)) .update('isLoading', () => fromJS(action.value));
default: default:
return state; return state;
} }

View File

@ -1,8 +1,11 @@
{ {
"popUpWarning.warning.import": "If you continue all your local config files<br></br>will be imported into the database.", "popUpWarning.warning.import_1": "If you continue all your local config files",
"popUpWarning.warning.export": "If you continue all your database config<br></br>will be written into config files.", "popUpWarning.warning.import_2": "will be imported into the database.",
"popUpWarning.warning.export_1": "If you continue all your database config",
"popUpWarning.warning.export_2": "will be written into config files.",
"popUpWarning.button.import": "Yes, import", "popUpWarning.button.import": "Yes, import",
"popUpWarning.button.export": "Yes, export", "popUpWarning.button.export": "Yes, export",
"popUpWarning.button.cancel": "Cancel",
"Header.Title": "Config Sync", "Header.Title": "Config Sync",
"Header.Description": "Manage your database config across environments.", "Header.Description": "Manage your database config across environments.",

5
bin/config-sync Normal file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env node
'use strict';
require('../server/cli');

4
codecov.yml Normal file
View File

@ -0,0 +1,4 @@
comment:
branches:
- master
- develop

View File

@ -1,12 +0,0 @@
{
"destination": "extensions/config-sync/files/",
"minify": false,
"importOnBootstrap": false,
"include": [
"core-store",
"role-permissions"
],
"exclude": [
"core-store.plugin_users-permissions_grant"
]
}

View File

@ -1,21 +0,0 @@
'use strict';
const fs = require('fs');
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*
* See more details here: https://strapi.io/documentation/v3.x/concepts/configurations.html#bootstrap
*/
module.exports = async () => {
if (strapi.plugins['config-sync'].config.importOnBootstrap) {
if (fs.existsSync(strapi.plugins['config-sync'].config.destination)) {
await strapi.plugins['config-sync'].services.main.importAllConfig();
}
}
};

View File

@ -1,28 +0,0 @@
{
"routes": [
{
"method": "GET",
"path": "/export",
"handler": "config.exportAll",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/import",
"handler": "config.importAll",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/diff",
"handler": "config.getDiff",
"config": {
"policies": []
}
}
]
}

View File

@ -1,84 +0,0 @@
'use strict';
const fs = require('fs');
const difference = require('../utils/getObjectDiff');
/**
* Main controllers for config import/export.
*/
module.exports = {
/**
* Export all config, from db to filesystem.
*
* @param {object} ctx - Request context object.
* @returns {void}
*/
exportAll: async (ctx) => {
await strapi.plugins['config-sync'].services.main.exportAllConfig();
ctx.send({
message: `Config was successfully exported to ${strapi.plugins['config-sync'].config.destination}.`
});
},
/**
* Import all config, from filesystem to db.
*
* @param {object} ctx - Request context object.
* @returns {void}
*/
importAll: async (ctx) => {
// Check for existance of the config file destination dir.
if (!fs.existsSync(strapi.plugins['config-sync'].config.destination)) {
ctx.send({
message: 'No config files were found.'
});
return;
}
await strapi.plugins['config-sync'].services.main.importAllConfig();
ctx.send({
message: 'Config was successfully imported.'
});
},
/**
* Get config diff between filesystem & db.
*
* @param {object} ctx - Request context object.
* @returns Object with key value pairs of config.
*/
getDiff: async (ctx) => {
// Check for existance of the config file destination dir.
if (!fs.existsSync(strapi.plugins['config-sync'].config.destination)) {
ctx.send({
message: 'No config files were found.'
});
return;
}
const formattedDiff = {
fileConfig: {},
databaseConfig: {},
diff: {}
};
const fileConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromFiles();
const databaseConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromDatabase();
const diff = difference(databaseConfig, fileConfig);
formattedDiff.diff = diff;
Object.keys(diff).map((changedConfigName) => {
formattedDiff.fileConfig[changedConfigName] = fileConfig[changedConfigName];
formattedDiff.databaseConfig[changedConfigName] = databaseConfig[changedConfigName];
})
return formattedDiff;
},
};

7
jest.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
name: 'Unit test',
testMatch: ['**/__tests__/?(*.)+(spec|test).js'],
transform: {},
coverageDirectory: "./coverage/",
collectCoverage: true,
};

View File

@ -1,21 +1,30 @@
{ {
"name": "strapi-plugin-config-sync", "name": "strapi-plugin-config-sync",
"version": "0.1.6", "version": "1.0.0-alpha.1",
"description": "Manage your Strapi database configuration as partial json files which can be imported/exported across environments. ", "description": "CLI & GUI for syncing config data across environments.",
"strapi": { "strapi": {
"displayName": "Config Sync",
"name": "config-sync", "name": "config-sync",
"icon": "sync", "icon": "sync",
"description": "Manage your Strapi database configuration as partial json files which can be imported/exported across environments. " "description": "CLI & GUI for syncing config data across environments.",
"required": false,
"kind": "plugin"
},
"bin": {
"config-sync": "./bin/config-sync"
},
"scripts": {
"eslint": "eslint --max-warnings=0 './**/*.{js,jsx}'",
"eslint:fix": "eslint --fix './**/*.{js,jsx}'",
"test:unit": "jest --verbose",
"postinstall": "rm -rf node_modules/@strapi/helper-plugin"
}, },
"dependencies": { "dependencies": {
"immutable": "^4.0.0-rc.12", "chalk": "^4.1.2",
"react": "^16.13.1", "cli-table": "^0.3.6",
"commander": "^8.3.0",
"inquirer": "^8.2.0",
"react-diff-viewer": "^3.1.1", "react-diff-viewer": "^3.1.1",
"react-dom": "^16.9.0",
"react-redux": "^7.2.2",
"redux": "^4.0.5",
"redux-immutable": "^4.0.0",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0" "redux-thunk": "^2.3.0"
}, },
"author": { "author": {
@ -30,19 +39,52 @@
"url": "https://github.com/boazpoolman" "url": "https://github.com/boazpoolman"
} }
], ],
"files": [
"admin",
"server",
"bin",
"strapi-admin.js",
"strapi-server.js"
],
"devDependencies": {
"@fortawesome/react-fontawesome": "^0.1.16",
"@strapi/design-system": "0.0.1-alpha.70",
"@strapi/helper-plugin": "4.0.0",
"@strapi/icons": "0.0.1-alpha.70",
"@strapi/strapi": "^4.0.0",
"@strapi/utils": "4.0.0",
"babel-eslint": "9.0.0",
"codecov": "^3.8.3",
"eslint": "^5.16.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-react-app": "^3.0.7",
"eslint-import-resolver-webpack": "^0.11.0",
"eslint-loader": "2.1.1",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-flowtype": "2.50.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^2.3.0",
"immutable": "^4.0.0-rc.14",
"jest": "^26.0.1",
"jest-cli": "^26.0.1",
"jest-styled-components": "^7.0.2",
"lodash": "^4.17.11",
"react": "^17.0.2",
"react-intl": "^5.20.12",
"react-redux": "^7.2.2",
"redux": "^4.0.5",
"redux-immutable": "^4.0.0",
"styled-components": "^5.2.3"
},
"bugs": { "bugs": {
"url": "https://github.com/boazpoolman/strapi-plugin-config-sync/issues" "url": "https://github.com/boazpoolman/strapi-plugin-config-sync/issues"
}, },
"homepage": "https://github.com/boazpoolman/strapi-plugin-config-sync#readme", "homepage": "https://github.com/boazpoolman/strapi-plugin-config-sync#readme",
"engines": { "engines": {
"node": ">=10.16.0 <=14.x.x", "node": ">=10.0.0",
"npm": ">=6.0.0" "npm": ">=6.0.0"
}, },
"license": "MIT", "license": "MIT"
"devDependencies": {
"husky": "^6.0.0"
},
"scripts": {
"prepare": "husky install"
}
} }

39
server/bootstrap.js Normal file
View File

@ -0,0 +1,39 @@
'use strict';
const fs = require('fs');
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*
* See more details here: https://strapi.io/documentation/v3.x/concepts/configurations.html#bootstrap
*/
module.exports = async () => {
// Import on bootstrap.
if (strapi.config.get('plugin.config-sync.importOnBootstrap')) {
if (fs.existsSync(strapi.config.get('plugin.config-sync.destination'))) {
await strapi.plugin('config-sync').service('main').importAllConfig();
}
}
// Register permission actions.
const actions = [
{
section: 'plugins',
displayName: 'Access the plugin settings',
uid: 'settings.read',
pluginName: 'config-sync',
},
{
section: 'plugins',
displayName: 'Menu link to plugin settings',
uid: 'menu-link',
pluginName: 'config-sync',
},
];
await strapi.admin.services.permission.actionProvider.registerMany(actions);
};

223
server/cli.js Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env node
const { Command } = require('commander');
const Table = require('cli-table');
const chalk = require('chalk');
const inquirer = require('inquirer');
const { isEmpty } = require('lodash');
const strapi = require('@strapi/strapi');
const packageJSON = require('../package.json');
const program = new Command();
const initTable = (head) => {
return new Table({
head: [chalk.green('Name'), chalk.green(head || 'State')],
colWidths: [65, 20],
chars: { top: '═',
'top-mid': '╤',
'top-left': '╔',
'top-right': '╗',
bottom: '═',
'bottom-mid': '╧',
'bottom-left': '╚',
'bottom-right': '╝',
left: '║',
'left-mid': '╟',
mid: '─',
'mid-mid': '┼',
right: '║',
'right-mid': '╢',
middle: '│',
},
});
};
const getConfigState = (diff, configName, syncType) => {
if (
diff.fileConfig[configName]
&& diff.databaseConfig[configName]
) {
return chalk.yellow(syncType ? 'Update' : 'Different');
} else if (
diff.fileConfig[configName]
&& !diff.databaseConfig[configName]
) {
if (syncType === 'import') {
return chalk.green('Create');
} else if (syncType === 'export') {
return chalk.red('Delete');
} else {
return chalk.red('Only in sync dir');
}
} else if (
!diff.fileConfig[configName]
&& diff.databaseConfig[configName]
) {
if (syncType === 'import') {
return chalk.red('Delete');
} else if (syncType === 'export') {
return chalk.green('Create');
} else {
return chalk.green('Only in DB');
}
}
};
const handleAction = async (syncType, skipConfirm, configType, partials) => {
const app = await strapi().load();
const diff = await app.plugin('config-sync').service('main').getFormattedDiff();
// No changes.
if (isEmpty(diff.diff)) {
console.log(`${chalk.bgCyan.bold('[notice]')} There are no changes to ${syncType}.`);
process.exit(0);
}
// Init table.
const table = initTable('Action');
const configNames = partials && partials.split(',');
const partialDiff = {};
// Fill partialDiff with arguments.
if (configNames) {
configNames.map((name) => {
if (diff.diff[name]) partialDiff[name] = diff.diff[name];
});
}
if (configType) {
Object.keys(diff.diff).map((name) => {
if (configType === name.split('.')[0]) {
partialDiff[name] = diff.diff[name];
}
});
}
// No changes for partial diff.
if ((partials || configType) && isEmpty(partialDiff)) {
console.log(`${chalk.bgCyan.bold('[notice]')} There are no changes for the specified config.`);
process.exit(0);
}
const finalDiff = (partials || configType) && partialDiff ? partialDiff : diff.diff;
// Add diff to table.
Object.keys(finalDiff).map((configName) => {
table.push([configName, getConfigState(diff, configName, syncType)]);
});
// Print table.
console.log(table.toString(), '\n');
// Prompt to confirm.
let answer = {};
if (!skipConfirm) {
answer = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to ${syncType} the config changes?`,
}]);
console.log('');
}
// Preform the action.
if (skipConfirm || answer.confirm) {
if (syncType === 'import') {
const onSuccess = (name) => console.log(`${chalk.bgGreen.bold('[success]')} Imported ${name}`);
try {
await Promise.all(Object.keys(finalDiff).map(async (name) => {
await app.plugin('config-sync').service('main').importSingleConfig(name, onSuccess);
}));
} catch (e) {
console.log(`${chalk.bgRed.bold('[error]')} Something went wrong during the import. ${e}`);
}
}
if (syncType === 'export') {
const onSuccess = (name) => console.log(`${chalk.bgGreen.bold('[success]')} Exported ${name}`);
try {
await Promise.all(Object.keys(finalDiff).map(async (name) => {
await app.plugin('config-sync').service('main').exportSingleConfig(name, onSuccess);
}));
} catch (e) {
console.log(`${chalk.bgRed.bold('[error]')} Something went wrong during the export. ${e}`);
}
}
}
process.exit(0);
};
// Initial program setup
program.storeOptionsAsProperties(false).allowUnknownOption(true);
program.helpOption('-h, --help', 'Display help for command');
program.addHelpCommand('help [command]', 'Display help for command');
// `$ config-sync version` (--version synonym)
program.version(packageJSON.version, '-v, --version', 'Output the version number');
program
.command('version')
.description('Output your version of the config-sync plugin')
.action(() => {
process.stdout.write(`${packageJSON.version}\n`);
process.exit(0);
});
// `$ config-sync import`
program
.command('import')
.alias('i')
.option('-t, --type <type>', 'The type of config')
.option('-p, --partial <partials>', 'A comma separated string of configs')
.option('-y', 'Skip the confirm prompt')
.description('Import the config')
.action(async ({ y, type, partial }) => {
return handleAction('import', y, type, partial);
});
// `$ config-sync export`
program
.command('export')
.alias('e')
.option('-t, --type <type>', 'The type of config')
.option('-p, --partial <partials>', 'A comma separated string of configs')
.option('-y', 'Skip the confirm prompt')
.description('Export the config')
.action(async ({ y, type, partial }) => {
return handleAction('export', y, type, partial);
});
// `$ config-sync diff`
program
.command('diff')
.alias('d')
// .option('-t, --type <type>', 'The type of config') // TODO: partial diff
.description('The config diff')
.action(async ({ type }) => {
const app = await strapi().load();
const diff = await app.plugin('config-sync').service('main').getFormattedDiff();
// No changes.
if (isEmpty(diff.diff)) {
console.log(`${chalk.bgCyan.bold('[notice]')} No differences between DB and sync directory.`);
process.exit(0);
}
// Init table.
const table = initTable();
// Add diff to table.
Object.keys(diff.diff).map((configName) => {
table.push([configName, getConfigState(diff, configName)]);
});
// Print table.
console.log(table.toString());
process.exit(0);
});
program.parseAsync(process.argv);

19
server/config.js Normal file
View File

@ -0,0 +1,19 @@
'use strict';
module.exports = {
default: {
destination: "src/extensions/config-sync/files/",
minify: false,
importOnBootstrap: false,
include: [
"core-store",
"user-role",
"admin-role",
"i18n-locale",
],
exclude: [
"core-store.plugin_users-permissions_grant",
],
},
validator() {},
};

197
server/config/type.js Normal file
View File

@ -0,0 +1,197 @@
const { logMessage, sanitizeConfig, dynamicSort, noLimit } = require('../utils');
const difference = require('../utils/getArrayDiff');
const ConfigType = class ConfigType {
constructor(queryString, configPrefix, uid, jsonFields, relations) {
if (!queryString) {
strapi.log.error(logMessage('Query string is missing for ConfigType'));
}
this.queryString = queryString;
this.configPrefix = configPrefix;
this.uid = uid;
this.jsonFields = jsonFields || [];
this.relations = relations || [];
}
/**
* Import a single role-permissions config file into the db.
*
* @param {string} configName - The name of the config file.
* @param {string} configContent - The JSON content of the config file.
* @returns {void}
*/
importSingle = async (configName, configContent) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(`${this.configPrefix}.${configName}`);
if (shouldExclude) return;
const queryAPI = strapi.query(this.queryString);
let existingConfig = await queryAPI
.findOne({
where: { [this.uid]: configName },
populate: this.relations.map(({ relationName }) => relationName),
});
if (existingConfig && configContent === null) {
const entity = await queryAPI.findOne({
where: { [this.uid]: configName },
populate: this.relations.map(({ relationName }) => relationName),
});
await Promise.all(this.relations.map(async ({ queryString, parentName }) => {
const relations = await noLimit(strapi.query(queryString), {
where: {
[parentName]: entity.id,
},
});
await Promise.all(relations.map(async (relation) => {
await strapi.query(queryString).delete({
where: { id: relation.id },
});
}));
}));
await queryAPI.delete({
where: { id: entity.id },
});
return;
}
if (!existingConfig) {
// Format JSON fields.
const query = { ...configContent };
this.jsonFields.map((field) => query[field] = JSON.stringify(configContent[field]));
// Create entity.
this.relations.map(({ relationName }) => delete query[relationName]);
const newEntity = await queryAPI.create({ data: query });
// Create relation entities.
await Promise.all(this.relations.map(async ({ queryString, relationName, parentName }) => {
const relationQueryApi = strapi.query(queryString);
await Promise.all(configContent[relationName].map(async (relationEntity) => {
const relationQuery = { ...relationEntity, [parentName]: newEntity };
await relationQueryApi.create({ data: relationQuery });
}));
}));
} else {
// Format JSON fields.
configContent = sanitizeConfig(configContent);
const query = { ...configContent };
this.jsonFields.map((field) => query[field] = JSON.stringify(configContent[field]));
// Update entity.
this.relations.map(({ relationName }) => delete query[relationName]);
const entity = await queryAPI.update({ where: { [this.uid]: configName }, data: query });
// Delete/create relations.
await Promise.all(this.relations.map(async ({ queryString, relationName, parentName, relationSortField }) => {
const relationQueryApi = strapi.query(queryString);
existingConfig = sanitizeConfig(existingConfig, relationName, relationSortField);
configContent = sanitizeConfig(configContent, relationName, relationSortField);
const configToAdd = difference(configContent[relationName], existingConfig[relationName], relationSortField);
const configToDelete = difference(existingConfig[relationName], configContent[relationName], relationSortField);
await Promise.all(configToDelete.map(async (config) => {
await relationQueryApi.delete({
where: {
[relationSortField]: config[relationSortField],
[parentName]: entity.id,
},
});
}));
await Promise.all(configToAdd.map(async (config) => {
await relationQueryApi.create({
data: { ...config, [parentName]: entity.id },
});
}));
}));
}
}
/**
* Export a single core-store config to a file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
exportSingle = async (configName) => {
const formattedDiff = await strapi.plugin('config-sync').service('main').getFormattedDiff(this.configPrefix);
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(`${configName}`);
if (shouldExclude) return;
const currentConfig = formattedDiff.databaseConfig[configName];
if (
!currentConfig
&& formattedDiff.fileConfig[configName]
) {
await strapi.plugin('config-sync').service('main').deleteConfigFile(configName);
} else {
await strapi.plugin('config-sync').service('main').writeConfigFile(this.configPrefix, currentConfig[this.uid], currentConfig);
}
}
/**
* Get all role-permissions config from the db.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllFromDatabase = async () => {
const AllConfig = await noLimit(strapi.query(this.queryString), {});
const configs = {};
await Promise.all(Object.values(AllConfig).map(async (config) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(`${this.configPrefix}.${config[this.uid]}`);
if (shouldExclude) return;
const formattedConfig = { ...sanitizeConfig(config) };
await Promise.all(this.relations.map(async ({ queryString, relationName, relationSortField, parentName }) => {
const relations = await noLimit(strapi.query(queryString), {
where: { [parentName]: { [this.uid]: config[this.uid] } },
});
relations.map((relation) => sanitizeConfig(relation));
relations.sort(dynamicSort(relationSortField));
formattedConfig[relationName] = relations;
}));
this.jsonFields.map((field) => formattedConfig[field] = JSON.parse(config[field]));
configs[`${this.configPrefix}.${config[this.uid]}`] = formattedConfig;
}));
return configs;
}
/**
* Import all core-store config files into the db.
*
* @returns {void}
*/
importAll = async () => {
// The main.importAllConfig service will loop the core-store.importSingle service.
await strapi.plugin('config-sync').service('main').importAllConfig(this.configPrefix);
}
/**
* Export all core-store config to files.
*
* @returns {void}
*/
exportAll = async () => {
// The main.importAllConfig service will loop the core-store.importSingle service.
await strapi.plugin('config-sync').service('main').exportAllConfig(this.configPrefix);
}
};
module.exports = ConfigType;

32
server/config/types.js Normal file
View File

@ -0,0 +1,32 @@
'use strict';
const ConfigType = require("./type");
module.exports = {
'i18n-locale': new ConfigType('plugin::i18n.locale', 'i18n-locale', 'code'),
'core-store': new ConfigType('strapi::core-store', 'core-store', 'key', ['value']),
'user-role': new ConfigType(
'plugin::users-permissions.role',
'user-role',
'type',
[],
[{
queryString: 'plugin::users-permissions.permission',
relationName: 'permissions',
parentName: 'role',
relationSortField: 'action',
}]
),
'admin-role': new ConfigType(
'admin::role',
'admin-role',
'code',
[],
[{
queryString: 'admin::permission',
relationName: 'permissions',
parentName: 'role',
relationSortField: 'action',
}]
),
};

View File

@ -0,0 +1,86 @@
'use strict';
const fs = require('fs');
const { isEmpty } = require('lodash');
/**
* Main controllers for config import/export.
*/
module.exports = {
/**
* Export all config, from db to filesystem.
*
* @param {object} ctx - Request context object.
* @returns {void}
*/
exportAll: async (ctx) => {
if (isEmpty(ctx.request.body)) {
await strapi.plugin('config-sync').service('main').exportAllConfig();
} else {
await Promise.all(ctx.request.body.map(async (configName) => {
await strapi.plugin('config-sync').service('main').exportSingleConfig(configName);
}));
}
ctx.send({
message: `Config was successfully exported to ${strapi.config.get('plugin.config-sync.destination')}.`,
});
},
/**
* Import all config, from filesystem to db.
*
* @param {object} ctx - Request context object.
* @returns {void}
*/
importAll: async (ctx) => {
// Check for existance of the config file destination dir.
if (!fs.existsSync(strapi.config.get('plugin.config-sync.destination'))) {
ctx.send({
message: 'No config files were found.',
});
return;
}
if (!ctx.request.body) {
ctx.send({
message: 'No config was specified for the export endpoint.',
});
return;
}
await Promise.all(ctx.request.body.map(async (configName) => {
await strapi.plugin('config-sync').service('main').importSingleConfig(configName);
}));
ctx.send({
message: 'Config was successfully imported.',
});
},
/**
* Get config diff between filesystem & db.
*
* @param {object} ctx - Request context object.
* @returns {object} formattedDiff - The formatted diff object.
* @returns {object} formattedDiff.fileConfig - The config as found in the filesystem.
* @returns {object} formattedDiff.databaseConfig - The config as found in the database.
* @returns {object} formattedDiff.diff - The diff between the file config and databse config.
*/
getDiff: async (ctx) => {
// Check for existance of the config file destination dir.
if (!fs.existsSync(strapi.config.get('plugin.config-sync.destination'))) {
ctx.send({
message: 'No config files were found.',
});
return;
}
return strapi.plugin('config-sync').service('main').getFormattedDiff();
},
};

View File

@ -0,0 +1,7 @@
'use strict';
const config = require('./config');
module.exports = {
config: config,
};

31
server/routes/admin.js Normal file
View File

@ -0,0 +1,31 @@
'use strict';
module.exports = {
type: 'admin',
routes: [
{
method: "POST",
path: "/export",
handler: "config.exportAll",
config: {
policies: [],
},
},
{
method: "POST",
path: "/import",
handler: "config.importAll",
config: {
policies: [],
},
},
{
method: "GET",
path: "/diff",
handler: "config.getDiff",
config: {
policies: [],
},
},
],
};

7
server/routes/index.js Normal file
View File

@ -0,0 +1,7 @@
'use strict';
const adminRoutes = require('./admin');
module.exports = {
admin: adminRoutes,
};

7
server/services/index.js Normal file
View File

@ -0,0 +1,7 @@
'use strict';
const main = require('./main');
module.exports = {
main,
};

282
server/services/main.js Normal file
View File

@ -0,0 +1,282 @@
'use strict';
const fs = require('fs');
const util = require('util');
const types = require('../config/types');
const difference = require('../utils/getObjectDiff');
/**
* Main services for config import/export.
*/
module.exports = () => ({
/**
* Write a single config file.
*
* @param {string} configType - The type of the config.
* @param {string} configName - The name of the config file.
* @param {string} fileContents - The JSON content of the config file.
* @returns {void}
*/
writeConfigFile: async (configType, configName, fileContents) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(`${configType}.${configName}`);
if (shouldExclude) return;
// Replace ':' with '#' in filenames for Windows support.
configName = configName.replace(/:/g, "#");
// Check if the JSON content should be minified.
const json = !strapi.config.get('plugin.config-sync').minify
? JSON.stringify(fileContents, null, 2)
: JSON.stringify(fileContents);
if (!fs.existsSync(strapi.config.get('plugin.config-sync.destination'))) {
fs.mkdirSync(strapi.config.get('plugin.config-sync.destination'), { recursive: true });
}
const writeFile = util.promisify(fs.writeFile);
await writeFile(`${strapi.config.get('plugin.config-sync.destination')}${configType}.${configName}.json`, json)
.then(() => {
// @TODO:
// Add logging for successfull config export.
})
.catch(() => {
// @TODO:
// Add logging for failed config export.
});
},
/**
* Delete config file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
deleteConfigFile: async (configName) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(`${configName}`);
if (shouldExclude) return;
// Replace ':' with '#' in filenames for Windows support.
configName = configName.replace(/:/g, "#");
fs.unlinkSync(`${strapi.config.get('plugin.config-sync.destination')}${configName}.json`);
},
/**
* Read from a config file.
*
* @param {string} configType - The type of config.
* @param {string} configName - The name of the config file.
* @returns {object} The JSON content of the config file.
*/
readConfigFile: async (configType, configName) => {
// Replace ':' with '#' in filenames for Windows support.
configName = configName.replace(/:/g, "#");
const readFile = util.promisify(fs.readFile);
return readFile(`${strapi.config.get('plugin.config-sync.destination')}${configType}.${configName}.json`)
.then((data) => {
return JSON.parse(data);
})
.catch(() => {
return null;
});
},
/**
* Get all the config JSON from the filesystem.
*
* @param {string} configType - Type of config to gather. Leave empty to get all config.
* @returns {object} Object with key value pairs of configs.
*/
getAllConfigFromFiles: async (configType = null) => {
if (!fs.existsSync(strapi.config.get('plugin.config-sync.destination'))) {
return {};
}
const configFiles = fs.readdirSync(strapi.config.get('plugin.config-sync.destination'));
const getConfigs = async () => {
const fileConfigs = {};
await Promise.all(configFiles.map(async (file) => {
const type = file.split('.')[0]; // Grab the first part of the filename.
const name = file.split(/\.(.+)/)[1].split('.').slice(0, -1).join('.'); // Grab the rest of the filename minus the file extension.
// Replace ':' with '#' in filenames for Windows support.
const formattedName = name.replace(/#/g, ":");
if (
configType && configType !== type
|| !strapi.config.get('plugin.config-sync.include').includes(type)
|| strapi.config.get('plugin.config-sync.exclude').includes(`${type}.${name}`)
) {
return;
}
const fileContents = await strapi.plugin('config-sync').service('main').readConfigFile(type, name);
fileConfigs[`${type}.${formattedName}`] = fileContents;
}));
return fileConfigs;
};
return getConfigs();
},
/**
* Get all the config JSON from the database.
*
* @param {string} configType - Type of config to gather. Leave empty to get all config.
* @returns {object} Object with key value pairs of configs.
*/
getAllConfigFromDatabase: async (configType = null) => {
const getConfigs = async () => {
let databaseConfigs = {};
await Promise.all(strapi.config.get('plugin.config-sync.include').map(async (type) => {
if (configType && configType !== type) {
return;
}
const config = await types[type].getAllFromDatabase();
databaseConfigs = Object.assign(config, databaseConfigs);
}));
return databaseConfigs;
};
return getConfigs();
},
/**
* Import all config files into the db.
*
* @param {string} configType - Type of config to impor. Leave empty to import all config.
* @param {object} onSuccess - Success callback to run on each single successfull import.
* @returns {void}
*/
importAllConfig: async (configType = null, onSuccess) => {
const fileConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromFiles();
const databaseConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromDatabase();
const diff = difference(databaseConfig, fileConfig);
await Promise.all(Object.keys(diff).map(async (file) => {
const type = file.split('.')[0]; // Grab the first part of the filename.
const name = file.split(/\.(.+)/)[1]; // Grab the rest of the filename minus the file extension.
if (configType && configType !== type) {
return;
}
await strapi.plugin('config-sync').service('main').importSingleConfig(`${type}.${name}`, onSuccess);
}));
},
/**
* Export all config files.
*
* @param {string} configType - Type of config to export. Leave empty to export all config.
* @param {object} onSuccess - Success callback to run on each single successfull import.
* @returns {void}
*/
exportAllConfig: async (configType = null, onSuccess) => {
const fileConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromFiles();
const databaseConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromDatabase();
const diff = difference(databaseConfig, fileConfig);
await Promise.all(Object.keys(diff).map(async (file) => {
const type = file.split('.')[0]; // Grab the first part of the filename.
const name = file.split(/\.(.+)/)[1]; // Grab the rest of the filename minus the file extension.
if (configType && configType !== type) {
return;
}
await strapi.plugin('config-sync').service('main').exportSingleConfig(`${type}.${name}`, onSuccess);
}));
},
/**
* Import a single config file into the db.
*
* @param {string} configName - The name of the config file.
* @param {object} onSuccess - Success callback to run on each single successfull import.
* @returns {void}
*/
importSingleConfig: async (configName, onSuccess) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(configName);
if (shouldExclude) return;
const [type, name] = configName.split('.'); // Split the configName.
const fileContents = await strapi.plugin('config-sync').service('main').readConfigFile(type, name);
try {
await types[type].importSingle(name, fileContents);
if (onSuccess) {
onSuccess(`${type}.${name}`);
}
} catch (e) {
throw new Error(e);
}
},
/**
* Export a single config file.
*
* @param {string} configName - The name of the config file.
* @param {object} onSuccess - Success callback to run on each single successfull import.
* @returns {void}
*/
exportSingleConfig: async (configName, onSuccess) => {
// Check if the config should be excluded.
const shouldExclude = strapi.config.get('plugin.config-sync.exclude').includes(configName);
if (shouldExclude) return;
const [type, name] = configName.split('.'); // Split the configName.
try {
await types[type].exportSingle(configName);
if (onSuccess) {
onSuccess(`${type}.${name}`);
}
} catch (e) {
throw new Error(e);
}
},
/**
* Get the formatted diff.
*
* @param {string} configType - Type of config to get the diff of. Leave empty to get the diff of all config.
*
* @returns {object} - the formatted diff.
*/
getFormattedDiff: async (configType = null) => {
const formattedDiff = {
fileConfig: {},
databaseConfig: {},
diff: {},
};
const fileConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromFiles(configType);
const databaseConfig = await strapi.plugin('config-sync').service('main').getAllConfigFromDatabase(configType);
const diff = difference(databaseConfig, fileConfig);
formattedDiff.diff = diff;
Object.keys(diff).map((changedConfigName) => {
formattedDiff.fileConfig[changedConfigName] = fileConfig[changedConfigName];
formattedDiff.databaseConfig[changedConfigName] = databaseConfig[changedConfigName];
});
return formattedDiff;
},
});

View File

@ -0,0 +1,7 @@
const difference = (arrayOne, arrayTwo, compareKey) => {
return arrayOne.filter(({ [compareKey]: id1 }) => {
return !arrayTwo.some(({ [compareKey]: id2 }) => id2 === id1);
});
};
module.exports = difference;

View File

@ -0,0 +1,30 @@
'use strict';
const { transform, isEqual, isArray, isObject } = require('lodash');
/**
* Find difference between two objects
* @param {object} origObj - Source object to compare newObj against
* @param {object} newObj - New object with potential changes
* @return {object} differences
*/
const difference = (origObj, newObj) => {
let arrayIndexCounter = 0;
const newObjChange = transform(newObj, (result, value, key) => {
if (!isEqual(value, origObj[key])) {
const resultKey = isArray(origObj) ? arrayIndexCounter++ : key;
result[resultKey] = (isObject(value) && isObject(origObj[key])) ? difference(value, origObj[key]) : value;
}
});
const origObjChange = transform(origObj, (result, value, key) => {
if (!isEqual(value, newObj[key])) {
const resultKey = isArray(newObj) ? arrayIndexCounter++ : key;
result[resultKey] = (isObject(value) && isObject(newObj[key])) ? difference(value, newObj[key]) : value;
}
});
return Object.assign(newObjChange, origObjChange);
};
module.exports = difference;

93
server/utils/index.js Normal file
View File

@ -0,0 +1,93 @@
'use strict';
const getCoreStore = () => {
return strapi.store({ type: 'plugin', name: 'config-sync' });
};
const getService = (name) => {
return strapi.plugin('config-sync').service(name);
};
const logMessage = (msg = '') => `[strapi-plugin-config-sync]: ${msg}`;
const sortByKeys = (unordered) => {
return Object.keys(unordered).sort().reduce((obj, key) => {
obj[key] = unordered[key];
return obj;
},
{}
);
};
const dynamicSort = (property) => {
let sortOrder = 1;
if (property[0] === "-") {
sortOrder = -1;
property = property.substr(1);
}
return (a, b) => {
if (sortOrder === -1) {
return b[property].localeCompare(a[property]);
} else {
return a[property].localeCompare(b[property]);
}
};
};
const sanitizeConfig = (config, relation, relationSortField) => {
delete config._id;
delete config.id;
delete config.updatedAt;
delete config.createdAt;
if (relation) {
const formattedRelations = [];
config[relation].map((relationEntity) => {
delete relationEntity._id;
delete relationEntity.id;
delete relationEntity.updatedAt;
delete relationEntity.createdAt;
relationEntity = sortByKeys(relationEntity);
formattedRelations.push(relationEntity);
});
if (relationSortField) {
formattedRelations.sort(dynamicSort(relationSortField));
}
config[relation] = formattedRelations;
}
return config;
};
const noLimit = async (query, parameters, limit = 100) => {
let entries = [];
const amountOfEntries = await query.count(parameters);
for (let i = 0; i < (amountOfEntries / limit); i++) {
/* eslint-disable-next-line */
const chunk = await query.findMany({
...parameters,
limit: limit,
offset: (i * limit),
});
entries = [...chunk, ...entries];
}
return entries;
};
module.exports = {
getService,
getCoreStore,
logMessage,
sanitizeConfig,
sortByKeys,
dynamicSort,
noLimit,
};

View File

@ -1,127 +0,0 @@
'use strict';
const coreStoreQueryString = 'core_store';
const configPrefix = 'core-store'; // Should be the same as the filename.
const difference = require('../utils/getObjectDiff');
/**
* Import/Export for core-store configs.
*/
module.exports = {
/**
* Export all core-store config to files.
*
* @returns {void}
*/
exportAll: async () => {
const formattedDiff = {
fileConfig: {},
databaseConfig: {},
diff: {}
};
const fileConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromFiles(configPrefix);
const databaseConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromDatabase(configPrefix);
const diff = difference(databaseConfig, fileConfig);
formattedDiff.diff = diff;
Object.keys(diff).map((changedConfigName) => {
formattedDiff.fileConfig[changedConfigName] = fileConfig[changedConfigName];
formattedDiff.databaseConfig[changedConfigName] = databaseConfig[changedConfigName];
})
await Promise.all(Object.entries(diff).map(async ([configName, config]) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configName}`);
if (shouldExclude) return;
const currentConfig = formattedDiff.databaseConfig[configName];
if (
!currentConfig &&
formattedDiff.fileConfig[configName]
) {
await strapi.plugins['config-sync'].services.main.deleteConfigFile(configName);
} else {
await strapi.plugins['config-sync'].services.main.writeConfigFile(configPrefix, currentConfig.key, currentConfig);
}
}));
},
/**
* Import a single core-store config file into the db.
*
* @param {string} configName - The name of the config file.
* @param {string} configContent - The JSON content of the config file.
* @returns {void}
*/
importSingle: async (configName, configContent) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${configName}`);
if (shouldExclude) return;
const coreStoreAPI = strapi.query(coreStoreQueryString);
const configExists = await coreStoreAPI
.findOne({ key: configName });
if (configExists && configContent === null) {
await coreStoreAPI.delete({ key: configName });
return;
}
const { value, ...fileContent } = configContent;
if (!configExists) {
await coreStoreAPI.create({ value: JSON.stringify(value), ...fileContent });
} else {
await coreStoreAPI.update({ key: configName }, { value: JSON.stringify(value), ...fileContent });
}
},
/**
* Get all core-store config from the db.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllFromDatabase: async () => {
const coreStore = await strapi.query(coreStoreQueryString).find({ _limit: -1 });
let configs = {};
Object.values(coreStore).map( ({ id, value, key, ...config }) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${key}`);
if (shouldExclude) return;
// Do not export the _id field, as it is immutable
delete config._id;
configs[`${configPrefix}.${key}`] = { key, value: JSON.parse(value), ...config };
});
return configs;
},
/**
* Import all core-store config files into the db.
*
* @returns {void}
*/
importAll: async () => {
// The main.importAllConfig service will loop the core-store.importSingle service.
await strapi.plugins['config-sync'].services.main.importAllConfig(configPrefix);
},
/**
* Export a single core-store config to a file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
exportSingle: async (configName) => {
// @TODO: write export for a single core-store config.
},
};

View File

@ -1,206 +0,0 @@
'use strict';
const fs = require('fs');
const util = require('util');
const difference = require('../utils/getObjectDiff');
/**
* Main services for config import/export.
*/
module.exports = {
/**
* Write a single config file.
*
* @param {string} configType - The type of the config.
* @param {string} configName - The name of the config file.
* @param {string} fileContents - The JSON content of the config file.
* @returns {void}
*/
writeConfigFile: async (configType, configName, fileContents) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configType}.${configName}`);
if (shouldExclude) return;
// Check if the JSON content should be minified.
const json =
!strapi.plugins['config-sync'].config.minify ?
JSON.stringify(fileContents, null, 2)
: JSON.stringify(fileContents);
if (!fs.existsSync(strapi.plugins['config-sync'].config.destination)) {
fs.mkdirSync(strapi.plugins['config-sync'].config.destination, { recursive: true });
}
const writeFile = util.promisify(fs.writeFile);
await writeFile(`${strapi.plugins['config-sync'].config.destination}${configType}.${configName}.json`, json)
.then(() => {
// @TODO:
// Add logging for successfull config export.
})
.catch(() => {
// @TODO:
// Add logging for failed config export.
});
},
/**
* Delete config file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
deleteConfigFile: async (configName) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configName}`);
if (shouldExclude) return;
fs.unlinkSync(`${strapi.plugins['config-sync'].config.destination}${configName}.json`);
},
/**
* Read from a config file.
*
* @param {string} configType - The type of config.
* @param {string} configName - The name of the config file.
* @returns {object} The JSON content of the config file.
*/
readConfigFile: async (configType, configName) => {
const readFile = util.promisify(fs.readFile);
return await readFile(`${strapi.plugins['config-sync'].config.destination}${configType}.${configName}.json`)
.then((data) => {
return JSON.parse(data);
})
.catch(() => {
return null;
});
},
/**
* Get all the config JSON from the filesystem.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllConfigFromFiles: async (configType = null) => {
if (!fs.existsSync(strapi.plugins['config-sync'].config.destination)) {
return {};
}
const configFiles = fs.readdirSync(strapi.plugins['config-sync'].config.destination);
const getConfigs = async () => {
let fileConfigs = {};
await Promise.all(configFiles.map(async (file) => {
const type = file.split('.')[0]; // Grab the first part of the filename.
const name = file.split(/\.(.+)/)[1].split('.').slice(0, -1).join('.'); // Grab the rest of the filename minus the file extension.
if (
configType && configType !== type ||
!strapi.plugins['config-sync'].config.include.includes(type) ||
strapi.plugins['config-sync'].config.exclude.includes(`${type}.${name}`)
) {
return;
}
const fileContents = await strapi.plugins['config-sync'].services.main.readConfigFile(type, name);
fileConfigs[`${type}.${name}`] = fileContents;
}));
return fileConfigs;
};
return await getConfigs();
},
/**
* Get all the config JSON from the database.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllConfigFromDatabase: async (configType = null) => {
const getConfigs = async () => {
let databaseConfigs = {};
await Promise.all(strapi.plugins['config-sync'].config.include.map(async (type) => {
if (configType && configType !== type) {
return;
}
const config = await strapi.plugins['config-sync'].services[type].getAllFromDatabase();
databaseConfigs = Object.assign(config, databaseConfigs);
}));
return databaseConfigs;
}
return await getConfigs();
},
/**
* Import all config files into the db.
*
* @returns {void}
*/
importAllConfig: async (configType = null) => {
const fileConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromFiles();
const databaseConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromDatabase();
const diff = difference(databaseConfig, fileConfig);
await Promise.all(Object.keys(diff).map(async (file) => {
const type = file.split('.')[0]; // Grab the first part of the filename.
const name = file.split(/\.(.+)/)[1]; // Grab the rest of the filename minus the file extension.
if (configType && configType !== type) {
return;
}
await strapi.plugins['config-sync'].services.main.importSingleConfig(type, name);
}));
},
/**
* Export all config files.
*
* @returns {void}
*/
exportAllConfig: async (configType = null) => {
await Promise.all(strapi.plugins['config-sync'].config.include.map(async (type) => {
if (configType && configType !== type) {
return;
}
await strapi.plugins['config-sync'].services[type].exportAll();
}));
},
/**
* Import a single config file into the db.
*
* @param {string} configType - The type of config.
* @param {string} configName - The name of the config file.
* @returns {void}
*/
importSingleConfig: async (configType, configName) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configType}.${configName}`);
if (shouldExclude) return;
const fileContents = await strapi.plugins['config-sync'].services.main.readConfigFile(configType, configName);
await strapi.plugins['config-sync'].services[configType].importSingle(configName, fileContents);
},
/**
* Export a single config file.
*
* @param {string} configType - The type of config.
* @param {string} configName - The name of the config file.
* @returns {void}
*/
exportSingleConfig: async (configType, configName) => {
},
};

View File

@ -1,152 +0,0 @@
'use strict';
const { sanitizeEntity } = require('strapi-utils');
const difference = require('../utils/getObjectDiff');
const configPrefix = 'role-permissions'; // Should be the same as the filename.
/**
* Import/Export for role-permissions configs.
*/
module.exports = {
/**
* Export all role-permissions config to files.
*
* @returns {void}
*/
exportAll: async () => {
const formattedDiff = {
fileConfig: {},
databaseConfig: {},
diff: {}
};
const fileConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromFiles(configPrefix);
const databaseConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromDatabase(configPrefix);
const diff = difference(databaseConfig, fileConfig);
formattedDiff.diff = diff;
Object.keys(diff).map((changedConfigName) => {
formattedDiff.fileConfig[changedConfigName] = fileConfig[changedConfigName];
formattedDiff.databaseConfig[changedConfigName] = databaseConfig[changedConfigName];
})
await Promise.all(Object.entries(diff).map(async ([configName, config]) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configName}`);
if (shouldExclude) return;
const currentConfig = formattedDiff.databaseConfig[configName];
if (
!currentConfig &&
formattedDiff.fileConfig[configName]
) {
await strapi.plugins['config-sync'].services.main.deleteConfigFile(configName);
} else {
await strapi.plugins['config-sync'].services.main.writeConfigFile(configPrefix, currentConfig.type, currentConfig);
}
}));
},
/**
* Import a single role-permissions config file into the db.
*
* @param {string} configName - The name of the config file.
* @param {string} configContent - The JSON content of the config file.
* @returns {void}
*/
importSingle: async (configName, configContent) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${configName}`);
if (shouldExclude) return;
const service =
strapi.plugins['users-permissions'].services.userspermissions;
const role = await strapi
.query('role', 'users-permissions')
.findOne({ type: configName });
if (role && configContent === null) {
const publicRole = await strapi.query('role', 'users-permissions').findOne({ type: 'public' });
const publicRoleID = publicRole.id;
await service.deleteRole(role.id, publicRoleID);
return;
}
const users = role ? role.users : [];
configContent.users = users;
if (!role) {
await service.createRole(configContent);
} else {
await service.updateRole(role.id, configContent);
}
},
/**
* Get all role-permissions config from the db.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllFromDatabase: async () => {
const service =
strapi.plugins['users-permissions'].services.userspermissions;
const [roles, plugins] = await Promise.all([
service.getRoles(),
service.getPlugins(),
]);
const rolesWithPermissions = await Promise.all(
roles.map(async role => service.getRole(role.id, plugins))
);
const sanitizedRolesArray = rolesWithPermissions.map(role =>
sanitizeEntity(role, {
model: strapi.plugins['users-permissions'].models.role,
})
);
let configs = {};
Object.values(sanitizedRolesArray).map(({ id, ...config }) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${config.type}`);
if (shouldExclude) return;
// Do not export the _id field, as it is immutable
delete config._id;
configs[`${configPrefix}.${config.type}`] = config;
});
return configs;
},
/**
* Import all role-permissions config files into the db.
*
* @returns {void}
*/
importAll: async () => {
// The main.importAllConfig service will loop the role-permissions.importSingle service.
await strapi.plugins['config-sync'].services.main.importAllConfig(configPrefix);
},
/**
* Export a single role-permissions config to a file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
exportSingle: async (configName) => {
// @TODO: write export for a single role-permissions config.
},
};

View File

@ -1,121 +0,0 @@
'use strict';
/**
* Import/Export for webhook configs.
*/
const webhookQueryString = 'strapi_webhooks';
const configPrefix = 'webhooks'; // Should be the same as the filename.
const difference = require('../utils/getObjectDiff');
module.exports = {
/**
* Export all webhooks to config files.
*
* @returns {void}
*/
exportAll: async () => {
const formattedDiff = {
fileConfig: {},
databaseConfig: {},
diff: {}
};
const fileConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromFiles(configPrefix);
const databaseConfig = await strapi.plugins['config-sync'].services.main.getAllConfigFromDatabase(configPrefix);
const diff = difference(databaseConfig, fileConfig);
formattedDiff.diff = diff;
Object.keys(diff).map((changedConfigName) => {
formattedDiff.fileConfig[changedConfigName] = fileConfig[changedConfigName];
formattedDiff.databaseConfig[changedConfigName] = databaseConfig[changedConfigName];
})
await Promise.all(Object.entries(diff).map(async ([configName, config]) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configName}`);
if (shouldExclude) return;
const currentConfig = formattedDiff.databaseConfig[configName];
if (
!currentConfig &&
formattedDiff.fileConfig[configName]
) {
await strapi.plugins['config-sync'].services.main.deleteConfigFile(configName);
} else {
await strapi.plugins['config-sync'].services.main.writeConfigFile(configPrefix, currentConfig.id, currentConfig);
}
}));
},
/**
* Import a single webhook config file into the db.
*
* @param {string} configName - The name of the config file.
* @param {string} configContent - The JSON content of the config file.
* @returns {void}
*/
importSingle: async (configName, configContent) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${configName}`);
if (shouldExclude) return;
const webhookAPI = strapi.query(webhookQueryString);
const configExists = await webhookAPI.findOne({ id: configName });
if (!configExists) {
await webhookAPI.create(configContent);
} else {
if (configContent === null) {
await webhookAPI.delete({ id: configName });
}
await webhookAPI.update({ id: configName }, configContent);
}
},
/**
* Get all webhook config from the db.
*
* @returns {object} Object with key value pairs of configs.
*/
getAllFromDatabase: async () => {
const webhooks = await strapi.query(webhookQueryString).find({ _limit: -1 });
let configs = {};
Object.values(webhooks).map( (config) => {
// Check if the config should be excluded.
const shouldExclude = strapi.plugins['config-sync'].config.exclude.includes(`${configPrefix}.${config.id}`);
if (shouldExclude) return;
// Do not export the _id field, as it is immutable
delete config._id;
configs[`${configPrefix}.${config.id}`] = config;
});
return configs;
},
/**
* Import all webhook config files into the db.
*
* @returns {void}
*/
importAll: async () => {
// The main.importAllConfig service will loop the webhooks.importSingle service.
await strapi.plugins['config-sync'].services.main.importAllConfig(configPrefix);
},
/**
* Export a single webhook into a config file.
*
* @param {string} configName - The name of the config file.
* @returns {void}
*/
exportSingle: async (configName) => {
// @TODO: write export for a single webhook config.
},
};

3
strapi-admin.js Normal file
View File

@ -0,0 +1,3 @@
'use strict';
module.exports = require('./admin/src').default;

17
strapi-server.js Normal file
View File

@ -0,0 +1,17 @@
'use strict';
const bootstrap = require('./server/bootstrap');
const services = require('./server/services');
const routes = require('./server/routes');
const config = require('./server/config');
const controllers = require('./server/controllers');
module.exports = () => {
return {
bootstrap,
routes,
config,
controllers,
services,
};
};

View File

@ -1,33 +0,0 @@
'use strict';
const { transform, isEqual, isArray, isObject } = require('lodash');
/**
* Find difference between two objects
* @param {object} origObj - Source object to compare newObj against
* @param {object} newObj - New object with potential changes
* @return {object} differences
*/
const difference = (origObj, newObj) => {
function changes(newObj, origObj) {
let arrayIndexCounter = 0
const newObjChange = transform(newObj, function (result, value, key) {
if (!isEqual(value, origObj[key])) {
let resultKey = isArray(origObj) ? arrayIndexCounter++ : key
result[resultKey] = (isObject(value) && isObject(origObj[key])) ? changes(value, origObj[key]) : value
}
});
const origObjChange = transform(origObj, function (result, value, key) {
if (!isEqual(value, newObj[key])) {
let resultKey = isArray(newObj) ? arrayIndexCounter++ : key
result[resultKey] = (isObject(value) && isObject(newObj[key])) ? changes(value, newObj[key]) : value
}
})
return Object.assign(newObjChange, origObjChange);
}
return changes(newObj, origObj)
}
module.exports = difference;

12473
yarn.lock Normal file

File diff suppressed because it is too large Load Diff