frontend: Schema registry UI improvements

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9479
GitOrigin-RevId: 306df780cfb9cc39a335abd98706ae2fe4293700
This commit is contained in:
Rishichandra Wawhal 2023-06-09 00:01:38 +05:30 committed by hasura-bot
parent c3527e3c98
commit db1b6cf424
12 changed files with 255 additions and 80 deletions

View File

@ -47,14 +47,17 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
items: [],
};
if (isCloudConsole(globals)) {
if (
isCloudConsole(globals) &&
(globals.userRole === 'admin' || globals.userRole === 'owner')
) {
sectionsData.graphql = {
key: 'graphql',
label: 'GraphQL',
items: [
{
key: 'schema-registry',
label: 'Schema Registry',
label: 'Schema Registry (Beta)',
route: '/settings/schema-registry',
dataTestVal: 'metadata-schema-registry-link',
},

View File

@ -3,11 +3,26 @@ import { SchemasList } from './SchemasList';
import { FeatureRequest } from './FeatureRequest';
import globals from '../../../Globals';
import { SCHEMA_REGISTRY_FEATURE_NAME } from '../constants';
import { Badge } from '../../../new-components/Badge';
import { SCHEMA_REGISTRY_REF_URL } from '../constants';
const Header: React.VFC = () => {
return (
<div className="flex w-full">
<h1 className="text-xl font-semibold">GraphQL Schema Registry</h1>
<div className="flex flex-col w-full">
<div className="flex w-full mb-sm">
<h1 className="text-xl font-semibold">GraphQL Schema Registry</h1>
<Badge className="mx-2" color="blue">
BETA
</Badge>
</div>
<a
className="text-muted w-auto"
href={SCHEMA_REGISTRY_REF_URL}
target="_blank"
rel="noreferrer noopener"
>
What is Schema Registry?
</a>
</div>
);
};

View File

@ -1,12 +1,25 @@
import React from 'react';
import { ChangeLevel } from '../types';
import { FaExclamationTriangle } from 'react-icons/fa';
import { IconTooltip } from '../../../new-components/Tooltip';
export const CountLabel: React.VFC<{
count: number;
count?: number;
type: ChangeLevel;
}> = props => {
const { count, type } = props;
if (count === undefined) {
return (
<IconTooltip
icon={<FaExclamationTriangle className="pr-2 text-xl text-secondary" />}
message={
'Could not compute changes with respect to previous schema for this role. A previous schema for this role might not exist or one of the GraphQL schemas could be erroneous.'
}
/>
);
}
let textColor = 'text-green-500';
let backgroundColor = 'bg-green-100';

View File

@ -3,6 +3,7 @@ import { Button } from '../../../new-components/Button';
import { GraphQLError } from 'graphql';
import { hasuraToast } from '../../../new-components/Toasts';
import { useSubmitSchemaRegistryFeatureRequest } from '../hooks/useSubmitSchemaRegistryFeatureRequest';
import { SCHEMA_REGISTRY_REF_URL } from '../constants';
export const FeatureRequest = () => {
const onSuccess = () => {
@ -46,7 +47,11 @@ export const FeatureRequest = () => {
schema changes more reliable, prevent breaking changes in your schema
and make collaboration across large teams, micro services and roles much
more manageable and predictable.{' '}
<a href="" target="_blank" rel="noreferrer noopener">
<a
href={SCHEMA_REGISTRY_REF_URL}
target="_blank"
rel="noreferrer noopener"
>
{' '}
Read more.
</a>

View File

@ -1,11 +1,15 @@
import React, { useMemo } from 'react';
import LZString from 'lz-string';
import moment from 'moment';
import { useGetSchema } from '../hooks/useGetSchema';
import { Tabs } from '../../../new-components/Tabs';
import { IconTooltip } from '../../../new-components/Tooltip';
import { SchemaRow } from './SchemaRow';
import { ChangeSummary } from './ChangeSummary';
import { FindIfSubStringExists, schemaTransformFn } from '../utils';
import {
FindIfSubStringExists,
schemaTransformFn,
getPublishTime,
} from '../utils';
import { Link } from 'react-router';
import { RoleBasedSchema, Schema } from '../types';
import { FaHome, FaAngleRight, FaFileImport, FaSearch } from 'react-icons/fa';
@ -62,14 +66,13 @@ const SchemasDetails: React.VFC<{
// We know for sure only one roleBasedSchema exists
const roleBasedSchema = schema.roleBasedSchemas[0];
const published = moment(schema.created_at);
return (
<div className="mx-4 mt-4">
<Breadcrumbs />
<div className="flex mb-sm">
<span className="font-bold text-xl text-black mr-4">
{roleBasedSchema.role}:{schema.entry_hash.substr(0, 16)}
{roleBasedSchema.role}:{schema.entry_hash}
</span>
</div>
<div className="border-neutral-200 bg-white border w-3/5">
@ -87,16 +90,22 @@ const SchemasDetails: React.VFC<{
<div className="ml-4 mb-2 ">
<SchemaRow
role={roleBasedSchema.role || ''}
changes={roleBasedSchema.changes || []}
changes={roleBasedSchema.changes}
/>
<div className="flex mt-4">
<div className="flex-col w-1/4">
<div className="font-bold text-gray-500">Published</div>
<span>{published.fromNow()}</span>
<div className="flex-col w-1/2">
<div className="flex items-center">
<p className="font-bold text-gray-500">Published</p>
<IconTooltip message="The time at which this GraphQL schema was generated" />
</div>
<span>{getPublishTime(schema.created_at)}</span>
</div>
<div className="flex-col w-3/4">
<div className="font-bold text-gray-500">Hash</div>
<div className="flex-col w-1/2">
<div className="flex items-center">
<p className="font-bold text-gray-500">Schema Hash</p>
<IconTooltip message="Hash of the GraphQL Schema SDL. Hash for two identical schema is identical." />
</div>
<span className="font-bold bg-gray-100 px-1 rounded text-sm">
{roleBasedSchema.hash}
</span>
@ -118,7 +127,12 @@ const SchemasDetails: React.VFC<{
{
value: 'changes',
label: 'Changes',
content: <ChangesView changes={roleBasedSchema.changes} />,
content: (
<ChangesView
changes={roleBasedSchema.changes}
role={roleBasedSchema.role}
/>
),
},
]}
/>
@ -149,8 +163,9 @@ export const SchemaView: React.VFC<{ schema: string }> = props => {
export const ChangesView: React.VFC<{
changes: RoleBasedSchema['changes'];
role: string;
}> = props => {
const { changes } = props;
const { changes, role } = props;
const [searchText, setSearchText] = React.useState('');
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) =>
@ -165,11 +180,27 @@ export const ChangesView: React.VFC<{
}, [searchText, changes]);
if (!changes) {
return <span>Could not compute what changed in this GraphQL Schema</span>;
return (
<div className="p-4">
<span className="text-muted">
Could not compute changes in this GraphQL schema with respect to the
previous schema for role <b>{role}</b>. This typically happens if
there is no previous schema for role <b>{role}</b> or if your GraphQL
schema is erroneous.
</span>
</div>
);
}
if (!changes.length) {
return <span className="ml-4">No changes!</span>;
return (
<div className="p-4">
<span className="text-muted">
No changes in this GraphQL schema with respect to the previous schema
for role <b>{role}</b>.
</span>
</div>
);
}
const breakingChanges =
@ -182,6 +213,12 @@ export const ChangesView: React.VFC<{
return (
<div className="flex-col m-8">
<div className="mb-8">
<p className="text-muted">
These changes are with respect to the previous schema for role{' '}
<b>{role}</b>.
</p>
</div>
<div className="flex-col">
<div className="font-bold text-md mb-8 text-gray-500">
Change Summary

View File

@ -1,36 +1,45 @@
import React from 'react';
import { CountLabel } from './CountLabel';
import { CapitalizeFirstLetter } from '../utils';
import { SchemaChange } from '../types';
import { FaChevronRight } from 'react-icons/fa';
export const SchemaRow: React.VFC<{
role: string;
changes: SchemaChange[];
changes?: SchemaChange[];
onClick?: VoidFunction;
}> = props => {
const { role, changes, onClick } = props;
const countBreakingChanges = changes?.filter(
c => c.criticality.level === 'BREAKING'
).length;
)?.length;
const countDangerousChanges = changes?.filter(
c => c.criticality.level === 'DANGEROUS'
).length;
)?.length;
const countSafeChanges = changes?.filter(
c => c.criticality.level === 'NON_BREAKING'
).length;
)?.length;
return (
<div className="w-full flex my-2">
<div className="flex text-base w-[72%] justify-start">
<span className="text-sm font-bold bg-gray-100 p-1 rounded">
{CapitalizeFirstLetter(role)}
{role}
</span>
</div>
<div className="flex text-base w-[28%] justify-between">
<CountLabel count={countBreakingChanges} type="BREAKING" />
<CountLabel count={countDangerousChanges} type="DANGEROUS" />
<CountLabel count={countSafeChanges} type="NON_BREAKING" />
{changes ? (
<>
<CountLabel count={countBreakingChanges || 0} type="BREAKING" />
<CountLabel count={countDangerousChanges || 0} type="DANGEROUS" />
<CountLabel count={countSafeChanges || 0} type="NON_BREAKING" />
</>
) : (
<>
<CountLabel count={countBreakingChanges} type="BREAKING" />
<CountLabel count={countDangerousChanges} type="DANGEROUS" />
<CountLabel count={countSafeChanges} type="NON_BREAKING" />
</>
)}
</div>
{onClick && (
<div

View File

@ -1,11 +1,12 @@
import React from 'react';
import { SchemaRow } from './SchemaRow';
import { useGetSchemaList } from '../hooks/useGetSchemaList';
import { Button } from '../../../new-components/Button';
import { Schema, RoleBasedSchema } from '../types';
import moment from 'moment';
import { browserHistory } from 'react-router';
import { useDispatch } from 'react-redux';
import _push from '../../../components/Services/Data/push';
import globals from '../../../Globals';
import { schemaListTransformFn } from '../utils';
import { schemaListTransformFn, getPublishTime } from '../utils';
export const SchemasList = () => {
const projectID = globals.hasuraCloudProjectId || '';
@ -21,13 +22,25 @@ export const SchemasList = () => {
case 'success': {
const schemaList = schemaListTransformFn(fetchSchemaResponse.response);
return <Tabularised schemas={schemaList} />;
return (
<Tabularised
schemas={schemaList}
loadMore={fetchSchemaResponse.loadMore}
isLoadingMore={fetchSchemaResponse.isLoadingMore}
shouldLoadMore={fetchSchemaResponse.shouldLoadMore}
/>
);
}
}
};
export const Tabularised: React.VFC<{ schemas: Schema[] }> = props => {
const { schemas } = props;
export const Tabularised: React.VFC<{
schemas: Schema[];
loadMore: VoidFunction;
isLoadingMore: boolean;
shouldLoadMore: boolean;
}> = props => {
const { schemas, loadMore, isLoadingMore, shouldLoadMore } = props;
return (
<div className="overflow-x-auto rounded-sm border-neutral-200 bg-gray-100 border w-3/5">
<div className="w-full flex bg-gray-100 px-4 py-2">
@ -43,16 +56,32 @@ export const Tabularised: React.VFC<{ schemas: Schema[] }> = props => {
</div>
<div className="flex flex-col w-full">
{schemas.length ? (
schemas.map(schema => {
return (
<SchemaCard
createdAt={schema.created_at}
schemaId={schema.id}
hash={schema.hash}
roleBasedSchemas={schema.roleBasedSchemas}
/>
);
})
<div className="mb-md">
{schemas.map(schema => {
return (
<SchemaCard
createdAt={schema.created_at}
schemaId={schema.id}
hash={schema.entry_hash}
roleBasedSchemas={schema.roleBasedSchemas}
/>
);
})}
{shouldLoadMore && (
<div className="flex w-full justify-center items-center">
<Button
onClick={e => {
e.preventDefault();
loadMore();
}}
isLoading={isLoadingMore}
disabled={isLoadingMore}
>
Load More
</Button>
</div>
)}
</div>
) : (
<div className="white border-t border-neutral-200">
<div className="p-xs" data-test="label-no-domain-found">
@ -73,17 +102,17 @@ const SchemaCard: React.VFC<{
}> = props => {
const { createdAt, hash, roleBasedSchemas } = props;
const published = moment(createdAt);
const dispatch = useDispatch();
return (
<div className="w-full flex-col px-4 py-2 mb-2 bg-white">
<div className="flex flex-col w-1/2">
<div className="flex flex-col w-full">
<div className="flex mt-4">
<div className="flex-col w-1/4">
<div className="flex-col w-1/2">
<div className="font-bold text-gray-500">Published</div>
<span>{published.fromNow()}</span>
<span>{getPublishTime(createdAt)}</span>
</div>
<div className="flex-col w-3/4">
<div className="flex-col w-1/2">
<div className="font-bold text-gray-500">Hash</div>
<span className="font-bold bg-gray-100 px-1 rounded text-sm">
{hash}
@ -93,16 +122,17 @@ const SchemaCard: React.VFC<{
</div>
<div className="flex-col w-full mt-8">
<div className="font-bold text-gray-500">Roles</div>
{roleBasedSchemas.length ? (
roleBasedSchemas.map((roleBasedSchema, index) => {
return (
<div className="flex-col w-full">
<SchemaRow
role={roleBasedSchema.role || ''}
changes={roleBasedSchema.changes || []}
changes={roleBasedSchema.changes}
onClick={() => {
browserHistory.push(
`${globals.urlPrefix}/settings/schema-registry/${roleBasedSchema.id}`
dispatch(
_push(`/settings/schema-registry/${roleBasedSchema.id}`)
);
}}
/>

View File

@ -7,3 +7,8 @@ export const FETCH_REGISTRY_SCHEMA_QUERY_NAME =
export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000;
export const SCHEMA_REGISTRY_FEATURE_NAME = 'GraphQLSchemaRegistry';
export const SCHEMA_REGISTRY_REF_URL =
'https://github.com/hasura/graphql-engine/issues/9574';
export const SCHEMA_LIST_FETCH_BATCH_SIZE = 10;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { useQuery } from 'react-query';
import { schemaRegsitryControlPlaneClient } from '../utils';
import { FETCH_REGSITRY_SCHEMA_QUERY } from '../queries';
import { FETCH_REGISTRY_SCHEMA_QUERY } from '../queries';
import { GetRegistrySchemaResponseWithError } from '../types';
import {
FETCH_REGISTRY_SCHEMA_QUERY_NAME,
@ -26,7 +26,7 @@ export const useGetSchema = (schemaId: string): FetchSchemaResponse => {
return schemaRegsitryControlPlaneClient.query<
GetRegistrySchemaResponseWithError,
{ schemaId: string }
>(FETCH_REGSITRY_SCHEMA_QUERY, {
>(FETCH_REGISTRY_SCHEMA_QUERY, {
schemaId: schemaId,
});
};

View File

@ -1,11 +1,12 @@
import * as React from 'react';
import { useQuery } from 'react-query';
import { FETCH_REGSITRY_SCHEMAS_QUERY } from '../queries';
import { FETCH_REGISTRY_SCHEMAS_QUERY } from '../queries';
import { schemaRegsitryControlPlaneClient } from '../utils';
import { GetSchemaListResponseWithError } from '../types';
import {
FETCH_REGISTRY_SCHEMAS_QUERY_NAME,
SCHEMA_REGISTRY_REFRESH_TIME,
SCHEMA_LIST_FETCH_BATCH_SIZE,
} from '../constants';
type FetchSchemaResponse =
@ -18,25 +19,58 @@ type FetchSchemaResponse =
}
| {
kind: 'success';
response: NonNullable<GetSchemaListResponseWithError['data']>;
response: NonNullable<
GetSchemaListResponseWithError['data']
>['schema_registry_dumps'];
loadMore: VoidFunction;
isLoadingMore: boolean;
shouldLoadMore: boolean;
};
export const useGetSchemaList = (projectId: string): FetchSchemaResponse => {
const fetchRegistrySchemasQueryFn = (projectId: string) => {
return schemaRegsitryControlPlaneClient.query<
GetSchemaListResponseWithError,
{ projectId: string }
>(FETCH_REGSITRY_SCHEMAS_QUERY, {
projectId: projectId,
});
};
const [dumps, setDumps] = React.useState<
NonNullable<GetSchemaListResponseWithError['data']>['schema_registry_dumps']
>([]);
const [loadingMore, setLoadingMore] = React.useState(false);
const [shouldLoadMore, setShouldLoadMore] = React.useState(true);
const { data, error, isLoading } = useQuery({
let cursor = 'now()';
if (dumps && dumps.length > 0) {
cursor = dumps[dumps.length - 1].change_recorded_at;
}
const fetchRegistrySchemasQueryFn = React.useCallback(
(projectId: string) => {
return schemaRegsitryControlPlaneClient.query<
GetSchemaListResponseWithError,
{ projectId: string; cursor: string; limit: number }
>(FETCH_REGISTRY_SCHEMAS_QUERY, {
projectId: projectId,
cursor: cursor,
limit: SCHEMA_LIST_FETCH_BATCH_SIZE,
});
},
[cursor]
);
const { data, error, isLoading, refetch } = useQuery({
queryKey: FETCH_REGISTRY_SCHEMAS_QUERY_NAME,
queryFn: () => fetchRegistrySchemasQueryFn(projectId),
refetchOnMount: 'always',
refetchOnWindowFocus: true,
staleTime: SCHEMA_REGISTRY_REFRESH_TIME,
onSettled: () => {
setLoadingMore(false);
},
onSuccess: response => {
if (response && response.data && response.data.schema_registry_dumps) {
const dumps = response.data.schema_registry_dumps;
setDumps(d => [...(d || []), ...dumps]);
if (dumps.length < SCHEMA_LIST_FETCH_BATCH_SIZE) {
setShouldLoadMore(false);
}
}
},
});
if (isLoading) {
@ -54,6 +88,12 @@ export const useGetSchemaList = (projectId: string): FetchSchemaResponse => {
return {
kind: 'success',
response: data.data,
response: dumps,
loadMore: () => {
setLoadingMore(true);
refetch();
},
isLoadingMore: loadingMore,
shouldLoadMore,
};
};

View File

@ -1,15 +1,17 @@
import { parse as gql } from 'graphql';
export const FETCH_REGSITRY_SCHEMAS_QUERY = gql(`
query fetchRegistrySchemas($projectId: uuid!) {
export const FETCH_REGISTRY_SCHEMAS_QUERY = gql(`
query fetchRegistrySchemas($projectId: uuid!, $cursor: timestamptz!, $limit: Int!) {
schema_registry_dumps(
where: {_and: [{project_id: {_eq: $projectId}, hasura_schema_role: {_eq: "admin"}}]},
where: {_and: [{project_id: {_eq: $projectId}, hasura_schema_role: {_eq: "admin"}}, {change_recorded_at: {_lt: $cursor}}]},
order_by: {change_recorded_at: desc}
limit: $limit
) {
change_recorded_at
schema_hash
entry_hash
id
metadata_resource_version
sibling_schemas {
id
entry_hash
@ -30,7 +32,7 @@ query fetchRegistrySchemas($projectId: uuid!) {
`);
export const FETCH_REGSITRY_SCHEMA_QUERY = gql(`
export const FETCH_REGISTRY_SCHEMA_QUERY = gql(`
query fetchRegistrySchema ($schemaId: uuid!) {
schema_registry_dumps (
where: {

View File

@ -9,6 +9,7 @@ import {
SiblingSchema,
GetRegistrySchemaResponseWithError,
} from './types';
import moment from 'moment';
export const CapitalizeFirstLetter = (str: string) => {
return str[0].toUpperCase() + str.slice(1);
@ -29,19 +30,24 @@ export const schemaRegsitryControlPlaneClient = createControlPlaneClient(
);
export const schemaListTransformFn = (
fetchedData: NonNullable<GetSchemaListResponseWithError['data']>
dumps: NonNullable<
GetSchemaListResponseWithError['data']
>['schema_registry_dumps']
) => {
const dumps = fetchedData.schema_registry_dumps || [];
const schemaList: Schema[] = [];
dumps.forEach((dump: SchemaRegistryDumpWithSiblingSchema) => {
const roleBasedSchemas: RoleBasedSchema[] = [];
dump.sibling_schemas.forEach((childSchema: SiblingSchema) => {
const changes: SchemaChange[] = [
...(childSchema.diff_with_previous_schema?.[0]?.schema_diff_data || []),
];
const prevSchemaDiff = childSchema.diff_with_previous_schema;
let changes: SchemaChange[] | undefined = [];
if (prevSchemaDiff?.length > 0) {
changes = [...(prevSchemaDiff[0]?.schema_diff_data || [])];
} else {
changes = undefined;
}
const roleBasedSchema: RoleBasedSchema = {
raw: childSchema.schema_sdl,
@ -75,9 +81,14 @@ export const schemaTransformFn = (
const roleBasedSchemas: RoleBasedSchema[] = [];
const changes = [
...(data.diff_with_previous_schema?.[0]?.schema_diff_data || []),
];
const prevSchemaDiff = data.diff_with_previous_schema;
let changes: SchemaChange[] | undefined = [];
if (prevSchemaDiff?.length > 0) {
changes = [...(prevSchemaDiff[0]?.schema_diff_data || [])];
} else {
changes = undefined;
}
const roleBasedSchema: RoleBasedSchema = {
id: data.id,
@ -100,3 +111,8 @@ export const schemaTransformFn = (
return schema;
};
export const getPublishTime = (isoStringTs: string) => {
const published = moment(isoStringTs);
return published.format('DD/MM/YYYY HH:mm:ss');
};