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:
Nicolas Beaussart 2021-07-08 10:17:34 +02:00 committed by hasura-bot
parent e567a096e6
commit e3e078fa42
36 changed files with 3070 additions and 154 deletions

View File

@ -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

View File

@ -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",

View File

@ -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"]
}
]
}
}

View File

@ -15,7 +15,7 @@ build:
npm run build
jest:
npm run jest
npm run jest -- --runInBand
test:
npm run dev & npm run test

View 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();
}

View File

@ -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',

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -0,0 +1,6 @@
.modal_footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}

View File

@ -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;

View File

@ -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,

View File

@ -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)} />

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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,
});
});
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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',
});
});
});

View File

@ -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",
},
],
},
],
},
}
`;

View File

@ -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)))
),
},
};

View File

@ -0,0 +1,9 @@
export type ModalType = { key: string; section: string };
export type modalOpenFn = (params: ModalType) => void;
export type SchemaSharingFetchingStatus =
| 'success'
| 'fetching'
| 'failure'
| 'none';

View File

@ -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;
}

View File

@ -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>
);
};

View 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;

View File

@ -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);

View File

@ -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';

View 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';

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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/**/*"]
}

View File

@ -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"]
}