diff --git a/admin/src/components/ConfigDiff/index.jsx b/admin/src/components/ConfigDiff/index.jsx index 2599ac9..893e19c 100644 --- a/admin/src/components/ConfigDiff/index.jsx +++ b/admin/src/components/ConfigDiff/index.jsx @@ -1,6 +1,8 @@ import React from 'react'; +import { useDispatch } from 'react-redux'; import RDV, { DiffMethod } from 'react-diff-viewer-continued'; import { useIntl } from 'react-intl'; +import { getFetchClient, useNotification } from '@strapi/strapi/admin'; /** * An issue with the diff-viewer library causes a difference in the way the library is exported. @@ -19,19 +21,23 @@ if (typeof RDV.default !== 'undefined') { } import { + Button, Modal, Grid, Typography, } from '@strapi/design-system'; +import ConfirmModal from '../ConfirmModal'; +import { exportAllConfig, importAllConfig } from '../../state/actions/Config'; -const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { +const ConfigDiff = ({ oldValue, newValue, configName, open, onOpenChange }) => { const { formatMessage } = useIntl(); + const dispatch = useDispatch(); + const { toggleNotification } = useNotification(); + const { post, get } = getFetchClient(); + const commonHeadingStyle = { paddingRight: '2rem' }; return ( - - - {trigger} - + @@ -40,17 +46,38 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { - - {formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })} + + {formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })} + + + {formatMessage({ id: 'config-sync.Buttons.Import' })} + + )} + onSubmit={(force) => dispatch(importAllConfig([configName], force, toggleNotification, formatMessage, post, get))} + /> - - {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} + + + {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} + + + {formatMessage({ id: 'config-sync.Buttons.Export' })} + + )} + onSubmit={() => dispatch(exportAllConfig([configName], toggleNotification, formatMessage, post, get))} + /> diff --git a/admin/src/components/ConfigList/ConfigListRow/index.jsx b/admin/src/components/ConfigList/ConfigListRow/index.jsx index 43da06c..081fe39 100644 --- a/admin/src/components/ConfigList/ConfigListRow/index.jsx +++ b/admin/src/components/ConfigList/ConfigListRow/index.jsx @@ -49,13 +49,13 @@ const CustomRow = ({ row, checked, updateValue, ...props }) => { onCheckedChange={updateValue} /> - props.onClick(e)}> + {configName} - props.onClick(e)}> + {configType} - props.onClick(e)}> + {state} diff --git a/admin/src/components/ConfigList/index.jsx b/admin/src/components/ConfigList/index.jsx index 7b0202a..f8edef4 100644 --- a/admin/src/components/ConfigList/index.jsx +++ b/admin/src/components/ConfigList/index.jsx @@ -22,8 +22,6 @@ import { setConfigPartialDiffInState } from '../../state/actions/Config'; const ConfigList = ({ diff, isLoading }) => { - const [originalConfig, setOriginalConfig] = useState({}); - const [newConfig, setNewConfig] = useState({}); const [cName, setCname] = useState(''); const [rows, setRows] = useState([]); const [checkedItems, setCheckedItems] = useState([]); @@ -68,8 +66,6 @@ const ConfigList = ({ diff, isLoading }) => { configType: type, state: getConfigState(name), onClick: (configType, configName) => { - setOriginalConfig(diff.fileConfig[`${configType}.${configName}`]); - setNewConfig(diff.databaseConfig[`${configType}.${configName}`]); setCname(`${configType}.${configName}`); }, }); @@ -87,24 +83,25 @@ const ConfigList = ({ diff, isLoading }) => { dispatch(setConfigPartialDiffInState(newPartialDiff)); }, [checkedItems]); - if (isLoading) { - return ( -
- {formatMessage({ id: 'config-sync.ConfigList.Loading' })} -
- ); - } + const renderList = () => { + if (isLoading) { + return ( +
+ {formatMessage({ id: 'config-sync.ConfigList.Loading' })} +
+ ); + } - if (!isLoading && !isEmpty(diff.message)) { - return ; - } + if (!isEmpty(diff.message)) { + return ; + } - if (!isLoading && isEmpty(diff.diff)) { - return ; - } + if (isEmpty(diff.diff)) { + return ; + } - const allChecked = checkedItems && checkedItems.every(Boolean); - const isIndeterminate = checkedItems.some(Boolean) && !allChecked; + const allChecked = checkedItems && checkedItems.every(Boolean); + const isIndeterminate = checkedItems.some(Boolean) && !allChecked; return (
@@ -131,26 +128,35 @@ const ConfigList = ({ diff, isLoading }) => { {rows.map((row, index) => ( - { - checkedItems[index] = !checkedItems[index]; - setCheckedItems([...checkedItems]); - }} - /> - )} + { + checkedItems[index] = !checkedItems[index]; + setCheckedItems([...checkedItems]); + }} /> ))}
+ ); + }; + + return ( + <> + {renderList()} + { + if (!open) setCname(''); + }} + /> + ); }; diff --git a/admin/src/index.cy.jsx b/admin/src/index.cy.jsx index e157500..2dd5347 100644 --- a/admin/src/index.cy.jsx +++ b/admin/src/index.cy.jsx @@ -71,4 +71,67 @@ describe('Config Sync', () => { cy.wait('@exportConfig').its('response.statusCode').should('equal', 200); cy.contains('plugin_users-permissions_email'); }); + + ['import', 'export'].forEach((action) => { + it(`Single-file ${action} refreshes the open diff modal`, () => { + const selectedConfig = 'core-store.plugin_users-permissions_advanced'; + const otherConfig = 'core-store.plugin_upload_settings'; + const fileConfig = { fileOnly: true }; + const databaseConfig = { databaseOnly: true }; + const initialDiff = { + diff: { [selectedConfig]: {}, [otherConfig]: {} }, + fileConfig: { [selectedConfig]: fileConfig, [otherConfig]: { enabled: true } }, + databaseConfig: { [selectedConfig]: databaseConfig, [otherConfig]: { enabled: false } }, + }; + const syncedConfig = action === 'import' ? fileConfig : databaseConfig; + const refreshedDiff = { + diff: action === 'import' ? { [otherConfig]: {} } : {}, + fileConfig: { ...initialDiff.fileConfig, [selectedConfig]: syncedConfig }, + databaseConfig: { ...initialDiff.databaseConfig, [selectedConfig]: syncedConfig }, + }; + let completed = false; + + cy.login(); + cy.navigateToInterface(); + cy.intercept('GET', '/config-sync/diff', (request) => { + request.reply(completed ? refreshedDiff : initialDiff); + }).as('modalDiff'); + cy.intercept('GET', '/config-sync/app-env', { + env: 'development', config: { soft: false }, + }); + cy.intercept('POST', `/config-sync/${action}`, (request) => { + completed = true; + request.reply({ message: 'Config synchronized.' }); + }).as('singleFileSync'); + cy.reload(); + cy.wait('@modalDiff'); + + cy.get('tbody tr').contains('plugin_users-permissions_advanced').click(); + cy.get('[role="dialog"]').should('contain', 'fileOnly').and('contain', 'databaseOnly'); + cy.get(`[role="dialog"] button[title="${action === 'import' + ? 'Import config into DB for this file only' + : 'Export DB config for this file only'}"]`).click(); + cy.contains('button', 'Cancel').click(); + cy.then(() => expect(completed).to.equal(false)); + + cy.get('[role="dialog"]').contains('button', action === 'import' ? 'Import' : 'Export').click(); + cy.contains('button', `Yes, ${action}`).click(); + cy.wait('@singleFileSync').then(({ request }) => { + expect(request.body).to.deep.equal(action === 'import' + ? { force: false, config: [selectedConfig] } + : [selectedConfig]); + }); + cy.wait('@modalDiff'); + cy.get('[role="dialog"]').should('be.visible') + .and('contain', selectedConfig) + .and('contain', action === 'import' ? 'fileOnly' : 'databaseOnly') + .and('not.contain', action === 'import' ? 'databaseOnly' : 'fileOnly'); + cy.get('tbody tr').should('not.contain', 'plugin_users-permissions_advanced'); + if (action === 'import') { + cy.get('tbody tr').should('contain', 'plugin_upload_settings'); + } else { + cy.contains('No differences between DB and sync directory. You are up-to-date!'); + } + }); + }); }); diff --git a/admin/src/translations/en.json b/admin/src/translations/en.json index e043d09..0e807e4 100644 --- a/admin/src/translations/en.json +++ b/admin/src/translations/en.json @@ -30,6 +30,8 @@ "ConfigDiff.Title": "Config changes for", "ConfigDiff.SyncDirectory": "Sync directory", "ConfigDiff.Database": "Database", + "ConfigDiff.ImportTitle": "Import config into DB for this file only", + "ConfigDiff.ExportTitle": "Export DB config for this file only", "Buttons.Export": "Export", "Buttons.DownloadConfig": "Download Config", diff --git a/admin/src/translations/es.json b/admin/src/translations/es.json index f624d91..a6a6f7e 100644 --- a/admin/src/translations/es.json +++ b/admin/src/translations/es.json @@ -26,6 +26,8 @@ "ConfigDiff.Title": "Cambios en la configuración para", "ConfigDiff.SyncDirectory": "Directorio de sincronización", "ConfigDiff.Database": "Base de datos", + "ConfigDiff.ImportTitle": "Importar configuración a la base de datos solo para este archivo", + "ConfigDiff.ExportTitle": "Exportar configuración de la base de datos solo para este archivo", "Buttons.Import": "Importar", "Buttons.Export": "Exportar", diff --git a/admin/src/translations/ru.json b/admin/src/translations/ru.json index f9fa0a3..a9e19e8 100644 --- a/admin/src/translations/ru.json +++ b/admin/src/translations/ru.json @@ -30,6 +30,8 @@ "ConfigDiff.Title": "Изменения конфигурации для", "ConfigDiff.SyncDirectory": "Каталог синхронизации", "ConfigDiff.Database": "База данных", + "ConfigDiff.ImportTitle": "Импортировать конфигурацию в базу данных только для этого файла", + "ConfigDiff.ExportTitle": "Экспортировать конфигурацию из базы данных только для этого файла", "Buttons.Export": "Экспортировать", "Buttons.DownloadConfig": "Скачать конфиг", diff --git a/playground/__tests__/cli.test.js b/playground/__tests__/cli.test.js index 8ace6c6..1006267 100644 --- a/playground/__tests__/cli.test.js +++ b/playground/__tests__/cli.test.js @@ -1,10 +1,16 @@ 'use strict'; +const fs = require('fs'); const util = require('util'); const exec = util.promisify(require('child_process').exec); jest.setTimeout(20000); +const selectedConfig = 'admin-role.strapi-editor'; +const otherConfig = 'admin-role.strapi-author'; +const selectedConfigPath = `config/sync/${selectedConfig}.json`; +const otherConfigPath = `config/sync/${otherConfig}.json`; + describe('Test the config-sync CLI', () => { afterAll(async () => { // Remove the generated files and the DB. @@ -19,6 +25,46 @@ describe('Test the config-sync CLI', () => { expect(diffOutput).toContain('No differences between DB and sync directory'); }); + test('Partial export only exports the selected config', async () => { + const selectedConfigBefore = fs.readFileSync(selectedConfigPath, 'utf8'); + const otherConfigBefore = fs.readFileSync(otherConfigPath, 'utf8'); + const selectedConfigData = JSON.parse(selectedConfigBefore); + selectedConfigData.description = `${selectedConfigData.description} (partial export test)`; + fs.writeFileSync(selectedConfigPath, JSON.stringify(selectedConfigData)); + + try { + const { stdout: exportOutput } = await exec(`yarn cs export --partial ${selectedConfig} -y`); + + expect(exportOutput).toContain(`Exported ${selectedConfig}`); + expect(exportOutput).not.toContain(`Exported ${otherConfig}`); + expect(JSON.parse(fs.readFileSync(selectedConfigPath, 'utf8'))).toEqual(JSON.parse(selectedConfigBefore)); + expect(fs.readFileSync(otherConfigPath, 'utf8')).toBe(otherConfigBefore); + } finally { + fs.writeFileSync(selectedConfigPath, selectedConfigBefore); + } + }); + + test('Partial import only imports the selected config', async () => { + const selectedConfigBefore = fs.readFileSync(selectedConfigPath, 'utf8'); + const otherConfigBefore = fs.readFileSync(otherConfigPath, 'utf8'); + const selectedConfigData = JSON.parse(selectedConfigBefore); + selectedConfigData.description = `${selectedConfigData.description} (partial import test)`; + fs.writeFileSync(selectedConfigPath, JSON.stringify(selectedConfigData)); + + try { + const { stdout: importOutput } = await exec(`yarn cs import --partial ${selectedConfig} -y`); + const { stdout: diffOutput } = await exec('yarn cs diff'); + + expect(importOutput).toContain(`Imported ${selectedConfig}`); + expect(importOutput).not.toContain(`Imported ${otherConfig}`); + expect(diffOutput).toContain('No differences between DB and sync directory'); + expect(fs.readFileSync(otherConfigPath, 'utf8')).toBe(otherConfigBefore); + } finally { + fs.writeFileSync(selectedConfigPath, selectedConfigBefore); + await exec(`yarn cs import --partial ${selectedConfig} -y`); + } + }); + test('Import (delete)', async () => { // Remove a file to trigger a delete. await exec('mv config/sync/admin-role.strapi-editor.json .tmp');