mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
console: add schema sharing
https://github.com/hasura/graphql-engine-mono/pull/1512 Co-authored-by: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com> GitOrigin-RevId: 71efbb2a5a577f765b83b1fd9ee9254a292d361d
This commit is contained in:
parent
e567a096e6
commit
e3e078fa42
@ -10,6 +10,7 @@
|
||||
field in the connection configuration
|
||||
- server: include action and event names in log output
|
||||
- server: log all HTTP errors in remote schema calls as `remote-schema-error` with details
|
||||
- console: add schema sharing
|
||||
|
||||
## v2.0.1
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"babel-plugin-styled-components",
|
||||
"transform-react-remove-prop-types",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
|
@ -107,7 +107,8 @@
|
||||
"react",
|
||||
"import",
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"react-hooks"
|
||||
"react-hooks",
|
||||
"testing-library"
|
||||
],
|
||||
"settings": {
|
||||
"import/resolve": {
|
||||
@ -215,6 +216,11 @@
|
||||
"react/jsx-indent": "off",
|
||||
"arrow-body-style": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// 3) Now we enable eslint-plugin-testing-library rules or preset only for matching files!
|
||||
"files": ["src/**/__tests__/**/*.[jt]s?(x)", "src/**/?(*.)+(spec|test).[jt]s?(x)"],
|
||||
"extends": ["plugin:testing-library/react", "plugin:jest-dom/recommended"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ build:
|
||||
npm run build
|
||||
|
||||
jest:
|
||||
npm run jest
|
||||
npm run jest -- --runInBand
|
||||
|
||||
test:
|
||||
npm run dev & npm run test
|
||||
|
37
console/cypress/integration/data/schema-sharing/test.ts
Normal file
37
console/cypress/integration/data/schema-sharing/test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { getIndexRoute } from '../../../helpers/dataHelpers';
|
||||
import { setMetaData } from '../../validators/validators';
|
||||
import { setPromptValue, testMode } from '../../../helpers/common';
|
||||
|
||||
const setup = () => {
|
||||
describe('Setup route', () => {
|
||||
it('Visit the index route', () => {
|
||||
cy.visit(getIndexRoute());
|
||||
cy.wait(7000);
|
||||
setMetaData();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const runSchemaSharingTests = () => {
|
||||
describe('schema sharing', () => {
|
||||
it('display content', () => {
|
||||
cy.contains('default').click();
|
||||
cy.get('[data-test=schema-container-tabs-data-gallery]').click();
|
||||
const oneToOne = cy.get('table').contains('Relationships: One-to-One');
|
||||
oneToOne.click();
|
||||
cy.contains('Install Schema').click();
|
||||
cy.wait(1000);
|
||||
const installed = cy.get('[data-test=table-links]').contains('_onetoone');
|
||||
installed.click();
|
||||
setPromptValue('_onetoone');
|
||||
cy.contains('_onetoone').parent().parent().contains('owner');
|
||||
cy.contains('_onetoone').parent().parent().contains('passport_info');
|
||||
cy.get('[title="Delete current schema"]').click();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (testMode !== 'cli') {
|
||||
setup();
|
||||
runSchemaSharingTests();
|
||||
}
|
@ -2,8 +2,10 @@ module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx|js|jsx)?$': 'ts-jest',
|
||||
'^.+\\.svg$': 'jest-svg-transformer',
|
||||
},
|
||||
testRegex: '(/__tests__/.*)\\.(test|spec).[jt]sx?$',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
moduleDirectories: ['node_modules', 'src'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
898
console/package-lock.json
generated
898
console/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.14.5",
|
||||
"@graphql-codegen/core": "^1.17.8",
|
||||
"@graphql-codegen/typescript": "^1.17.10",
|
||||
"@reduxjs/toolkit": "^1.5.1",
|
||||
@ -117,6 +118,8 @@
|
||||
"@babel/preset-typescript": "7.13.0",
|
||||
"@babel/register": "7.9.0",
|
||||
"@babel/runtime": "7.9.6",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^11.2.7",
|
||||
"@types/clean-webpack-plugin": "0.1.3",
|
||||
"@types/concurrently": "5.1.0",
|
||||
"@types/dotenv": "8.2.0",
|
||||
@ -190,9 +193,11 @@
|
||||
"eslint-plugin-chai-friendly": "0.4.1",
|
||||
"eslint-plugin-cypress": "2.10.3",
|
||||
"eslint-plugin-import": "2.18.2",
|
||||
"eslint-plugin-jest-dom": "^3.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.2.3",
|
||||
"eslint-plugin-react": "7.19.0",
|
||||
"eslint-plugin-react-hooks": "^4.0.4",
|
||||
"eslint-plugin-testing-library": "^4.6.0",
|
||||
"express": "4.17.1",
|
||||
"extract-hoc": "0.0.5",
|
||||
"extract-text-webpack-plugin": "3.0.2",
|
||||
@ -204,6 +209,7 @@
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"ignore-loader": "0.1.2",
|
||||
"jest": "26.6.3",
|
||||
"jest-svg-transformer": "^1.0.0",
|
||||
"jquery": "3.5.1",
|
||||
"less-loader": "4.1.0",
|
||||
"lint-staged": "10.2.2",
|
||||
|
6
console/src/components/Common/Modal/Modal.scss
Normal file
6
console/src/components/Common/Modal/Modal.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.modal_footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
@ -3,18 +3,21 @@ import {
|
||||
Modal as BootstrapModal,
|
||||
Button as BootstrapModalButton,
|
||||
} from 'react-bootstrap';
|
||||
import styles from './Modal.scss';
|
||||
|
||||
export interface ModalProps {
|
||||
show?: boolean;
|
||||
title: React.ReactElement;
|
||||
children: React.ReactElement;
|
||||
onClose?(): void;
|
||||
onSubmit?(): void;
|
||||
onCancel?(): void;
|
||||
customClass?: string;
|
||||
submitText?: string;
|
||||
submitText?: React.ReactElement | string;
|
||||
leftActions?: React.ReactElement;
|
||||
submitTestId?: string;
|
||||
}
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
const Modal = ({
|
||||
show = true,
|
||||
title,
|
||||
onClose,
|
||||
@ -22,9 +25,10 @@ const Modal: React.FC<ModalProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitText = '',
|
||||
leftActions,
|
||||
submitTestId = '',
|
||||
children,
|
||||
}) => {
|
||||
}: ModalProps) => {
|
||||
const getHeader = () => {
|
||||
return (
|
||||
<BootstrapModal.Header closeButton>
|
||||
@ -52,16 +56,22 @@ const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
return (
|
||||
<BootstrapModal.Footer>
|
||||
<BootstrapModalButton onClick={triggerOnClose}>
|
||||
Cancel
|
||||
</BootstrapModalButton>
|
||||
<BootstrapModalButton
|
||||
onClick={onSubmit}
|
||||
bsStyle="primary"
|
||||
data-test={submitTestId}
|
||||
>
|
||||
{submitText || 'Submit'}
|
||||
</BootstrapModalButton>
|
||||
<div className={styles.modal_footer}>
|
||||
<div>{leftActions ?? null}</div>
|
||||
|
||||
<div>
|
||||
<BootstrapModalButton onClick={triggerOnClose}>
|
||||
Cancel
|
||||
</BootstrapModalButton>
|
||||
<BootstrapModalButton
|
||||
onClick={onSubmit}
|
||||
bsStyle="primary"
|
||||
data-test={submitTestId}
|
||||
>
|
||||
{submitText || 'Submit'}
|
||||
</BootstrapModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</BootstrapModal.Footer>
|
||||
);
|
||||
};
|
||||
@ -85,4 +95,6 @@ const Modal: React.FC<ModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
Modal.Button = BootstrapModalButton;
|
||||
|
||||
export default Modal;
|
||||
|
@ -5,10 +5,12 @@ import addExistingTableReducer from './Add/AddExistingTableViewActions';
|
||||
import rawSQLReducer from './RawSQL/Actions';
|
||||
import { dataSidebarReducer } from './DataSubSidebar';
|
||||
import customFunctionReducer from './Function/customFunctionReducer';
|
||||
import { schemaSharingReducer } from './Schema/SchemaSharing/Actions';
|
||||
|
||||
const dataReducer = {
|
||||
tables: tableReducer,
|
||||
functions: customFunctionReducer,
|
||||
schemaSharing: schemaSharingReducer,
|
||||
addTable: combineReducers({
|
||||
table: addTableReducer,
|
||||
existingTableView: addExistingTableReducer,
|
||||
|
@ -33,6 +33,7 @@ import ConnectDatabase from './DataSources/ConnectDatabase';
|
||||
import { setDriver } from '../../../dataSources';
|
||||
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
|
||||
import { getSourcesFromMetadata } from '../../../metadata/selector';
|
||||
import SchemaGallery from './Schema/SchemaSharing/SchemaGallery';
|
||||
|
||||
const makeDataRouter = (
|
||||
connect,
|
||||
@ -57,7 +58,9 @@ const makeDataRouter = (
|
||||
<Route path="manage/create" component={ConnectedCreateDataSourcePage} />
|
||||
<Route path="schema/manage/connect" component={ConnectDatabase} />
|
||||
<Route path="manage/edit/:databaseName" component={ConnectDatabase} />
|
||||
<Route path=":source/gallery" component={SchemaGallery} />
|
||||
<Route path=":source" component={ConnectedDataSourceContainer}>
|
||||
<Route path="display" component={ConnectedDataSourceContainer} />
|
||||
<Route path="schema">
|
||||
<Route path=":schema" component={schemaConnector(connect)} />
|
||||
<Route path=":schema/tables" component={schemaConnector(connect)} />
|
||||
|
@ -0,0 +1,414 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { Driver } from '../../../../../dataSources';
|
||||
import { ReduxState } from '../../../../../types';
|
||||
import requestAction from '../../../../../utils/requestAction';
|
||||
import { AsyncThunkConfig } from '../../../../../store';
|
||||
import { makeMigrationCall } from '../../DataActions';
|
||||
import { getRunSqlQuery } from '../../../../Common/utils/v1QueryUtils';
|
||||
import Endpoints from '../../../../../Endpoints';
|
||||
import {
|
||||
exportMetadataQuery,
|
||||
generateReplaceMetadataQuery,
|
||||
} from '../../../../../metadata/queryUtils';
|
||||
import { HasuraMetadataV3 } from '../../../../../metadata/types';
|
||||
import { SchemaSharingFetchingStatus } from './types';
|
||||
|
||||
type SchemaSharingTemplateDetailFull = {
|
||||
sql: string;
|
||||
longDescription?: string;
|
||||
imageUrl?: string;
|
||||
publicUrl: string;
|
||||
blogPostLink?: string;
|
||||
metadataObject?: {
|
||||
resource_version: number;
|
||||
metadata: HasuraMetadataV3;
|
||||
};
|
||||
affectedMetadata?: string[]; // TODO defines the possible values
|
||||
};
|
||||
|
||||
export type SchemaSharingTemplateItem = {
|
||||
key: string;
|
||||
type: 'database';
|
||||
title: string;
|
||||
description: string;
|
||||
relativeFolderPath: string;
|
||||
dialect: Driver;
|
||||
fetchingStatus: SchemaSharingFetchingStatus;
|
||||
isPartialData: boolean;
|
||||
details?: SchemaSharingTemplateDetailFull;
|
||||
};
|
||||
|
||||
interface SchemaSharingSection {
|
||||
name: string;
|
||||
templates: SchemaSharingTemplateItem[];
|
||||
}
|
||||
|
||||
export interface SchemaSharingStore {
|
||||
globalConfigState: SchemaSharingFetchingStatus;
|
||||
schemas?: {
|
||||
sections: SchemaSharingSection[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerJsonRootConfig {
|
||||
[key: string]: {
|
||||
type: 'database';
|
||||
dialect: Driver;
|
||||
title: string;
|
||||
description: string;
|
||||
relativeFolderPath: string;
|
||||
category: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerJsonSchemaDefinition {
|
||||
longDescription?: string;
|
||||
imageUrl?: string;
|
||||
blogPostLink?: string;
|
||||
sqlFiles: string[];
|
||||
metadataUrl?: string;
|
||||
affectedMetadata?: string[]; // TODO defines the possible values
|
||||
}
|
||||
|
||||
const mapRootJsonFromServerToState = (
|
||||
data: ServerJsonRootConfig
|
||||
): Required<SchemaSharingStore['schemas']> => {
|
||||
const sectionsGroups: Record<
|
||||
string,
|
||||
SchemaSharingTemplateItem[]
|
||||
> = Object.entries(data)
|
||||
.map(([key, value]) => ({
|
||||
...value,
|
||||
key,
|
||||
}))
|
||||
.reduce<Record<string, SchemaSharingTemplateItem[]>>(
|
||||
(previousValue, currentValue) => {
|
||||
const item: SchemaSharingTemplateItem = {
|
||||
type: 'database',
|
||||
isPartialData: true,
|
||||
fetchingStatus: 'none',
|
||||
key: currentValue.key,
|
||||
description: currentValue.description,
|
||||
dialect: currentValue.dialect,
|
||||
title: currentValue.title,
|
||||
relativeFolderPath: currentValue.relativeFolderPath,
|
||||
};
|
||||
return {
|
||||
...previousValue,
|
||||
[currentValue.category]: [
|
||||
...(previousValue[currentValue.category] ?? []),
|
||||
item,
|
||||
],
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const sections: SchemaSharingSection[] = Object.entries(sectionsGroups).map(
|
||||
([name, templates]) => ({
|
||||
name,
|
||||
templates,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sections,
|
||||
};
|
||||
};
|
||||
|
||||
const initialStoreState: SchemaSharingStore = {
|
||||
globalConfigState: 'none',
|
||||
schemas: undefined,
|
||||
};
|
||||
|
||||
export const schemaSharingSelectors = {
|
||||
getGlobalConfigState: (state: ReduxState) =>
|
||||
state.schemaSharing.globalConfigState,
|
||||
getTemplateBySectionAndKey: ({
|
||||
key,
|
||||
section,
|
||||
}: {
|
||||
key: string;
|
||||
section: string;
|
||||
}) => (state: ReduxState) => {
|
||||
const maybeSection = state.schemaSharing.schemas?.sections?.find(
|
||||
block => block.name === section
|
||||
);
|
||||
if (!maybeSection) {
|
||||
return undefined;
|
||||
}
|
||||
const maybeTemplate = maybeSection.templates.find(
|
||||
template => template.key === key
|
||||
);
|
||||
if (!maybeTemplate) {
|
||||
return undefined;
|
||||
}
|
||||
return maybeTemplate;
|
||||
},
|
||||
getSchemasForDb: (driver: Driver) => (state: ReduxState) =>
|
||||
state.schemaSharing.schemas?.sections
|
||||
.map(section => ({
|
||||
...section,
|
||||
templates: section.templates.filter(
|
||||
template => template.dialect === driver
|
||||
),
|
||||
}))
|
||||
.filter(section => section.templates.length > 0),
|
||||
};
|
||||
|
||||
const repo_owner = 'hasura';
|
||||
const repo_name = 'schema-sharing';
|
||||
const repo_branch = 'main';
|
||||
|
||||
export const BASE_URL_TEMPLATE = `https://raw.githubusercontent.com/${repo_owner}/${repo_name}/${repo_branch}`;
|
||||
export const BASE_URL_PUBLIC = `https://github.com/${repo_owner}/${repo_name}/blob/${repo_branch}`;
|
||||
export const ROOT_CONFIG_PATH = `${BASE_URL_TEMPLATE}/config.json`;
|
||||
|
||||
export const fetchGlobalSchemaSharingConfiguration = createAsyncThunk<
|
||||
ServerJsonRootConfig,
|
||||
undefined,
|
||||
AsyncThunkConfig
|
||||
>('SchemaSharing/GET_REPOSITORY_ROOT_CONFIG', async (params, { dispatch }) => {
|
||||
const rawData = await dispatch(requestAction(ROOT_CONFIG_PATH));
|
||||
return JSON.parse(rawData);
|
||||
});
|
||||
|
||||
export const fetchSchemaConfigurationByName = createAsyncThunk<
|
||||
SchemaSharingTemplateDetailFull,
|
||||
{ key: string; category: string },
|
||||
AsyncThunkConfig
|
||||
>(
|
||||
'SchemaSharing/FETCH_ITEM_CONFIG',
|
||||
async ({ key, category }, { dispatch, getState }) => {
|
||||
const maybeTemplate = schemaSharingSelectors.getTemplateBySectionAndKey({
|
||||
key,
|
||||
section: category,
|
||||
})(getState());
|
||||
if (!maybeTemplate) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const baseTemplatePath = `${BASE_URL_TEMPLATE}/${maybeTemplate.relativeFolderPath}`;
|
||||
const publicUrl = `${BASE_URL_PUBLIC}/${maybeTemplate.relativeFolderPath}`;
|
||||
|
||||
const itemConfigRaw = await dispatch(
|
||||
requestAction(`${baseTemplatePath}/config.json`)
|
||||
);
|
||||
const itemConfig: ServerJsonSchemaDefinition = JSON.parse(itemConfigRaw);
|
||||
|
||||
const sqlFiles = await Promise.all(
|
||||
itemConfig.sqlFiles.map(sqlFile =>
|
||||
dispatch(requestAction<string>(`${baseTemplatePath}/${sqlFile}`))
|
||||
)
|
||||
);
|
||||
|
||||
const metadataObject = JSON.parse(
|
||||
await dispatch(
|
||||
requestAction<string>(`${baseTemplatePath}/${itemConfig.metadataUrl}`)
|
||||
)
|
||||
);
|
||||
|
||||
const fullObject: SchemaSharingTemplateDetailFull = {
|
||||
sql: sqlFiles.join('\n'),
|
||||
affectedMetadata: itemConfig.affectedMetadata,
|
||||
blogPostLink: itemConfig.blogPostLink,
|
||||
imageUrl: `${baseTemplatePath}/${itemConfig.imageUrl}`,
|
||||
longDescription: itemConfig.longDescription,
|
||||
metadataObject,
|
||||
publicUrl,
|
||||
};
|
||||
|
||||
return fullObject;
|
||||
}
|
||||
);
|
||||
|
||||
export const applyTemplate = createAsyncThunk<
|
||||
void,
|
||||
{ key: string; category: string },
|
||||
AsyncThunkConfig
|
||||
>(
|
||||
'SchemaSharing/applyTemplate',
|
||||
async ({ key, category }, { getState, dispatch }) => {
|
||||
let template = schemaSharingSelectors.getTemplateBySectionAndKey({
|
||||
key,
|
||||
section: category,
|
||||
})(getState());
|
||||
if (template?.isPartialData) {
|
||||
await dispatch(fetchSchemaConfigurationByName({ key, category }));
|
||||
template = schemaSharingSelectors.getTemplateBySectionAndKey({
|
||||
key,
|
||||
section: category,
|
||||
})(getState());
|
||||
}
|
||||
if (!template || !template.details) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
const source = getState().tables.currentDataSource;
|
||||
|
||||
const sql = template.details.sql;
|
||||
|
||||
await dispatch(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
makeMigrationCall(
|
||||
dispatch,
|
||||
getState,
|
||||
[getRunSqlQuery(sql ?? '', source)],
|
||||
[],
|
||||
`apply_sql_template_${key}`,
|
||||
resolve,
|
||||
reject,
|
||||
'Applying sql from template',
|
||||
'SQL migration successfully applied',
|
||||
'An error occurred while applying the template'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const { dataHeaders } = getState().tables;
|
||||
|
||||
if (template.details.metadataObject !== undefined) {
|
||||
const oldMetadata = await dispatch(
|
||||
requestAction<{
|
||||
resource_version: number;
|
||||
metadata: HasuraMetadataV3;
|
||||
}>(Endpoints.metadata, {
|
||||
method: 'POST',
|
||||
headers: dataHeaders,
|
||||
body: JSON.stringify(exportMetadataQuery),
|
||||
})
|
||||
);
|
||||
|
||||
const newMetadata: HasuraMetadataV3 = {
|
||||
...oldMetadata.metadata,
|
||||
sources: oldMetadata.metadata.sources.map(oldSource => {
|
||||
if (oldSource.name !== source) {
|
||||
return oldSource;
|
||||
}
|
||||
const metadataObject =
|
||||
template?.details?.metadataObject?.metadata?.sources?.[0];
|
||||
if (!metadataObject) {
|
||||
return oldSource;
|
||||
}
|
||||
return {
|
||||
...oldSource,
|
||||
tables: [...oldSource.tables, ...(metadataObject.tables ?? [])],
|
||||
functions: [
|
||||
...(oldSource.functions ?? []),
|
||||
...(metadataObject.functions ?? []),
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await dispatch(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
makeMigrationCall(
|
||||
dispatch,
|
||||
getState,
|
||||
[
|
||||
generateReplaceMetadataQuery({
|
||||
metadata: newMetadata,
|
||||
resource_version: 0,
|
||||
}),
|
||||
],
|
||||
[
|
||||
generateReplaceMetadataQuery({
|
||||
metadata: newMetadata,
|
||||
resource_version: 0,
|
||||
}),
|
||||
],
|
||||
`apply_metadata_template_${key}`,
|
||||
resolve,
|
||||
reject,
|
||||
'Applying metadata from template',
|
||||
`Template ${template?.title} applied`,
|
||||
'An error occurred while applying the template'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const schemaSharingSlice = createSlice({
|
||||
name: 'schemaSharing',
|
||||
initialState: initialStoreState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchGlobalSchemaSharingConfiguration.pending, state => {
|
||||
state.globalConfigState = 'fetching';
|
||||
})
|
||||
.addCase(fetchGlobalSchemaSharingConfiguration.rejected, state => {
|
||||
state.globalConfigState = 'failure';
|
||||
})
|
||||
.addCase(
|
||||
fetchGlobalSchemaSharingConfiguration.fulfilled,
|
||||
(state, { payload }) => {
|
||||
state.globalConfigState = 'success';
|
||||
state.schemas = mapRootJsonFromServerToState(payload);
|
||||
}
|
||||
)
|
||||
.addCase(
|
||||
fetchSchemaConfigurationByName.pending,
|
||||
(
|
||||
state,
|
||||
{
|
||||
meta: {
|
||||
arg: { category, key },
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const maybeTemplate = state.schemas?.sections
|
||||
?.find?.(section => section.name === category)
|
||||
?.templates?.find(template => template.key === key);
|
||||
if (maybeTemplate) {
|
||||
maybeTemplate.fetchingStatus = 'fetching';
|
||||
}
|
||||
}
|
||||
)
|
||||
.addCase(
|
||||
fetchSchemaConfigurationByName.fulfilled,
|
||||
(
|
||||
state,
|
||||
{
|
||||
meta: {
|
||||
arg: { category, key },
|
||||
},
|
||||
payload,
|
||||
}
|
||||
) => {
|
||||
const maybeTemplate = state.schemas?.sections
|
||||
?.find?.(section => section.name === category)
|
||||
?.templates?.find(template => template.key === key);
|
||||
if (maybeTemplate) {
|
||||
maybeTemplate.fetchingStatus = 'success';
|
||||
maybeTemplate.isPartialData = false;
|
||||
maybeTemplate.details = payload;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addCase(
|
||||
fetchSchemaConfigurationByName.rejected,
|
||||
(
|
||||
state,
|
||||
{
|
||||
meta: {
|
||||
arg: { category, key },
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const maybeTemplate = state.schemas?.sections
|
||||
?.find?.(section => section.name === category)
|
||||
?.templates?.find(template => template.key === key);
|
||||
if (maybeTemplate) {
|
||||
maybeTemplate.fetchingStatus = 'failure';
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { reducer } = schemaSharingSlice;
|
||||
|
||||
export const schemaSharingReducer = reducer;
|
@ -0,0 +1,124 @@
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.image_in_detail {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.row_content:hover {
|
||||
background-color: #ebf7de;
|
||||
}
|
||||
|
||||
.pr_md {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.mr_md {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.gallery_heading_text {
|
||||
font-weight: 600 !important;
|
||||
vertical-align: middle;
|
||||
> th {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery_section_block {
|
||||
font-weight: 600 !important;
|
||||
vertical-align: middle;
|
||||
> th {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.template_table {
|
||||
tr + tr {
|
||||
border-top: 1px solid #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.schema_background {
|
||||
background: #f2f5f9;
|
||||
}
|
||||
|
||||
.description_whitespace {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.ace_custom_style {
|
||||
border: 1px solid #dddddd;
|
||||
margin: .5em 0;
|
||||
background-color: #f8fafb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge_metadata {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
font-weight: 600;
|
||||
border-radius: 16px;
|
||||
color: #6B7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.modal_override {
|
||||
width: 720px;
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sm_button {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 5px 0 5px;
|
||||
}
|
||||
|
||||
.icon_padding {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.on_hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schema_gallery_button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
tr:hover .schema_gallery_button{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal_description {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb_xs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal_body {
|
||||
max-height: 510px;
|
||||
overflow-y: scroll;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import Tabbed from './../../TabbedSchema';
|
||||
import { useAppSelector } from '../../../../../store';
|
||||
import { Driver } from '../../../../../dataSources';
|
||||
|
||||
import styles from './SchemaGallery.scss';
|
||||
import { SchemaGalleryBody } from './SchemaGalleryTable';
|
||||
import { SchemaGalleryModal } from './SchemaGalleryModal';
|
||||
import { ModalType } from './types';
|
||||
|
||||
const SchemaGallery: React.VFC = () => {
|
||||
const currentDataSource = useAppSelector<Driver>(
|
||||
state => state.tables.currentDataSource
|
||||
);
|
||||
|
||||
const [modalState, setShowModal] = React.useState<ModalType | undefined>(
|
||||
undefined
|
||||
);
|
||||
const closeModal = () => setShowModal(undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabbed tabName="gallery" currentDataSource={currentDataSource}>
|
||||
<div className={styles.pr_md}>
|
||||
<SchemaGalleryBody onModalOpen={setShowModal} />
|
||||
</div>
|
||||
</Tabbed>
|
||||
{modalState !== undefined ? (
|
||||
<SchemaGalleryModal closeModal={closeModal} content={modalState} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SchemaGallery;
|
@ -0,0 +1,193 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../../../store';
|
||||
import {
|
||||
applyTemplate,
|
||||
fetchSchemaConfigurationByName,
|
||||
schemaSharingSelectors,
|
||||
} from './Actions';
|
||||
import Modal from '../../../../Common/Modal/Modal';
|
||||
import styles from './SchemaGallery.scss';
|
||||
import { ModalType } from './types';
|
||||
import AceEditor from '../../../../Common/AceEditor/BaseEditor';
|
||||
import { showErrorNotification } from '../../../Common/Notification';
|
||||
|
||||
export const SchemaGalleryModalBody: React.VFC<{
|
||||
content: ModalType;
|
||||
}> = ({ content }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const currentTemplate = useAppSelector(
|
||||
schemaSharingSelectors.getTemplateBySectionAndKey({
|
||||
section: content.section,
|
||||
key: content.key,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTemplate && currentTemplate.fetchingStatus === 'none') {
|
||||
dispatch(
|
||||
fetchSchemaConfigurationByName({
|
||||
key: content.key,
|
||||
category: content.section,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [content, currentTemplate, dispatch]);
|
||||
|
||||
if (!currentTemplate) {
|
||||
return <div>Something went wrong, please try again later.</div>;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTemplate.fetchingStatus === 'fetching' ||
|
||||
currentTemplate.fetchingStatus === 'none'
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<span className={`fa fa-spinner fa-spin ${styles.mr_md}`} />
|
||||
Loading schema
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const details = currentTemplate.details;
|
||||
if (currentTemplate.fetchingStatus === 'failure' || !details) {
|
||||
return <div>Something went wrong, please try again later.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modal_body}>
|
||||
{details.longDescription ? (
|
||||
<p className={styles.description_whitespace}>
|
||||
{details.longDescription}
|
||||
</p>
|
||||
) : null}
|
||||
{details.blogPostLink ? (
|
||||
<p>
|
||||
Read the blog post{' '}
|
||||
<a
|
||||
href={details.blogPostLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{details.imageUrl ? (
|
||||
<>
|
||||
<hr className={styles.modal_description} />
|
||||
<p>
|
||||
<img
|
||||
className={styles.image_in_detail}
|
||||
src={details.imageUrl}
|
||||
alt=""
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<hr className={styles.modal_description} />
|
||||
<p className={`${styles.mb_xs} ${styles.strong} ${styles.muted}`}>SQL:</p>
|
||||
<AceEditor
|
||||
readOnly
|
||||
value={details.sql}
|
||||
className={styles.ace_custom_style}
|
||||
mode="sql"
|
||||
width="100%"
|
||||
showGutter={false}
|
||||
showPrintMargin={false}
|
||||
setOptions={{ showLineNumbers: false }}
|
||||
maxLines={150}
|
||||
/>
|
||||
{details.affectedMetadata ? (
|
||||
<>
|
||||
<p className={`${styles.mb_xs} ${styles.strong} ${styles.muted}`}>
|
||||
Metadata:
|
||||
</p>
|
||||
<p>
|
||||
{details.affectedMetadata.map(affected => (
|
||||
<span key={affected} className={styles.badge_metadata}>
|
||||
{affected}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SchemaGalleryModal: React.VFC<{
|
||||
content: ModalType;
|
||||
closeModal: () => void;
|
||||
}> = ({ content, closeModal }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const currentTemplate = useAppSelector(
|
||||
schemaSharingSelectors.getTemplateBySectionAndKey({
|
||||
section: content.section,
|
||||
key: content.key,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTemplate === undefined) {
|
||||
closeModal();
|
||||
}
|
||||
}, [closeModal, currentTemplate]);
|
||||
|
||||
if (currentTemplate === undefined) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
dispatch(applyTemplate({ key: content.key, category: content.section }))
|
||||
.then(() => {
|
||||
closeModal();
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(
|
||||
showErrorNotification(
|
||||
'An error occurred while applying this template'
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shouldDisplaySubmit = currentTemplate.fetchingStatus === 'success';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
title={<p>{currentTemplate.title}</p>}
|
||||
onClose={closeModal}
|
||||
onCancel={closeModal}
|
||||
onSubmit={shouldDisplaySubmit ? onSubmit : undefined}
|
||||
customClass={styles.modal_override}
|
||||
submitText={
|
||||
shouldDisplaySubmit ? (
|
||||
<>
|
||||
<span
|
||||
className={`fa fa-upload ${styles.icon_padding}`}
|
||||
aria-hidden
|
||||
/>
|
||||
Install Schema
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
leftActions={
|
||||
shouldDisplaySubmit ? (
|
||||
<Modal.Button
|
||||
onClick={() =>
|
||||
window.open(currentTemplate?.details?.publicUrl, '_blank')
|
||||
}
|
||||
>
|
||||
<i className="fa fa-github" style={{ marginRight: '6px' }} />
|
||||
View on GitHub
|
||||
</Modal.Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SchemaGalleryModalBody content={content} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,93 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../../../../store';
|
||||
import {
|
||||
fetchGlobalSchemaSharingConfiguration,
|
||||
schemaSharingSelectors,
|
||||
SchemaSharingTemplateItem,
|
||||
} from './Actions';
|
||||
import { currentDriver } from '../../../../../dataSources';
|
||||
import styles from './SchemaGallery.scss';
|
||||
import { modalOpenFn } from './types';
|
||||
|
||||
export const SchemaGalleryContentRow: React.VFC<{
|
||||
template: SchemaSharingTemplateItem;
|
||||
openModal: () => void;
|
||||
}> = ({ template, openModal }) => {
|
||||
return (
|
||||
<tr key={template.key} className={styles.row_content}>
|
||||
<td className={styles.td}>
|
||||
<a onClick={openModal} className={styles.on_hover}>
|
||||
{template.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.td}>{template.description}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export const SchemaGalleryBody: React.VFC<{ onModalOpen: modalOpenFn }> = ({
|
||||
onModalOpen,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const globalStatusFetching = useAppSelector(
|
||||
schemaSharingSelectors.getGlobalConfigState
|
||||
);
|
||||
const templateForDb = useAppSelector(
|
||||
schemaSharingSelectors.getSchemasForDb(currentDriver)
|
||||
);
|
||||
|
||||
if (globalStatusFetching === 'none') {
|
||||
dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
}
|
||||
|
||||
if (globalStatusFetching === 'fetching' || globalStatusFetching === 'none') {
|
||||
return (
|
||||
<div>
|
||||
<span className={`fa fa-spinner fa-spin ${styles.mr_md}`} />
|
||||
Loading schemas
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (globalStatusFetching === 'failure') {
|
||||
return <div>Something went wrong, please try again later.</div>;
|
||||
}
|
||||
|
||||
if (!templateForDb || templateForDb.length === 0) {
|
||||
return <div>No templates</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr className={styles.gallery_heading_text}>
|
||||
<th>Template Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={styles.template_table}>
|
||||
{templateForDb.map(section => (
|
||||
<Fragment key={section.name}>
|
||||
<tr key={section.name}>
|
||||
<td colSpan={3} className={styles.td}>
|
||||
<span className={`${styles.strong} ${styles.muted}`}>
|
||||
{section.name}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{section.templates.map(template => (
|
||||
<SchemaGalleryContentRow
|
||||
key={template.key}
|
||||
template={template}
|
||||
openModal={() =>
|
||||
onModalOpen({ key: template.key, section: section.name })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
@ -0,0 +1,130 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import {
|
||||
applyTemplate,
|
||||
fetchGlobalSchemaSharingConfiguration,
|
||||
fetchSchemaConfigurationByName,
|
||||
schemaSharingReducer,
|
||||
SchemaSharingStore,
|
||||
} from '../Actions';
|
||||
import { CATEGORY_1, networkStubs } from './stubs/schemaSharingNetworkStubs';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('SchemaSharing state', () => {
|
||||
describe('global config get flow', () => {
|
||||
it('should dispatch the root config success when the api works', async () => {
|
||||
server.use(networkStubs.rootJson);
|
||||
|
||||
// any to not add the whole store
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
|
||||
await store.dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
expect(store.getState().schemaSharing).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should dispatch root config failure when the api don't work", async () => {
|
||||
server.use(networkStubs.rootJsonError);
|
||||
const expected: SchemaSharingStore = {
|
||||
globalConfigState: 'failure',
|
||||
schemas: undefined,
|
||||
};
|
||||
// any to not add the whole store
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
|
||||
await store.dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
|
||||
expect(store.getState()).toEqual({
|
||||
schemaSharing: expected,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('fetch single config file', () => {
|
||||
it('should populate with all the data from the gihtub api', async () => {
|
||||
// Prepare
|
||||
server.use(
|
||||
networkStubs.rootJson,
|
||||
networkStubs.template1.config,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSql,
|
||||
networkStubs.template1.metadata
|
||||
);
|
||||
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
await store.dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
// Act
|
||||
await store.dispatch(
|
||||
fetchSchemaConfigurationByName({
|
||||
key: 'template-1',
|
||||
category: CATEGORY_1,
|
||||
})
|
||||
);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(store.getState().schemaSharing).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('apply schema', () => {
|
||||
it('should apply migration', async () => {
|
||||
// Prepare
|
||||
server.use(
|
||||
networkStubs.rootJson,
|
||||
networkStubs.template1.config,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSql,
|
||||
networkStubs.template1.metadata
|
||||
);
|
||||
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
await store.dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
await store.dispatch(
|
||||
fetchSchemaConfigurationByName({
|
||||
key: 'template-1',
|
||||
category: CATEGORY_1,
|
||||
})
|
||||
);
|
||||
|
||||
// Act
|
||||
await store.dispatch(
|
||||
applyTemplate({
|
||||
key: 'template-1',
|
||||
category: CATEGORY_1,
|
||||
})
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(store.getState().schemaSharing).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('reducer', () => {
|
||||
it('should have the correct default state', () => {
|
||||
const result = schemaSharingReducer(undefined, { type: '' });
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
globalConfigState: 'none',
|
||||
schemas: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,70 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
import React from 'react';
|
||||
import SchemaGallery from '../SchemaGallery';
|
||||
import { schemaSharingReducer } from '../Actions';
|
||||
import { networkStubs } from './stubs/schemaSharingNetworkStubs';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const renderSchemaGallery = () => {
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
tables: () => ({ currentDataSource: 'postgres' }),
|
||||
},
|
||||
});
|
||||
render(
|
||||
<Provider store={store} key="provider">
|
||||
<SchemaGallery />
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
describe('SchemaGallery', () => {
|
||||
it('should the list of templates', async () => {
|
||||
server.use(
|
||||
networkStubs.rootJson,
|
||||
networkStubs.template1.metadata,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSql,
|
||||
networkStubs.template1.config
|
||||
);
|
||||
renderSchemaGallery();
|
||||
|
||||
expect(screen.getByText(/loading schemas/i)).toBeVisible();
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schemas/i));
|
||||
|
||||
screen.getByText(/template-1/i).click();
|
||||
|
||||
expect(screen.getByText(/loading schema/i)).toBeVisible();
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schema/i));
|
||||
|
||||
expect(screen.getByText(/long long description/i)).toBeVisible();
|
||||
|
||||
act(() => {
|
||||
screen
|
||||
.getByRole('button', {
|
||||
name: /install schema/i,
|
||||
})
|
||||
.click();
|
||||
});
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.getByText(/long long description/i)
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/long long description/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
import { RequestHandler } from 'msw/lib/types/handlers/RequestHandler';
|
||||
import {
|
||||
schemaSharingReducer,
|
||||
fetchGlobalSchemaSharingConfiguration,
|
||||
} from '../Actions';
|
||||
import { CATEGORY_1, networkStubs } from './stubs/schemaSharingNetworkStubs';
|
||||
import { SchemaGalleryModalBody } from '../SchemaGalleryModal';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const schemaGalleryModalBodyRender = async (...handlers: RequestHandler[]) => {
|
||||
server.use(networkStubs.rootJson, ...handlers);
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
|
||||
await store.dispatch(fetchGlobalSchemaSharingConfiguration());
|
||||
|
||||
render(
|
||||
<Provider store={store} key="provider">
|
||||
<SchemaGalleryModalBody
|
||||
content={{ key: 'template-1', section: CATEGORY_1 }}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SchemaGalleryModalBody', () => {
|
||||
it('should display loading at first', async () => {
|
||||
await schemaGalleryModalBodyRender(
|
||||
networkStubs.template1.configSlow,
|
||||
networkStubs.template1.metadata,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSql
|
||||
);
|
||||
|
||||
expect(screen.getByText(/loading schema/i)).toBeVisible();
|
||||
});
|
||||
it('should display an error when a request is failing', async () => {
|
||||
await schemaGalleryModalBodyRender(
|
||||
networkStubs.template1.config,
|
||||
networkStubs.template1.metadata,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSqlError
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schema/i));
|
||||
|
||||
expect(
|
||||
screen.getByText(/something went wrong, please try again later\./i)
|
||||
).toBeVisible();
|
||||
});
|
||||
it('should display the content from the config', async () => {
|
||||
await schemaGalleryModalBodyRender(
|
||||
networkStubs.template1.config,
|
||||
networkStubs.template1.metadata,
|
||||
networkStubs.template1.firstSql,
|
||||
networkStubs.template1.secondSql
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schema/i));
|
||||
|
||||
expect(screen.getByText(/long long description/i)).toBeVisible();
|
||||
|
||||
expect(screen.getByText(/read the blog post \./i)).toBeVisible();
|
||||
});
|
||||
});
|
@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
import { schemaSharingReducer, SchemaSharingTemplateItem } from '../Actions';
|
||||
import {
|
||||
SchemaGalleryBody,
|
||||
SchemaGalleryContentRow,
|
||||
} from '../SchemaGalleryTable';
|
||||
import { networkStubs } from './stubs/schemaSharingNetworkStubs';
|
||||
|
||||
const templateItem: SchemaSharingTemplateItem = {
|
||||
key: 'template-1',
|
||||
description: 'Some description of the schema',
|
||||
fetchingStatus: 'success',
|
||||
title: 'Title of the schema',
|
||||
isPartialData: true,
|
||||
dialect: 'postgres',
|
||||
type: 'database',
|
||||
relativeFolderPath: './test',
|
||||
};
|
||||
|
||||
describe('SchemaGalleryContentRow', () => {
|
||||
it('should display the content', () => {
|
||||
const openModal = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<SchemaGalleryContentRow
|
||||
template={templateItem}
|
||||
openModal={openModal}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('cell', {
|
||||
name: /some description of the schema/i,
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
expect(screen.getByText(/title of the schema/i)).toBeVisible();
|
||||
screen.getByText(/title of the schema/i).click();
|
||||
expect(openModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const renderSchemaGalleryBody = () => {
|
||||
const openModalFn = jest.fn();
|
||||
|
||||
const store = configureStore<any>({
|
||||
reducer: {
|
||||
schemaSharing: schemaSharingReducer,
|
||||
},
|
||||
});
|
||||
render(
|
||||
<Provider store={store} key="provider">
|
||||
<SchemaGalleryBody onModalOpen={openModalFn} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
return {
|
||||
openModalFn,
|
||||
};
|
||||
};
|
||||
|
||||
describe('SchemaGalleryBody', () => {
|
||||
it('should display loading at first', async () => {
|
||||
server.use(networkStubs.rootJsonWithLoading);
|
||||
|
||||
renderSchemaGalleryBody();
|
||||
|
||||
expect(screen.getByText(/loading schemas/i)).toBeVisible();
|
||||
});
|
||||
it('should display an error when the api return something bad', async () => {
|
||||
server.use(networkStubs.rootJsonError);
|
||||
|
||||
renderSchemaGalleryBody();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schemas/i));
|
||||
|
||||
expect(
|
||||
screen.getByText(/something went wrong, please try again later\./i)
|
||||
).toBeVisible();
|
||||
});
|
||||
it('should display an error when the api return something bad', async () => {
|
||||
server.use(networkStubs.rootJsonEmpty);
|
||||
|
||||
renderSchemaGalleryBody();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schemas/i));
|
||||
|
||||
expect(screen.getByText(/no template/i)).toBeVisible();
|
||||
});
|
||||
it('should display the list from the api', async () => {
|
||||
server.use(networkStubs.rootJson);
|
||||
|
||||
const { openModalFn } = renderSchemaGalleryBody();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading schemas/i));
|
||||
|
||||
expect(screen.getByText(/aaaaaaaa/i)).toBeVisible();
|
||||
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[1]).getByText(/template-1/i)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[2]).getByText(
|
||||
/this is the description of template one/i
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[3]).getByText(/bbbbbbbb/i)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[4]).getByText(/template-2/i)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(screen.getAllByRole('cell')[5]).getByText(
|
||||
/this is the description of template two/i
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
screen.getByText(/template-1/i).click();
|
||||
|
||||
expect(openModalFn).toHaveBeenCalledWith({
|
||||
key: 'template-1',
|
||||
section: 'AAAAAAAA',
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,341 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SchemaSharing state apply schema should apply migration 1`] = `
|
||||
Object {
|
||||
"globalConfigState": "success",
|
||||
"schemas": Object {
|
||||
"sections": Array [
|
||||
Object {
|
||||
"name": "AAAAAAAA",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template one",
|
||||
"details": Object {
|
||||
"affectedMetadata": Array [
|
||||
"some",
|
||||
"meta",
|
||||
],
|
||||
"blogPostLink": "https://hasura.io/blog/whats-new-in-hasura-cloud-may-2021/",
|
||||
"imageUrl": "https://raw.githubusercontent.com/hasura/schema-sharing/main/./postgres-template-1/someImage.url",
|
||||
"longDescription": "long long description",
|
||||
"metadataObject": Object {
|
||||
"metadata": Object {
|
||||
"sources": Array [
|
||||
Object {
|
||||
"configuration": Object {
|
||||
"connection_info": Object {
|
||||
"database_url": Object {
|
||||
"from_env": "HASURA_GRAPHQL_DATABASE_URL_OTHER",
|
||||
},
|
||||
"isolation_level": "read-committed",
|
||||
"pool_settings": Object {
|
||||
"connection_lifetime": 6000,
|
||||
"idle_timeout": 1800,
|
||||
"max_connections": 500,
|
||||
"retries": 10,
|
||||
},
|
||||
"use_prepared_statements": true,
|
||||
},
|
||||
},
|
||||
"kind": "postgres",
|
||||
"name": "default",
|
||||
"tables": Array [
|
||||
Object {
|
||||
"object_relationships": Array [
|
||||
Object {
|
||||
"name": "passport_info",
|
||||
"using": Object {
|
||||
"foreign_key_constraint_on": Object {
|
||||
"column": "owner_id",
|
||||
"table": Object {
|
||||
"name": "passport_info",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"table": Object {
|
||||
"name": "owner",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"object_relationships": Array [
|
||||
Object {
|
||||
"name": "owner",
|
||||
"using": Object {
|
||||
"foreign_key_constraint_on": "owner_id",
|
||||
},
|
||||
},
|
||||
],
|
||||
"table": Object {
|
||||
"name": "passport_info",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"version": 3,
|
||||
},
|
||||
"resource_version": 13,
|
||||
},
|
||||
"publicUrl": "https://github.com/hasura/schema-sharing/blob/main/./postgres-template-1",
|
||||
"sql": "
|
||||
CREATE SCHEMA _onetoone;
|
||||
|
||||
-- Create Tables
|
||||
CREATE TABLE _onetoone.owner (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE _onetoone.passport_info (
|
||||
id serial PRIMARY KEY,
|
||||
passport_number text NOT NULL UNIQUE,
|
||||
owner_id integer REFERENCES _onetoone.owner(id) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \\"_onetoone\\".\\"owner\\" (\\"name\\") VALUES
|
||||
('Coleman Spickett'),
|
||||
('Gallard Dreye'),;
|
||||
|
||||
INSERT INTO \\"_onetoone\\".\\"passport_info\\" (\\"passport_number\\", \\"owner_id\\") VALUES
|
||||
('553221', 1),
|
||||
('839016', 2),",
|
||||
},
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "success",
|
||||
"isPartialData": false,
|
||||
"key": "template-1",
|
||||
"relativeFolderPath": "./postgres-template-1",
|
||||
"title": "template-1",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "BBBBBBBB",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template two",
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-2",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-2",
|
||||
"type": "database",
|
||||
},
|
||||
Object {
|
||||
"description": "This is the description of template 3",
|
||||
"dialect": "mysql",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-3",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-3",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SchemaSharing state fetch single config file should populate with all the data from the gihtub api 1`] = `
|
||||
Object {
|
||||
"globalConfigState": "success",
|
||||
"schemas": Object {
|
||||
"sections": Array [
|
||||
Object {
|
||||
"name": "AAAAAAAA",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template one",
|
||||
"details": Object {
|
||||
"affectedMetadata": Array [
|
||||
"some",
|
||||
"meta",
|
||||
],
|
||||
"blogPostLink": "https://hasura.io/blog/whats-new-in-hasura-cloud-may-2021/",
|
||||
"imageUrl": "https://raw.githubusercontent.com/hasura/schema-sharing/main/./postgres-template-1/someImage.url",
|
||||
"longDescription": "long long description",
|
||||
"metadataObject": Object {
|
||||
"metadata": Object {
|
||||
"sources": Array [
|
||||
Object {
|
||||
"configuration": Object {
|
||||
"connection_info": Object {
|
||||
"database_url": Object {
|
||||
"from_env": "HASURA_GRAPHQL_DATABASE_URL_OTHER",
|
||||
},
|
||||
"isolation_level": "read-committed",
|
||||
"pool_settings": Object {
|
||||
"connection_lifetime": 6000,
|
||||
"idle_timeout": 1800,
|
||||
"max_connections": 500,
|
||||
"retries": 10,
|
||||
},
|
||||
"use_prepared_statements": true,
|
||||
},
|
||||
},
|
||||
"kind": "postgres",
|
||||
"name": "default",
|
||||
"tables": Array [
|
||||
Object {
|
||||
"object_relationships": Array [
|
||||
Object {
|
||||
"name": "passport_info",
|
||||
"using": Object {
|
||||
"foreign_key_constraint_on": Object {
|
||||
"column": "owner_id",
|
||||
"table": Object {
|
||||
"name": "passport_info",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"table": Object {
|
||||
"name": "owner",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"object_relationships": Array [
|
||||
Object {
|
||||
"name": "owner",
|
||||
"using": Object {
|
||||
"foreign_key_constraint_on": "owner_id",
|
||||
},
|
||||
},
|
||||
],
|
||||
"table": Object {
|
||||
"name": "passport_info",
|
||||
"schema": "_onetoone",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"version": 3,
|
||||
},
|
||||
"resource_version": 13,
|
||||
},
|
||||
"publicUrl": "https://github.com/hasura/schema-sharing/blob/main/./postgres-template-1",
|
||||
"sql": "
|
||||
CREATE SCHEMA _onetoone;
|
||||
|
||||
-- Create Tables
|
||||
CREATE TABLE _onetoone.owner (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE _onetoone.passport_info (
|
||||
id serial PRIMARY KEY,
|
||||
passport_number text NOT NULL UNIQUE,
|
||||
owner_id integer REFERENCES _onetoone.owner(id) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \\"_onetoone\\".\\"owner\\" (\\"name\\") VALUES
|
||||
('Coleman Spickett'),
|
||||
('Gallard Dreye'),;
|
||||
|
||||
INSERT INTO \\"_onetoone\\".\\"passport_info\\" (\\"passport_number\\", \\"owner_id\\") VALUES
|
||||
('553221', 1),
|
||||
('839016', 2),",
|
||||
},
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "success",
|
||||
"isPartialData": false,
|
||||
"key": "template-1",
|
||||
"relativeFolderPath": "./postgres-template-1",
|
||||
"title": "template-1",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "BBBBBBBB",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template two",
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-2",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-2",
|
||||
"type": "database",
|
||||
},
|
||||
Object {
|
||||
"description": "This is the description of template 3",
|
||||
"dialect": "mysql",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-3",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-3",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SchemaSharing state global config get flow should dispatch the root config success when the api works 1`] = `
|
||||
Object {
|
||||
"globalConfigState": "success",
|
||||
"schemas": Object {
|
||||
"sections": Array [
|
||||
Object {
|
||||
"name": "AAAAAAAA",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template one",
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-1",
|
||||
"relativeFolderPath": "./postgres-template-1",
|
||||
"title": "template-1",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"name": "BBBBBBBB",
|
||||
"templates": Array [
|
||||
Object {
|
||||
"description": "This is the description of template two",
|
||||
"dialect": "postgres",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-2",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-2",
|
||||
"type": "database",
|
||||
},
|
||||
Object {
|
||||
"description": "This is the description of template 3",
|
||||
"dialect": "mysql",
|
||||
"fetchingStatus": "none",
|
||||
"isPartialData": true,
|
||||
"key": "template-3",
|
||||
"relativeFolderPath": "/home",
|
||||
"title": "template-3",
|
||||
"type": "database",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
@ -0,0 +1,229 @@
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
BASE_URL_TEMPLATE,
|
||||
ROOT_CONFIG_PATH,
|
||||
ServerJsonRootConfig,
|
||||
} from '../../Actions';
|
||||
import { HasuraMetadataV3 } from '../../../../../../../metadata/types';
|
||||
|
||||
export const CATEGORY_1 = 'AAAAAAAA';
|
||||
export const CATEGORY_2 = 'BBBBBBBB';
|
||||
|
||||
export const MOCK_ROOT_CONFIG: ServerJsonRootConfig = {
|
||||
'template-1': {
|
||||
title: 'template-1',
|
||||
relativeFolderPath: './postgres-template-1',
|
||||
dialect: 'postgres',
|
||||
description: 'This is the description of template one',
|
||||
type: 'database',
|
||||
category: CATEGORY_1,
|
||||
},
|
||||
'template-2': {
|
||||
title: 'template-2',
|
||||
relativeFolderPath: '/home',
|
||||
dialect: 'postgres',
|
||||
description: 'This is the description of template two',
|
||||
type: 'database',
|
||||
category: CATEGORY_2,
|
||||
},
|
||||
'template-3': {
|
||||
title: 'template-3',
|
||||
relativeFolderPath: '/home',
|
||||
dialect: 'mysql',
|
||||
description: 'This is the description of template 3',
|
||||
type: 'database',
|
||||
category: CATEGORY_2,
|
||||
},
|
||||
};
|
||||
|
||||
export const CURRENT_MOCK_METAe: HasuraMetadataV3 = {
|
||||
version: 3,
|
||||
inherited_roles: [],
|
||||
actions: [],
|
||||
allowlist: [],
|
||||
cron_triggers: [],
|
||||
query_collections: [],
|
||||
remote_schemas: [],
|
||||
rest_endpoints: [],
|
||||
sources: [
|
||||
{
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
tables: [
|
||||
{
|
||||
table: {
|
||||
schema: '_onetoone',
|
||||
name: 'owner',
|
||||
},
|
||||
},
|
||||
],
|
||||
configuration: {
|
||||
connection_info: {
|
||||
use_prepared_statements: true,
|
||||
database_url: {
|
||||
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
|
||||
},
|
||||
isolation_level: 'read-committed',
|
||||
pool_settings: {
|
||||
connection_lifetime: 600,
|
||||
retries: 1,
|
||||
idle_timeout: 180,
|
||||
max_connections: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_ONE_TO_ONE_METADATA = {
|
||||
resource_version: 13,
|
||||
metadata: {
|
||||
version: 3,
|
||||
sources: [
|
||||
{
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
tables: [
|
||||
{
|
||||
table: {
|
||||
schema: '_onetoone',
|
||||
name: 'owner',
|
||||
},
|
||||
object_relationships: [
|
||||
{
|
||||
name: 'passport_info',
|
||||
using: {
|
||||
foreign_key_constraint_on: {
|
||||
column: 'owner_id',
|
||||
table: {
|
||||
schema: '_onetoone',
|
||||
name: 'passport_info',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
table: {
|
||||
schema: '_onetoone',
|
||||
name: 'passport_info',
|
||||
},
|
||||
object_relationships: [
|
||||
{
|
||||
name: 'owner',
|
||||
using: {
|
||||
foreign_key_constraint_on: 'owner_id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
configuration: {
|
||||
connection_info: {
|
||||
use_prepared_statements: true,
|
||||
database_url: {
|
||||
from_env: 'HASURA_GRAPHQL_DATABASE_URL_OTHER',
|
||||
},
|
||||
isolation_level: 'read-committed',
|
||||
pool_settings: {
|
||||
connection_lifetime: 6000,
|
||||
retries: 10,
|
||||
idle_timeout: 1800,
|
||||
max_connections: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const BASE_PS_URL = `${BASE_URL_TEMPLATE}/./postgres-template-1`;
|
||||
|
||||
export const networkStubs = {
|
||||
rootJson: rest.get(ROOT_CONFIG_PATH, (req, res, context) => {
|
||||
return res(context.text(JSON.stringify(MOCK_ROOT_CONFIG)));
|
||||
}),
|
||||
rootJsonEmpty: rest.get(ROOT_CONFIG_PATH, (req, res, context) => {
|
||||
return res(context.text(JSON.stringify({})));
|
||||
}),
|
||||
rootJsonWithLoading: rest.get(ROOT_CONFIG_PATH, (req, res, context) => {
|
||||
return res(
|
||||
context.text(JSON.stringify(MOCK_ROOT_CONFIG)),
|
||||
context.delay(1000)
|
||||
);
|
||||
}),
|
||||
rootJsonError: rest.get(ROOT_CONFIG_PATH, (req, res, context) => {
|
||||
return res(context.status(500), context.json({ message: 'ERROR' }));
|
||||
}),
|
||||
template1: {
|
||||
config: rest.get(`${BASE_PS_URL}/config.json`, (req, res, context) =>
|
||||
res(
|
||||
context.text(
|
||||
JSON.stringify({
|
||||
longDescription: 'long long description',
|
||||
imageUrl: 'someImage.url',
|
||||
blogPostLink:
|
||||
'https://hasura.io/blog/whats-new-in-hasura-cloud-may-2021/',
|
||||
sqlFiles: ['./first.sql', './second.sql'],
|
||||
metadataUrl: './meta.json',
|
||||
affectedMetadata: ['some', 'meta'],
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
configSlow: rest.get(`${BASE_PS_URL}/config.json`, (req, res, context) =>
|
||||
res(
|
||||
context.text(
|
||||
JSON.stringify({
|
||||
longDescription: 'long long description',
|
||||
imageUrl: 'someImage.url',
|
||||
blogPostLink:
|
||||
'https://hasura.io/blog/whats-new-in-hasura-cloud-may-2021/',
|
||||
sqlFiles: ['./first.sql', './second.sql'],
|
||||
metadataUrl: './meta.json',
|
||||
affectedMetadata: ['some', 'meta'],
|
||||
})
|
||||
),
|
||||
context.delay(1000)
|
||||
)
|
||||
),
|
||||
firstSql: rest.get(`${BASE_PS_URL}/first.sql`, (req, res, context) =>
|
||||
res(
|
||||
context.text(`
|
||||
CREATE SCHEMA _onetoone;
|
||||
|
||||
-- Create Tables
|
||||
CREATE TABLE _onetoone.owner (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE _onetoone.passport_info (
|
||||
id serial PRIMARY KEY,
|
||||
passport_number text NOT NULL UNIQUE,
|
||||
owner_id integer REFERENCES _onetoone.owner(id) NOT NULL
|
||||
);`)
|
||||
)
|
||||
),
|
||||
secondSql: rest.get(`${BASE_PS_URL}/second.sql`, (req, res, context) =>
|
||||
res(
|
||||
context.text(`
|
||||
INSERT INTO "_onetoone"."owner" ("name") VALUES
|
||||
('Coleman Spickett'),
|
||||
('Gallard Dreye'),;
|
||||
|
||||
INSERT INTO "_onetoone"."passport_info" ("passport_number", "owner_id") VALUES
|
||||
('553221', 1),
|
||||
('839016', 2),`)
|
||||
)
|
||||
),
|
||||
secondSqlError: rest.get(`${BASE_PS_URL}/second.sql`, (req, res, context) =>
|
||||
res(context.text('Not found'), context.status(404))
|
||||
),
|
||||
metadata: rest.get(`${BASE_PS_URL}/meta.json`, (req, res, context) =>
|
||||
res(context.text(JSON.stringify(MOCK_ONE_TO_ONE_METADATA)))
|
||||
),
|
||||
},
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
export type ModalType = { key: string; section: string };
|
||||
|
||||
export type modalOpenFn = (params: ModalType) => void;
|
||||
|
||||
export type SchemaSharingFetchingStatus =
|
||||
| 'success'
|
||||
| 'fetching'
|
||||
| 'failure'
|
||||
| 'none';
|
@ -126,3 +126,16 @@
|
||||
float: left;
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
.mr_xxs {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.ion_icon {
|
||||
font-size: 16px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.tab_heading_flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -1,19 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Dispatch, ReduxState } from '../../../types';
|
||||
import { mapDispatchToPropsEmpty } from '../../Common/utils/reactUtils';
|
||||
import styles from '../../Common/Common.scss';
|
||||
import Button from '../../Common/Button/Button';
|
||||
import { createNewSchema, deleteSchema } from './Schema/Actions';
|
||||
import { updateCurrentSchema } from './DataActions';
|
||||
import {
|
||||
getDataSourceBaseRoute,
|
||||
getSchemaPermissionsRoute,
|
||||
} from '../../Common/utils/routesUtils';
|
||||
import { getSchemaPermissionsRoute } from '../../Common/utils/routesUtils';
|
||||
import _push from './push';
|
||||
import { isFeatureSupported } from '../../../dataSources';
|
||||
import BreadCrumb from '../../Common/Layout/BreadCrumb/BreadCrumb';
|
||||
import Tabbed from './TabbedSchema';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
@ -52,132 +48,112 @@ const SourceView: React.FC<Props> = props => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ paddingTop: '20px', paddingLeft: '15px' }}>
|
||||
<div className={styles.padd_left}>
|
||||
<Helmet title="Source - Data | Hasura" />
|
||||
<div>
|
||||
<BreadCrumb
|
||||
breadCrumbs={[
|
||||
{ url: `/data`, title: 'Data' },
|
||||
{
|
||||
url: getDataSourceBaseRoute(currentDataSource),
|
||||
title: currentDataSource,
|
||||
prefix: <i className="fa fa-database" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.display_flex}>
|
||||
<h2 className={`${styles.headerText} ${styles.display_inline}`}>
|
||||
{currentDataSource}
|
||||
</h2>
|
||||
{isFeatureSupported('schemas.create.enabled') ? (
|
||||
<span>
|
||||
{!isCreateActive ? (
|
||||
<Tabbed tabName="display" currentDataSource={currentDataSource}>
|
||||
<div>
|
||||
{isFeatureSupported('schemas.create.enabled') ? (
|
||||
<span>
|
||||
{!isCreateActive ? (
|
||||
<Button
|
||||
data-test="data-create-schema"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
className={`${styles.add_mar_left}, ${styles.display_flex}`}
|
||||
onClick={() => setIsCreateActive(true)}
|
||||
>
|
||||
Create Schema
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className={styles.display_inline}
|
||||
style={{ paddingLeft: '10px' }}
|
||||
>
|
||||
<div className={styles.display_inline}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Schema name"
|
||||
className={`form-control input-sm ${styles.display_inline}`}
|
||||
value={createSchemaName}
|
||||
onChange={(e: any) => {
|
||||
e.persist();
|
||||
setCreateSchemaName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-test="data-create-schema"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
className={styles.add_mar_left}
|
||||
onClick={handleCreateSchema}
|
||||
>
|
||||
Create Schema
|
||||
</Button>
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
className={styles.add_mar_left_mid}
|
||||
onClick={() => {
|
||||
setIsCreateActive(false);
|
||||
setCreateSchemaName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<hr />
|
||||
<div id="schema-list-view">
|
||||
{schemaList.length ? (
|
||||
schemaList.map((schema, key: number) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.padd_small} ${styles.padd_left_remove}`}
|
||||
>
|
||||
<Button
|
||||
data-test="data-create-schema"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
className={styles.add_mar_left}
|
||||
onClick={() => setIsCreateActive(true)}
|
||||
color="white"
|
||||
size="xs"
|
||||
onClick={() => handleView(schema)}
|
||||
>
|
||||
Create Schema
|
||||
View
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className={styles.display_inline}
|
||||
style={{ paddingLeft: '10px' }}
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
className={styles.mar_small_left}
|
||||
onClick={() => handlePermissionsSummary(schema)}
|
||||
>
|
||||
<div className={styles.display_inline}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Schema name"
|
||||
className={`form-control input-sm ${styles.display_inline}`}
|
||||
value={createSchemaName}
|
||||
onChange={(e: any) => {
|
||||
e.persist();
|
||||
setCreateSchemaName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-test="data-create-schema"
|
||||
color="yellow"
|
||||
size="sm"
|
||||
className={styles.add_mar_left}
|
||||
onClick={handleCreateSchema}
|
||||
>
|
||||
Create Schema
|
||||
</Button>
|
||||
Permissions Summary
|
||||
</Button>
|
||||
{isFeatureSupported('schemas.delete.enabled') ? (
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
className={styles.add_mar_left_mid}
|
||||
onClick={() => {
|
||||
setIsCreateActive(false);
|
||||
setCreateSchemaName('');
|
||||
}}
|
||||
className={styles.mar_small_left}
|
||||
onClick={() => handleDelete(schema)}
|
||||
>
|
||||
Cancel
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div
|
||||
key={key}
|
||||
className={`${styles.display_inline} ${styles.padd_small_left}`}
|
||||
>
|
||||
{schema}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<hr />
|
||||
<div id="schema-list-view">
|
||||
{schemaList.length ? (
|
||||
schemaList.map((schema, key: number) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.padd_small} ${styles.padd_left_remove}`}
|
||||
>
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
onClick={() => handleView(schema)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
className={styles.mar_small_left}
|
||||
onClick={() => handlePermissionsSummary(schema)}
|
||||
>
|
||||
Permissions Summary
|
||||
</Button>
|
||||
{isFeatureSupported('schemas.delete.enabled') ? (
|
||||
<Button
|
||||
color="white"
|
||||
size="xs"
|
||||
className={styles.mar_small_left}
|
||||
onClick={() => handleDelete(schema)}
|
||||
>
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div
|
||||
key={key}
|
||||
className={`${styles.display_inline} ${styles.padd_small_left}`}
|
||||
>
|
||||
{schema}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div>There are no schemas at the moment</div>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div>There are no schemas at the moment</div>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
</Tabbed>
|
||||
);
|
||||
};
|
||||
|
||||
|
67
console/src/components/Services/Data/TabbedSchema.tsx
Normal file
67
console/src/components/Services/Data/TabbedSchema.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import CommonTabLayout from '../../Common/Layout/CommonTabLayout/CommonTabLayout';
|
||||
import { RightContainer } from '../../Common/Layout/RightContainer';
|
||||
import schemaStyles from './Schema/styles.scss';
|
||||
import { Tabs } from '../../Common/Layout/ReusableTabs/ReusableTabs';
|
||||
|
||||
import styles from '../../../components/Common/Common.scss';
|
||||
|
||||
const tabs: Tabs = {
|
||||
display: {
|
||||
display_text: 'Schema',
|
||||
},
|
||||
gallery: {
|
||||
display_text: 'Schema Gallery',
|
||||
},
|
||||
};
|
||||
|
||||
const appPrefix = '/data';
|
||||
|
||||
const TabbedSchema: React.FC<{
|
||||
tabName: 'display' | 'gallery';
|
||||
currentDataSource: string;
|
||||
}> = ({ children, tabName, currentDataSource }) => {
|
||||
const breadCrumbs = [
|
||||
{
|
||||
title: 'Data',
|
||||
url: appPrefix,
|
||||
},
|
||||
{
|
||||
title: `${currentDataSource}`,
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RightContainer>
|
||||
<Helmet title={`${tabs[tabName]?.display_text} - Hasura`} />
|
||||
<div
|
||||
className={`${styles.view_stitch_schema_wrapper} ${styles.addWrapper}`}
|
||||
>
|
||||
<CommonTabLayout
|
||||
appPrefix={appPrefix}
|
||||
currentTab={tabName}
|
||||
heading={
|
||||
<div className={schemaStyles.tab_heading_flex}>
|
||||
<span
|
||||
className={`${schemaStyles.mr_xxs} ${schemaStyles.ion_icon}`}
|
||||
>
|
||||
<i className="fa fa-database" />
|
||||
</span>
|
||||
{currentDataSource}
|
||||
</div>
|
||||
}
|
||||
tabsInfo={tabs}
|
||||
breadCrumbs={breadCrumbs}
|
||||
baseUrl={`${appPrefix}/${currentDataSource}`}
|
||||
showLoader={false}
|
||||
testPrefix="schema-container-tabs"
|
||||
/>
|
||||
<div className={styles.add_pad_top}>{children}</div>
|
||||
</div>
|
||||
</RightContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabbedSchema;
|
@ -219,7 +219,12 @@ const DatabaseItemsView: React.FC<DatabaseItemsViewProps> = ({
|
||||
databaseLoading,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const showActiveStyle = pathname === `/data/${item.name}/`;
|
||||
const showActiveStyle = [
|
||||
`/data/${item.name}/`,
|
||||
`/data/${item.name}`,
|
||||
`/data/${item.name}/display`,
|
||||
`/data/${item.name}/gallery`,
|
||||
].includes(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(isActive);
|
||||
|
@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
|
||||
import { reducer as notifications } from 'react-notification-system-redux';
|
||||
import { routerReducer } from 'react-router-redux';
|
||||
|
||||
import { dataReducer } from './components/Services/Data';
|
||||
import dataReducer from './components/Services/Data/DataReducer';
|
||||
import { remoteSchemaReducer } from './components/Services/RemoteSchema';
|
||||
import { actionsReducer } from './components/Services/Actions';
|
||||
import { typesReducer } from './components/Services/Types';
|
||||
|
6
console/src/setupTests.ts
Normal file
6
console/src/setupTests.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import '@testing-library/jest-dom';
|
@ -14,10 +14,9 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware => {
|
||||
// Middleware by default : https://redux-toolkit.js.org/api/getDefaultMiddleware#included-default-middleware
|
||||
let middlewares = getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredPaths: ['routing.locationBeforeTransitions.query'],
|
||||
ignoredActions: ['@@router/LOCATION_CHANGE'],
|
||||
},
|
||||
// Removed because we use callbacks in some places and they are not serializable.
|
||||
// see https://redux.js.org/style-guide/style-guide#do-not-put-non-serializable-values-in-state-or-actions
|
||||
serializableCheck: false,
|
||||
// Remove this line when you want to
|
||||
// See https://redux.js.org/style-guide/style-guide#do-not-mutate-state
|
||||
immutableCheck: false,
|
||||
@ -42,3 +41,7 @@ export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
export type AsyncThunkConfig = {
|
||||
dispatch: AppDispatch;
|
||||
state: RootState;
|
||||
};
|
||||
|
@ -12,6 +12,8 @@ $humility: #777;
|
||||
|
||||
// Bootstrap Variables
|
||||
$brand-primary: darken(#428bca, 6.5%);
|
||||
$btn-primary-bg: #fec53d;
|
||||
$btn-primary-color: #606060;
|
||||
$brand-secondary: #e25139;
|
||||
$brand-success: #5cb85c;
|
||||
$brand-warning: #f0ad4e;
|
||||
|
@ -73,6 +73,9 @@ export type RunSqlType = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Entry<O, K extends keyof O> = [K, O[K]];
|
||||
export type Entries<O> = Entry<O, keyof O>[];
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const __DEVELOPMENT__: boolean;
|
||||
|
@ -356,15 +356,16 @@ export const getDownQueryComments = (
|
||||
) => {
|
||||
if (Array.isArray(upqueries) && upqueries.length >= 0) {
|
||||
const comment = [
|
||||
"Could not auto-generate a down migration.",
|
||||
"Please write an appropriate down migration for the SQL below:",
|
||||
...upqueries.map((i) => i.args.sql),
|
||||
""
|
||||
].join("\n")
|
||||
// Normalize \r\n to \n and add comments before every line
|
||||
.replace(/\r?(^|\n)(?!$)/g, "$1-- ")
|
||||
// Eliminate trailing spaces
|
||||
.replace(/ +\n/g, "\n");
|
||||
'Could not auto-generate a down migration.',
|
||||
'Please write an appropriate down migration for the SQL below:',
|
||||
...upqueries.map(i => i.args.sql),
|
||||
'',
|
||||
]
|
||||
.join('\n')
|
||||
// Normalize \r\n to \n and add comments before every line
|
||||
.replace(/\r?(^|\n)(?!$)/g, '$1-- ')
|
||||
// Eliminate trailing spaces
|
||||
.replace(/ +\n/g, '\n');
|
||||
return [getRunSqlQuery(comment, source)];
|
||||
}
|
||||
// all other errors
|
||||
|
@ -11,8 +11,9 @@
|
||||
"noEmit": true,
|
||||
"types": ["react"],
|
||||
"typeRoots": ["./src/types", "node_modules/@types"],
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"target": "es5"
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts", "cypress"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "cypress"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
@ -3,6 +3,6 @@
|
||||
"types": ["react", "jest"]
|
||||
},
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*", "src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
"exclude": ["node_modules", "cypress"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user