From a2ca88baf254c527ceeb1c1258f37344d6543e61 Mon Sep 17 00:00:00 2001 From: kat Date: Thu, 17 Sep 2026 11:28:35 +0100 Subject: [PATCH 1/6] test: individual import/export for a particular config file --- playground/__tests__/cli.test.js | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) 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'); From b172d90b2b59ac55be4e32e461f60ad0ed10e01f Mon Sep 17 00:00:00 2001 From: kat Date: Thu, 17 Sep 2026 12:16:36 +0100 Subject: [PATCH 2/6] feat: add import/export buttons for single config file --- admin/src/components/ConfigDiff/index.jsx | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/admin/src/components/ConfigDiff/index.jsx b/admin/src/components/ConfigDiff/index.jsx index 2599ac9..0d1e533 100644 --- a/admin/src/components/ConfigDiff/index.jsx +++ b/admin/src/components/ConfigDiff/index.jsx @@ -1,6 +1,9 @@ import React from 'react'; +import { useDispatch } from 'react-redux'; +import { Button } from '@strapi/design-system'; 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. @@ -23,9 +26,15 @@ import { Grid, Typography, } from '@strapi/design-system'; +import ConfirmModal from '../ConfirmModal'; +import { exportAllConfig, importAllConfig } from '../../state/actions/Config'; const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { const { formatMessage } = useIntl(); + const dispatch = useDispatch(); + const { toggleNotification } = useNotification(); + const { post, get } = getFetchClient(); + const commonHeadingStyle = { paddingRight: '2rem' }; return ( @@ -40,11 +49,24 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { - - {formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })} + + {formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })} + + Import} + onSubmit={(force) => dispatch(importAllConfig([configName], force, toggleNotification, formatMessage, post, get))} + /> - - {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} + + + {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} + + Export} + onSubmit={() => dispatch(exportAllConfig([configName], toggleNotification, formatMessage, post, get))} + /> From a28754385c327e8f18ed980708cbad51c87331c5 Mon Sep 17 00:00:00 2001 From: kat Date: Thu, 17 Sep 2026 12:29:25 +0100 Subject: [PATCH 3/6] feat: fix eslint errprs --- admin/src/components/ConfigDiff/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/admin/src/components/ConfigDiff/index.jsx b/admin/src/components/ConfigDiff/index.jsx index 0d1e533..abdf266 100644 --- a/admin/src/components/ConfigDiff/index.jsx +++ b/admin/src/components/ConfigDiff/index.jsx @@ -1,6 +1,5 @@ import React from 'react'; import { useDispatch } from 'react-redux'; -import { Button } from '@strapi/design-system'; import RDV, { DiffMethod } from 'react-diff-viewer-continued'; import { useIntl } from 'react-intl'; import { getFetchClient, useNotification } from '@strapi/strapi/admin'; @@ -22,6 +21,7 @@ if (typeof RDV.default !== 'undefined') { } import { + Button, Modal, Grid, Typography, @@ -49,7 +49,7 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { - + {formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })} { - {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} + {formatMessage({ id: 'config-sync.ConfigDiff.Database' })} Date: Fri, 6 Nov 2026 15:34:56 +0000 Subject: [PATCH 4/6] fix(admin): localize single-file diff controls Use translated Import/Export labels and tooltips in the diff modal, with English, Spanish, and Russian catalog entries. --- admin/src/components/ConfigDiff/index.jsx | 12 ++++++++++-- admin/src/translations/en.json | 2 ++ admin/src/translations/es.json | 2 ++ admin/src/translations/ru.json | 2 ++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/admin/src/components/ConfigDiff/index.jsx b/admin/src/components/ConfigDiff/index.jsx index abdf266..482f350 100644 --- a/admin/src/components/ConfigDiff/index.jsx +++ b/admin/src/components/ConfigDiff/index.jsx @@ -54,7 +54,11 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { Import} + trigger={( + + )} onSubmit={(force) => dispatch(importAllConfig([configName], force, toggleNotification, formatMessage, post, get))} /> @@ -64,7 +68,11 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { Export} + trigger={( + + )} onSubmit={() => dispatch(exportAllConfig([configName], toggleNotification, formatMessage, post, get))} /> 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": "Скачать конфиг", From 3a313a85fd414a9622c3ac4e6badb66f45033b26 Mon Sep 17 00:00:00 2001 From: kat Date: Fri, 6 Nov 2026 15:36:34 +0000 Subject: [PATCH 5/6] fix(admin): refresh open single-file diff Derive modal values from the latest diff state and keep the modal controlled across import/export refreshes. --- admin/src/components/ConfigDiff/index.jsx | 11 +-- .../ConfigList/ConfigListRow/index.jsx | 6 +- admin/src/components/ConfigList/index.jsx | 74 ++++++++++--------- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/admin/src/components/ConfigDiff/index.jsx b/admin/src/components/ConfigDiff/index.jsx index 482f350..893e19c 100644 --- a/admin/src/components/ConfigDiff/index.jsx +++ b/admin/src/components/ConfigDiff/index.jsx @@ -29,7 +29,7 @@ import { 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(); @@ -37,10 +37,7 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { const commonHeadingStyle = { paddingRight: '2rem' }; return ( - - - {trigger} - + @@ -79,8 +76,8 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => { 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(''); + }} + /> + ); }; From fb442fdeb394c5add76eea79c52f454d898f7332 Mon Sep 17 00:00:00 2001 From: kat Date: Fri, 6 Nov 2026 15:36:58 +0000 Subject: [PATCH 6/6] test(admin): cover single-file diff modal actions Add Cypress coverage for localized import/export controls and refreshed diff content while the modal remains open. --- admin/src/index.cy.jsx | 63 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) 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!'); + } + }); + }); });