Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions admin/src/components/ConfigDiff/index.jsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
<Modal.Root>
<Modal.Trigger>
{trigger}
</Modal.Trigger>
<Modal.Root open={open} onOpenChange={onOpenChange}>
<Modal.Content>
<Modal.Header>
<Typography variant="omega" fontWeight="bold" textColor="neutral800">
Expand All @@ -40,17 +46,38 @@ const ConfigDiff = ({ oldValue, newValue, configName, trigger }) => {
</Modal.Header>
<Modal.Body>
<Grid.Root paddingBottom={4} style={{ textAlign: 'center' }}>
<Grid.Item col={6}>
<Typography variant="delta" style={{ width: '100%' }}>{formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })}</Typography>
<Grid.Item col={6} style={{ justifyContent: 'center' }}>
<Typography variant="delta" style={commonHeadingStyle}>{formatMessage({ id: 'config-sync.ConfigDiff.SyncDirectory' })}
</Typography>
<ConfirmModal
type="import"
trigger={(
<Button title={formatMessage({ id: 'config-sync.ConfigDiff.ImportTitle' })}>
{formatMessage({ id: 'config-sync.Buttons.Import' })}
</Button>
)}
onSubmit={(force) => dispatch(importAllConfig([configName], force, toggleNotification, formatMessage, post, get))}
/>
</Grid.Item>
<Grid.Item col={6}>
<Typography variant="delta" style={{ width: '100%' }}>{formatMessage({ id: 'config-sync.ConfigDiff.Database' })}</Typography>
<Grid.Item col={6} style={{ justifyContent: 'center' }}>
<Typography variant="delta" style={commonHeadingStyle}>
{formatMessage({ id: 'config-sync.ConfigDiff.Database' })}
</Typography>
<ConfirmModal
type="export"
trigger={(
<Button title={formatMessage({ id: 'config-sync.ConfigDiff.ExportTitle' })}>
{formatMessage({ id: 'config-sync.Buttons.Export' })}
</Button>
)}
onSubmit={() => dispatch(exportAllConfig([configName], toggleNotification, formatMessage, post, get))}
/>
</Grid.Item>
</Grid.Root>
<Typography variant="pi">
<ReactDiffViewer
oldValue={JSON.stringify(oldValue, null, 2)}
newValue={JSON.stringify(newValue, null, 2)}
oldValue={JSON.stringify(oldValue, null, 2) ?? ''}
newValue={JSON.stringify(newValue, null, 2) ?? ''}
splitView
compareMethod={DiffMethod.WORDS}
/>
Expand Down
6 changes: 3 additions & 3 deletions admin/src/components/ConfigList/ConfigListRow/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ const CustomRow = ({ row, checked, updateValue, ...props }) => {
onCheckedChange={updateValue}
/>
</Td>
<Td onClick={(e) => props.onClick(e)}>
<Td>
<Typography variant="omega">{configName}</Typography>
</Td>
<Td onClick={(e) => props.onClick(e)}>
<Td>
<Typography variant="omega">{configType}</Typography>
</Td>
<Td onClick={(e) => props.onClick(e)}>
<Td>
<Typography variant="omega" style={stateStyle(state)}>{state}</Typography>
</Td>
</Tr>
Expand Down
74 changes: 40 additions & 34 deletions admin/src/components/ConfigList/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down Expand Up @@ -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}`);
},
});
Expand All @@ -87,24 +83,25 @@ const ConfigList = ({ diff, isLoading }) => {
dispatch(setConfigPartialDiffInState(newPartialDiff));
}, [checkedItems]);

if (isLoading) {
return (
<div style={{ textAlign: 'center', marginTop: 40 }}>
<Loader>{formatMessage({ id: 'config-sync.ConfigList.Loading' })}</Loader>
</div>
);
}
const renderList = () => {
if (isLoading) {
return (
<div style={{ textAlign: 'center', marginTop: 40 }}>
<Loader>{formatMessage({ id: 'config-sync.ConfigList.Loading' })}</Loader>
</div>
);
}

if (!isLoading && !isEmpty(diff.message)) {
return <FirstExport />;
}
if (!isEmpty(diff.message)) {
return <FirstExport />;
}

if (!isLoading && isEmpty(diff.diff)) {
return <NoChanges />;
}
if (isEmpty(diff.diff)) {
return <NoChanges />;
}

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 (
<div>
Expand All @@ -131,26 +128,35 @@ const ConfigList = ({ diff, isLoading }) => {
</Thead>
<Tbody>
{rows.map((row, index) => (
<ConfigDiff
key={row.configName}
oldValue={originalConfig}
newValue={newConfig}
configName={cName}
trigger={(
<ConfigListRow
row={row}
checked={checkedItems[index]}
updateValue={() => {
checkedItems[index] = !checkedItems[index];
setCheckedItems([...checkedItems]);
}}
/>
)}
<ConfigListRow
key={`${row.configType}.${row.configName}`}
row={row}
checked={checkedItems[index]}
updateValue={() => {
checkedItems[index] = !checkedItems[index];
setCheckedItems([...checkedItems]);
}}
/>
))}
</Tbody>
</Table>
</div>
);
};

return (
<>
{renderList()}
<ConfigDiff
oldValue={diff.fileConfig?.[cName]}
newValue={diff.databaseConfig?.[cName]}
configName={cName}
open={Boolean(cName)}
onOpenChange={(open) => {
if (!open) setCname('');
}}
/>
</>
);
};

Expand Down
63 changes: 63 additions & 0 deletions admin/src/index.cy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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!');
}
});
});
});
2 changes: 2 additions & 0 deletions admin/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions admin/src/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions admin/src/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"ConfigDiff.Title": "Изменения конфигурации для",
"ConfigDiff.SyncDirectory": "Каталог синхронизации",
"ConfigDiff.Database": "База данных",
"ConfigDiff.ImportTitle": "Импортировать конфигурацию в базу данных только для этого файла",
"ConfigDiff.ExportTitle": "Экспортировать конфигурацию из базы данных только для этого файла",

"Buttons.Export": "Экспортировать",
"Buttons.DownloadConfig": "Скачать конфиг",
Expand Down
46 changes: 46 additions & 0 deletions playground/__tests__/cli.test.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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');
Expand Down