console: add new allow list manager under feature flag

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5816
GitOrigin-RevId: e240a08524ad688d36d362d2be03697b7889fd7f
This commit is contained in:
Daniele Cammareri 2022-09-15 10:03:50 +02:00 committed by hasura-bot
parent 118b03e329
commit 85d5082322
59 changed files with 1241 additions and 510 deletions

View File

@ -85,3 +85,5 @@ export {
SampleDBBanner,
newSampleDBTrial,
} from '../src/components/Services/Data/DataSources/SampleDatabase';
export { AllowListDetail } from '../src/components/Services/AllowList/AllowListDetail';

View File

@ -0,0 +1,123 @@
import React from 'react';
import { browserHistory } from 'react-router';
import { Tabs } from '@/new-components/Tabs';
import { useConsoleConfig } from '@/hooks/useEnvVars';
import {
useQueryCollections,
QueryCollectionsOperations,
QueryCollectionHeader,
} from '@/features/QueryCollections';
import { AllowListSidebar, AllowListPermissions } from '@/features/AllowLists';
import PageContainer from '@/components/Common/Layout/PageContainer/PageContainer';
interface AllowListDetailProps {
params: {
name: string;
section: string;
};
}
export const buildUrl = (name: string, section: string) =>
`/api/allow-list/detail/${name}/${section}`;
export const pushUrl = (name: string, section: string) => {
browserHistory.push(buildUrl(name, section));
};
export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
const { name, section } = props.params;
const {
data: queryCollections,
isLoading,
isRefetching,
} = useQueryCollections();
const { type } = useConsoleConfig();
const queryCollection = queryCollections?.find(
({ name: collectionName }) => collectionName === name
);
if (
!isLoading &&
!isRefetching &&
queryCollections?.[0] &&
(!name || !queryCollection)
) {
// Redirect to first collection if no collection is selected or if the selected collection is not found
pushUrl(queryCollections[0].name, section ?? 'operations');
}
return (
<div className="flex flex-auto overflow-y-hidden h-[calc(100vh-35.49px-54px)]">
<PageContainer
helmet="Allow List Detail"
leftContainer={
<div className="bg-white border-r border-gray-300 h-full overflow-y-auto p-4">
<AllowListSidebar
onQueryCollectionCreate={newName => {
pushUrl(newName, 'operations');
}}
buildQueryCollectionHref={(collectionName: string) =>
buildUrl(collectionName, 'operations')
}
onQueryCollectionClick={url => browserHistory.push(url)}
selectedCollectionQuery={name}
/>
</div>
}
>
<div className="h-full overflow-y-auto p-4">
{queryCollection && (
<div>
<QueryCollectionHeader
onRename={(_, newName) => {
pushUrl(newName, section);
}}
onDelete={() => {
if (queryCollections?.[0]?.name) {
pushUrl(queryCollections?.[0]?.name, 'operations');
}
}}
queryCollection={queryCollection}
/>
</div>
)}
{type !== 'oss' ? (
<Tabs
value={section}
onValueChange={value => {
pushUrl(name, value);
}}
items={[
{
value: 'operations',
label: 'Operations',
content: (
<div className="p-4">
<QueryCollectionsOperations collectionName={name} />
</div>
),
},
{
value: 'permissions',
label: 'Permissions',
content: (
<div className="p-4">
<AllowListPermissions collectionName={name} />
</div>
),
},
]}
/>
) : (
<QueryCollectionsOperations collectionName={name} />
)}
</div>
</PageContainer>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './AllowListDetail';

View File

@ -1,13 +1,22 @@
import React from 'react';
import { Link, RouteComponentProps } from 'react-router';
import { canAccessSecuritySettings } from '@/utils/permissions';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
type TopNavProps = {
location: RouteComponentProps<unknown, unknown>['location'];
};
const TopNav: React.FC<TopNavProps> = ({ location }) => {
const { enabled: allowListEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.allowListId
);
const sectionsData = [
[
{
key: 'graphiql',
link: '/api/api-explorer',
@ -20,10 +29,23 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
dataTestVal: 'rest-explorer-link',
title: 'REST',
},
],
[
...(allowListEnabled
? [
{
key: 'allow-list',
link: '/api/allow-list',
dataTestVal: 'allow-list',
title: 'Allow List',
},
]
: []),
],
];
if (canAccessSecuritySettings()) {
sectionsData.push({
sectionsData[1].push({
key: 'security',
link: '/api/security/api_limits',
dataTestVal: 'security-explorer-link',
@ -40,11 +62,14 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
return (
<div className="flex justify-between items-center border-b border-gray-300 bg-white px-sm">
<div className="flex space-x-4 px-1">
{sectionsData.map(section => (
<div className="flex px-1 w-full">
{sectionsData.map((group, groupIndex) =>
group.map((section, sectionIndex) => (
<div
role="presentation"
className={`whitespace-nowrap font-medium pt-2 pb-1 px-2 border-b-4
className={`${
groupIndex === 1 && sectionIndex === 0 ? 'ml-auto' : 'ml-4'
} whitespace-nowrap font-medium pt-2 pb-1 px-2 border-b-4
${
isActive(section.link)
? 'border-gray-300 hover:border-gray-300'
@ -60,7 +85,8 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
{section.title}
</Link>
</div>
))}
))
)}
</div>
</div>
);

View File

@ -1,6 +1,7 @@
.notifications-wrapper .notification {
height: auto !important;
width: auto !important;
pointer-events: auto;
}
.notifications-wrapper .notifications-tr,

View File

@ -1,5 +1,12 @@
import React from 'react';
import { browserHistory } from 'react-router';
import {
availableFeatureFlagIds,
FeatureFlagToast,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
import AllowedQueriesNotes from './AllowedQueriesNotes';
import AddAllowedQuery from './AddAllowedQuery';
import AllowedQueriesList from './AllowedQueriesList';
@ -17,6 +24,14 @@ interface Props {
const AllowedQueries: React.FC<Props> = props => {
const { dispatch, allowedQueries } = props;
const { enabled: featureFlagEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.allowListId
);
if (featureFlagEnabled) {
browserHistory.push('/api/allow-list');
}
return (
<div className="clear-both pl-md pt-md mb-md">
<div>
@ -35,6 +50,7 @@ const AllowedQueries: React.FC<Props> = props => {
/>
</div>
</div>
<FeatureFlagToast flagId={availableFeatureFlagIds.allowListId} />
</div>
);
};

View File

@ -1,5 +1,9 @@
/* eslint-disable no-underscore-dangle */
import React from 'react';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
import { Link, RouteComponentProps } from 'react-router';
import LeftContainer from '../../Common/Layout/LeftContainer/LeftContainer';
import CheckIcon from '../../Common/Icons/Check';
@ -44,6 +48,10 @@ interface SectionData {
const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
const sectionsData: SectionData[] = [];
const { enabled: newAllowListEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.allowListId
);
sectionsData.push({
key: 'actions',
link: '/settings/metadata-actions',
@ -73,7 +81,7 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
sectionsData.push({
key: 'allow-list',
link: '/settings/allow-list',
link: newAllowListEnabled ? '/api/allow-list' : '/settings/allow-list',
dataTestVal: 'allow-list-link',
title: 'Allow List',
});

View File

@ -1,36 +0,0 @@
import { Button } from '@/new-components/Button';
import React from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { QueryCollectionCreateDialog } from './QueryCollectionCreateDialog';
import { AllowListStatus } from './AllowListStatus';
export const AllowListSidebarHeader = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
return (
<>
{isCreateModalOpen && (
<QueryCollectionCreateDialog
onClose={() => setIsCreateModalOpen(false)}
/>
)}
<div className="flex items-center">
<span className="text-sm font-semibold text-muted uppercase tracking-wider">
Allow List
</span>
<div className="ml-1.5">
<AllowListStatus />
</div>
<div className="ml-auto">
<Button
icon={<FaFolderPlus />}
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
Add Collection
</Button>
</div>
</div>
</>
);
};

View File

@ -1,42 +0,0 @@
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { useQueryCollections } from '../../../QueryCollections/hooks/useQueryCollections';
import { QueryCollectionItem } from './QueryCollectionItem';
interface QueryCollectionItemProps {
selectedCollectionQuery: string;
search: string;
}
export const QueryCollectionList = ({
selectedCollectionQuery,
search,
}: QueryCollectionItemProps) => {
const { data: queryCollections, isLoading, isError } = useQueryCollections();
if (isError) {
return null; // TOOD: we're waiting for error state design
}
if (isLoading) {
return <Skeleton width={200} height={20} />;
}
const matchingQueryCollections = (queryCollections || []).filter(
({ name }) => !search || name.includes(search)
);
return (
<div className="-mt-2 mb-xs">
{queryCollections &&
matchingQueryCollections.map(({ name }) => (
<QueryCollectionItem
href="#"
key={name}
name={name}
selected={name === selectedCollectionQuery}
/>
))}
</div>
);
};

View File

@ -8,7 +8,7 @@ import {
import { handlers } from '../../hooks/AllowListPermissions/mock/handlers.mocks';
export default {
title: 'Features/Allow List Permission/CardedTable',
title: 'Features/Allow List/Allow List Permissions',
component: AllowListPermissions,
decorators: [ReactQueryDecorator()],
parameters: {

View File

@ -1,8 +1,16 @@
import React from 'react';
import { CgSpinner } from 'react-icons/cg';
import { Button } from '@/new-components/Button';
import { Switch } from '@/new-components/Switch';
import { FaCheck } from 'react-icons/fa';
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { FaCheck, FaPlusCircle } from 'react-icons/fa';
import { useSetRoleToAllowListPermission } from '../../hooks/AllowListPermissions/useSetRoleToAllowListPermissions';
import { useEnabledRolesFromAllowListState } from '../../hooks/AllowListPermissions/useEnabledRolesFromAllowListState';
import { useAddToAllowList } from '../../hooks/useAddToAllowList';
export interface AllowListPermissionsTabProps {
collectionName: string;
@ -11,37 +19,53 @@ export interface AllowListPermissionsTabProps {
export const AllowListPermissions: React.FC<AllowListPermissionsTabProps> = ({
collectionName,
}) => {
const {
allAvailableRoles,
newRoles,
setNewRoles,
enabledRoles,
setEnabledRoles,
} = useEnabledRolesFromAllowListState(collectionName);
const { allAvailableRoles, newRoles, setNewRoles, enabledRoles } =
useEnabledRolesFromAllowListState(collectionName);
const { data: isCollectionInAllowlist } = useMetadata(
MetadataSelector.isCollectionInAllowlist(collectionName)
);
const { setRoleToAllowListPermission } =
useSetRoleToAllowListPermission(collectionName);
const handleToggle = (roleName: string, index: number) => {
let newEnabledRoles = [];
const { addToAllowList, isLoading: addToAllowListLoading } =
useAddToAllowList();
const { fireNotification } = useFireNotification();
const [updatingRoles, setUpdatingRoles] = React.useState<string[]>([]);
const handleToggle = (roleName: string) => {
setUpdatingRoles([...updatingRoles, roleName]);
let newEnabledRoles: string[] = [];
// add roleName to enabledRoles, remove duplicates
if (enabledRoles.includes(roleName)) {
newEnabledRoles = Array.from(
new Set(enabledRoles.filter(role => role !== roleName))
);
setEnabledRoles(newEnabledRoles);
} else {
newEnabledRoles = Array.from(new Set([...enabledRoles, roleName]));
// remove enabled role from newRoles
setNewRoles(
newRoles.length > 1
? newRoles.filter(role => role !== newRoles[index])
: newRoles
);
setEnabledRoles(newEnabledRoles);
}
setRoleToAllowListPermission(newEnabledRoles);
setRoleToAllowListPermission(newEnabledRoles, {
onSuccess: () => {
fireNotification({
type: 'success',
message: 'Allow list permissions updated',
title: 'Success',
});
setUpdatingRoles(updatingRoles.filter(role => role !== roleName));
},
onError: e => {
fireNotification({
type: 'error',
message: `Error updating allow list permissions: ${e.message}`,
title: 'Error',
});
setUpdatingRoles(updatingRoles.filter(role => role !== roleName));
},
});
};
const handleNewRole = (value: string, index: number) => {
@ -60,9 +84,47 @@ export const AllowListPermissions: React.FC<AllowListPermissionsTabProps> = ({
setNewRoles(newAddedRoles);
};
if (!isCollectionInAllowlist) {
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="text-2xl font-bold text-gray-500">
This collection is not in the allowlist
</div>
<div className="text-gray-500">
Please add this collection to the allowlist to manage permissions
</div>
<Button
icon={<FaPlusCircle />}
mode="primary"
className="mt-4"
onClick={() => {
addToAllowList(collectionName, {
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Collection added to allowlist',
message: `Collection ${collectionName} has been added to the allowlist`,
});
},
onError: e => {
fireNotification({
type: 'error',
title: 'Collection not added to allowlist',
message: `Collection ${collectionName} could not be added to the allowlist: ${e.message}`,
});
},
});
}}
isLoading={addToAllowListLoading}
>
Add to allowlist
</Button>
</div>
);
}
return (
<>
<div className="p-md">
<div className="overflow-x-auto border border-gray-300 rounded mb-md">
<table className="min-w-full divide-y divide-gray-300 text-left">
<thead>
@ -86,7 +148,7 @@ export const AllowListPermissions: React.FC<AllowListPermissionsTabProps> = ({
<FaCheck className="text-green-600" />
</td>
</tr>
{allAvailableRoles.map((roleName, index) => (
{allAvailableRoles.map(roleName => (
<tr className="divide-x divide-gray-300">
<td className="w-0 bg-gray-50 p-sm font-semibold text-muted">
<div className="flex items-center">
@ -94,10 +156,14 @@ export const AllowListPermissions: React.FC<AllowListPermissionsTabProps> = ({
</div>
</td>
<td className="group relative text-center p-sm whitespace-nowrap cursor-pointer">
{updatingRoles.includes(roleName) ? (
<CgSpinner className={`animate-spin ${'w-5 h-5'}`} />
) : (
<Switch
checked={enabledRoles?.includes(roleName)}
onCheckedChange={() => handleToggle(roleName, index)}
checked={enabledRoles.includes(roleName)}
onCheckedChange={() => handleToggle(roleName)}
/>
)}
</td>
</tr>
))}
@ -113,22 +179,33 @@ export const AllowListPermissions: React.FC<AllowListPermissionsTabProps> = ({
onChange={e => handleNewRole(e.target.value, index)}
/>
</td>
<td className="group relative text-center p-sm whitespace-nowrap cursor-pointer">
<td className="group relative text-center p-sm whitespace-nowrap cursor-pointer flex items-center justify-center">
{updatingRoles.includes(newRole) ? (
<CgSpinner className={`animate-spin ${'w-5 h-5'}`} />
) : (
<Switch
checked={enabledRoles.includes(newRole)}
onCheckedChange={
newRole !== ''
? () => handleToggle(newRole, index)
: () => {}
newRole !== '' ? () => handleToggle(newRole) : () => {}
}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{enabledRoles.length === 0 && (
<IndicatorCard>
<p className="m-0">
The collection is in <span className="font-bold">global mode</span>:
all users are enabled. If you want to assign permissions in a more
granular way, enable specific roles. If you want to disable all
users, remove the collection from the allow list{' '}
</p>
</IndicatorCard>
)}
</>
);
};

View File

@ -0,0 +1 @@
export * from './AllowListPermissions';

View File

@ -3,6 +3,8 @@ import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AllowListSidebar } from './AllowListSidebar';
import { handlers } from '../../../QueryCollections/hooks/useQueryCollections/mocks/handlers.mock';
@ -19,5 +21,10 @@ export default {
} as ComponentMeta<typeof AllowListSidebar>;
export const Primary = () => (
<AllowListSidebar selectedCollectionQuery="allowed_queries" />
<AllowListSidebar
buildQueryCollectionHref={() => '#'}
onQueryCollectionClick={action('onQueryCollectionClick')}
onQueryCollectionCreate={action('onQueryCollectionCreate')}
selectedCollectionQuery="allowed-queries"
/>
);

View File

@ -8,10 +8,18 @@ import { AllowListSidebarSearchForm } from './AllowListSidebarSearchForm';
interface AllowListSidebarProps {
selectedCollectionQuery: string;
buildQueryCollectionHref: (name: string) => string;
onQueryCollectionClick: (url: string) => void;
onQueryCollectionCreate: (name: string) => void;
}
export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
const { selectedCollectionQuery } = props;
const {
selectedCollectionQuery,
buildQueryCollectionHref,
onQueryCollectionClick,
onQueryCollectionCreate,
} = props;
const [search, setSearch] = React.useState('');
const debouncedSearch = React.useMemo(() => debounce(setSearch, 300), []);
@ -22,11 +30,15 @@ export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
return (
<div>
<AllowListSidebarHeader />
<AllowListSidebarHeader
onQueryCollectionCreate={onQueryCollectionCreate}
/>
<AllowListSidebarSearchForm
setSearch={(searchString: string) => debouncedSearch(searchString)}
/>
<QueryCollectionList
buildHref={buildQueryCollectionHref}
onClick={onQueryCollectionClick}
selectedCollectionQuery={selectedCollectionQuery}
search={search}
/>
@ -34,7 +46,7 @@ export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
<IndicatorCard status="info">
<p>
Want to enable your allow list? You can set{' '}
<span className="text-red-600 font-mono bg-red-50 rounded px-1.5 py-0.5">
<span className="text-red-600 font-mono bg-red-50 rounded px-1.5 py-0.5 break-all">
HASURA_GRAPHQL_ENABLE_ALLOWLIST
</span>{' '}
to{' '}
@ -42,6 +54,13 @@ export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
true
</span>{' '}
so that your API will only allow accepted pre-selected operations.
<a
href="https://hasura.io/docs/latest/security/allow-list/#enable-allow-list"
target="_blank"
rel="noopener noreferrer"
>
<span className="italic font-thin text-sm pl-1">(Know More)</span>
</a>
</p>
</IndicatorCard>
)}

View File

@ -0,0 +1,48 @@
import { Button } from '@/new-components/Button';
import { useConsoleConfig } from '@/hooks/useEnvVars';
import React from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { QueryCollectionCreateDialog } from './QueryCollectionCreateDialog';
import { AllowListStatus } from './AllowListStatus';
interface AllowListSidebarHeaderProps {
onQueryCollectionCreate: (name: string) => void;
}
export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
const { onQueryCollectionCreate } = props;
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { type } = useConsoleConfig();
return (
<div className="pb-4">
{isCreateModalOpen && (
<QueryCollectionCreateDialog
onCreate={onQueryCollectionCreate}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
<div className="flex flex-col 2xl:flex-row">
<div className="flex items-center ">
<span className="text-sm font-semibold text-muted uppercase tracking-wider whitespace-nowrap">
Allow List
</span>
<div className="ml-1.5">
<AllowListStatus />
</div>
</div>
{type !== 'oss' && (
<div className="mt-2 2xl:mt-0 2xl:ml-auto">
<Button
icon={<FaFolderPlus />}
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
Add Collection
</Button>
</div>
)}
</div>
</div>
);
};

View File

@ -33,7 +33,11 @@ const SearchInput: React.FC<AllowListSidebarSearchFormProps> = ({
export const AllowListSidebarSearchForm: React.FC<AllowListSidebarSearchFormProps> =
({ setSearch }) => {
return (
<Form schema={schema} onSubmit={() => {}} className="p-0">
<Form
schema={schema}
onSubmit={() => {}}
className="pl-0 pr-0 !p-0 !bg-white"
>
{() => <SearchInput setSearch={setSearch} />}
</Form>
);

View File

@ -26,5 +26,5 @@ export const AllowListStatus = () => {
return <Badge color="green">Enabled</Badge>;
}
return <Badge color="red">disabled</Badge>;
return <Badge color="indigo">Disabled</Badge>;
};

View File

@ -2,10 +2,12 @@ import React from 'react';
import z from 'zod';
import { Dialog } from '@/new-components/Dialog';
import { Form, InputField } from '@/new-components/Form';
import { useFireNotification } from '@/new-components/Notifications';
import { useCreateQueryCollection } from '../../../QueryCollections/hooks/useCreateQueryCollection';
interface QueryCollectionCreateDialogProps {
onClose: () => void;
onCreate: (name: string) => void;
}
const schema = z.object({
@ -13,8 +15,9 @@ const schema = z.object({
});
export const QueryCollectionCreateDialog: React.FC<QueryCollectionCreateDialogProps> =
props => {
const { onClose } = props;
const { onClose, onCreate } = props;
const { createQueryCollection, isLoading } = useCreateQueryCollection();
const { fireNotification } = useFireNotification();
return (
<Form schema={schema} onSubmit={() => {}}>
@ -42,12 +45,23 @@ export const QueryCollectionCreateDialog: React.FC<QueryCollectionCreateDialogPr
addToAllowList: true,
onSuccess: () => {
onClose();
onCreate(name as string);
fireNotification({
type: 'success',
title: 'Collection created',
message: `Collection ${name} was created successfully`,
});
},
onError: error => {
setError('name', {
type: 'manual',
message: (error as Error).message,
});
fireNotification({
type: 'error',
title: 'Collection creation failed',
message: (error as Error).message,
});
},
});
}

View File

@ -12,12 +12,12 @@ export const QueryCollectionItem: React.FC<QueryCollectionItemProps> =
const { name, selected, className, ...rest } = props;
const Icon = selected ? FaFolderOpen : FaFolder;
const textClassName = selected
? 'text-amber-500 hover:text-amber-600'
: 'hover:bg-gray-100 hover:text-gray-900';
? 'text-amber-500 hover:text-amber-600 focus:text-amber-600'
: 'text-muted hover:bg-gray-100 hover:text-gray-900';
return (
<a
className={clsx(
`cursor-pointer flex items-center text-muted rounded py-1.5 px-sm`,
`cursor-pointer flex items-center rounded py-1.5 px-sm hover:no-underline focus:no-underline`,
textClassName,
className
)}

View File

@ -0,0 +1,70 @@
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { useQueryCollections } from '../../../QueryCollections/hooks/useQueryCollections';
import { QueryCollectionItem } from './QueryCollectionItem';
interface QueryCollectionItemProps {
selectedCollectionQuery: string;
search: string;
buildHref: (name: string) => string;
onClick: (url: string) => void;
}
export const QueryCollectionList = (props: QueryCollectionItemProps) => {
const { selectedCollectionQuery, search, buildHref, onClick } = props;
const { data: queryCollections, isLoading, isError } = useQueryCollections();
if (isError) {
return null; // TOOD: we're waiting for error state design
}
if (isLoading) {
return (
<div className="px-sm -mt-2 mb-xs">
<Skeleton width={200} height={20} />
</div>
);
}
const matchingQueryCollections = (queryCollections || []).filter(
({ name }) => !search || name?.toLowerCase().includes(search?.toLowerCase())
);
if (
search &&
queryCollections.length > 0 &&
matchingQueryCollections.length === 0
) {
return (
<div className="px-sm -mt-2 mb-xs">
<p className="text-gray-500">No results found</p>
</div>
);
}
return (
<div>
<div className="px-sm -ml-3 mb-xs">
<p className="text-sm font-semibold text-muted uppercase tracking-wider uppercase">
Collections
</p>
</div>
<div className="-mt-2 mb-xs">
{queryCollections &&
matchingQueryCollections.map(({ name }) => (
<QueryCollectionItem
href={buildHref(name)}
onClick={e => {
onClick(buildHref(name));
e.preventDefault();
}}
key={name}
name={name}
selected={name === selectedCollectionQuery}
/>
))}
</div>
</div>
);
};

View File

@ -1,24 +1,16 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useRoles } from '@/features/MetadataAPI';
import { useEnabledRolesFromAllowList } from './useEnabledRolesFromAllowList';
export const useEnabledRolesFromAllowListState = (collectionName: string) => {
const { data: allAvailableRoles } = useRoles();
const { data: allowListRoles } = useEnabledRolesFromAllowList(collectionName);
const [enabledRoles, setEnabledRoles] = React.useState<string[]>([]);
const { data: enabledRoles } = useEnabledRolesFromAllowList(collectionName);
const [newRoles, setNewRoles] = React.useState<string[]>(['']);
useEffect(() => {
if (allowListRoles && allowListRoles !== enabledRoles) {
setEnabledRoles(allowListRoles);
}
}, [allowListRoles]);
return {
allAvailableRoles,
newRoles,
enabledRoles: enabledRoles || [],
newRoles: newRoles.filter(role => !allAvailableRoles.includes(role)),
setNewRoles,
enabledRoles,
setEnabledRoles,
};
};

View File

@ -1,91 +1,35 @@
import {
REQUEST_SUCCESS,
updateSchemaInfo,
} from '@/components/Services/Data/DataActions';
import {
allowedMetadataTypes,
useMetadata,
useMetadataMigration,
} from '@/features/MetadataAPI';
import { useDispatch } from 'react-redux';
import { AnyAction } from 'redux';
import { ReduxState } from '@/types';
import { ThunkDispatch } from 'redux-thunk';
import { useFireNotification } from '@/new-components/Notifications';
export const useSetRoleToAllowListPermission = (collectionName: string) => {
const { fireNotification } = useFireNotification();
const { data: metadata } = useMetadata();
const dispatch: ThunkDispatch<ReduxState, unknown, AnyAction> = useDispatch();
const { mutate } = useMetadataMigration();
// to check if collection is present in allowList
const isAllowList = metadata?.metadata.allowlist?.find(
qs => qs.collection === collectionName
);
const mutation = useMetadataMigration({
onSuccess: () => {
dispatch({ type: REQUEST_SUCCESS });
dispatch(updateSchemaInfo()).then(() => {
fireNotification({
title: 'Success!',
message: 'Permission added',
type: 'success',
});
});
},
onError: (error: Error) => {
fireNotification({
title: 'Error',
message: error?.message ?? 'Error while adding permission',
type: 'error',
});
},
});
const setRoleToAllowListPermission = (roles: string[]): void => {
// drop collection from allow list if we are disabling the last role
if (roles.length === 0) {
const type: allowedMetadataTypes = 'drop_collection_from_allowlist';
mutation.mutate({
query: {
type,
args: {
collection: collectionName,
},
},
});
} else if (!isAllowList) {
// add collection to allow list and add roles if we are enabling the 1st role
const type: allowedMetadataTypes = 'add_collection_to_allowlist';
mutation.mutate({
query: {
type,
args: {
collection: collectionName,
scope: {
global: false,
roles,
},
},
},
});
} else {
const setRoleToAllowListPermission = (
roles: string[],
options?: Parameters<typeof mutate>[1]
): void => {
const type: allowedMetadataTypes =
'update_scope_of_collection_in_allowlist';
mutation.mutate({
mutate(
{
query: {
type,
args: {
collection: collectionName,
scope: {
scope:
roles.length === 0
? { global: true }
: {
global: false,
roles,
},
},
},
});
}
},
options
);
};
return { setRoleToAllowListPermission };

View File

@ -11,7 +11,7 @@ export const handlers = (delay = 0, url = baseUrl) => [
if (
body.type !== 'add_collection_to_allowlist' ||
body.args.collection !== 'allowed_queries'
body.args.collection !== 'allowed-queries'
) {
return res(
ctx.delay(delay),

View File

@ -41,7 +41,7 @@ export default {
},
argTypes: {
collectionName: {
defaultValue: 'allowed_queries',
defaultValue: 'allowed-queries',
description: 'The name of the query collection to add to the allow list',
control: {
type: 'text',

View File

@ -20,7 +20,7 @@ describe('useAddToAllowList', () => {
{ wrapper }
);
await result.current.addToAllowList('allowed_queries');
await result.current.addToAllowList('allowed-queries');
await waitForValueToChange(() => result.current.isSuccess);
expect(result.current.isSuccess).toBe(true);

View File

@ -11,7 +11,7 @@ export const handlers = (delay = 0, url = baseUrl) => [
if (
body.type !== 'drop_collection_from_allowlist' ||
body.args.collection !== 'allowed_queries'
body.args.collection !== 'allowed-queries'
) {
return res(
ctx.delay(delay),

View File

@ -44,7 +44,7 @@ export default {
},
argTypes: {
collectionName: {
defaultValue: 'allowed_queries',
defaultValue: 'allowed-queries',
description:
'The name of the query collection to remove from the allow list',
control: {

View File

@ -20,7 +20,7 @@ describe('useRemoveFromAllowList', () => {
{ wrapper }
);
await result.current.removeFromAllowList('allowed_queries');
await result.current.removeFromAllowList('allowed-queries');
await waitForValueToChange(() => result.current.isSuccess);
expect(result.current.isSuccess).toBe(true);

View File

@ -1,2 +1,4 @@
export { useAddToAllowList } from './hooks/useAddToAllowList';
export { useRemoveFromAllowList } from './hooks/useRemoveFromAllowList';
export { AllowListSidebar } from './components/AllowListSidebar';
export { AllowListPermissions } from './components/AllowListPermissions';

View File

@ -2,10 +2,12 @@ import { FeatureFlagDefinition } from './types';
const relationshipTabTablesId = '0bea35ff-d3e9-45e9-af1b-59923bf82fa9';
const gdcId = '88436c32-2798-11ed-a261-0242ac120002';
const allowListId = '3a042e0c-e0d4-46b6-8c87-01f2ed9f63b0';
export const availableFeatureFlagIds = {
relationshipTabTablesId,
gdcId,
allowListId,
};
export const availableFeatureFlags: FeatureFlagDefinition[] = [
@ -19,6 +21,16 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
defaultValue: false,
discussionUrl: '',
},
{
id: allowListId,
title: 'New Allow List Manager',
description:
'Try out the new UI for the Allow List management in the API section.',
section: 'api',
status: 'alpha',
defaultValue: false,
discussionUrl: '',
},
{
id: gdcId,
title: 'Experimental features for GDC',

View File

@ -285,4 +285,14 @@ export namespace MetadataSelector {
? queryCollectionDefinition?.scope?.roles
: [];
};
export const isCollectionInAllowlist =
(collectionName: string) =>
(m: MetadataResponse): boolean => {
return (
m.metadata?.allowlist?.find(
entry => entry?.collection === collectionName
) !== undefined
);
};
}

View File

@ -1,137 +0,0 @@
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import {
useAddToAllowList,
useRemoveFromAllowList,
} from '@/features/AllowLists';
import { useMetadata } from '@/features/MetadataAPI';
import { QueryCollectionEntry } from '@/metadata/types';
import { Button } from '@/new-components/Button';
import { DropdownMenu } from '@/new-components/DropdownMenu';
import React, { useState } from 'react';
import { FaEllipsisH, FaPlusCircle } from 'react-icons/fa';
import { useDeleteQueryCollections } from '../hooks/useDeleteQueryCollections';
import { QueryCollectionRenameDialog } from './QueryCollectionRenameDialog';
interface QueryCollectionHeaderProps {
queryCollection: QueryCollectionEntry;
}
export const QueryCollectionHeader: React.FC<QueryCollectionHeaderProps> =
props => {
const { queryCollection } = props;
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const { deleteQueryCollection, isLoading: deleteLoading } =
useDeleteQueryCollections();
const { addToAllowList, isLoading: addLoading } = useAddToAllowList();
const { removeFromAllowList, isLoading: removeLoding } =
useRemoveFromAllowList();
const { data: metadata } = useMetadata();
return (
<>
{isRenameModalOpen && (
<QueryCollectionRenameDialog
currentName={queryCollection.name}
onClose={() => {
setIsRenameModalOpen(false);
}}
/>
)}
<div className="flex items-center mb-xs">
<div>
<h1 className="text-xl font-semibold">{queryCollection.name}</h1>
<p className="text-muted">
Add queries to the collection to create a safe list of operations
which can be run against your GraphQL API.
</p>
</div>
<div className="relative ml-auto mr-sm">
<DropdownMenu
items={[
[
<div
className="font-semibold"
onClick={() => {
// this is a workaround for a weird but caused by interaction of radix ui dialog and dropdown menu
setTimeout(() => {
setIsRenameModalOpen(true);
}, 0);
}}
>
Edit Collection Name
</div>,
metadata?.metadata.allowlist?.find(
entry => entry.collection === queryCollection.name
) ? (
<div
className="font-semibold"
onClick={() => {
removeFromAllowList(queryCollection.name, {
onSuccess: () => {
// fire notification
},
onError: () => {
// fire notification
},
});
}}
>
Remove from Allow List
</div>
) : (
<div
className="font-semibold"
onClick={() => {
addToAllowList(queryCollection.name, {
onSuccess: () => {
// fire notification
},
onError: () => {
// fire notification
},
});
}}
>
Add to Allow List
</div>
),
],
[
<div
className="font-semibold text-red-600"
onClick={() => {
const confirmMessage = `This will permanently delete the query collection "${queryCollection.name}"`;
const isOk = getConfirmation(
confirmMessage,
true,
queryCollection.name
);
if (isOk) {
deleteQueryCollection(queryCollection.name, {
onSuccess: () => {
// fire notification
},
onError: () => {
// fire notification
},
});
}
}}
>
Delete Collection
</div>,
],
]}
>
<Button isLoading={deleteLoading || addLoading || removeLoding}>
<FaEllipsisH />
</Button>
</DropdownMenu>
</div>
<Button mode="primary" icon={<FaPlusCircle />}>
Add Operation
</Button>
</div>
</>
);
};

View File

@ -2,10 +2,11 @@ import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { QueryCollectionHeader } from './QueryCollectionHeader';
import { handlers } from '../hooks/useQueryCollections/mocks/handlers.mock';
import { createMetadata } from '../hooks/useQueryCollections/mocks/mockData';
import { handlers } from '../../hooks/useQueryCollections/mocks/handlers.mock';
import { createMetadata } from '../../hooks/useQueryCollections/mocks/mockData';
export default {
title: 'Features/Query Collections/Query Collection Header',
@ -24,6 +25,8 @@ export const Primary = () => {
return (
metadata.metadata.query_collections?.[0] && (
<QueryCollectionHeader
onDelete={action('delete')}
onRename={action('rename')}
queryCollection={metadata.metadata.query_collections?.[0]}
/>
)

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { FaPlusCircle } from 'react-icons/fa';
import { QueryCollectionEntry } from '@/metadata/types';
import { Button } from '@/new-components/Button';
import { QueryCollectionRenameDialog } from './QueryCollectionRenameDialog';
import { QueryCollectionHeaderMenu } from './QueryCollectionHeaderMenu';
import { QueryCollectionOperationAdd } from '../QueryCollectionOperationDialog/QueryCollectionOperationAdd';
interface QueryCollectionHeaderProps {
queryCollection: QueryCollectionEntry;
onDelete: (name: string) => void;
onRename: (name: string, newName: string) => void;
}
export const QueryCollectionHeader: React.FC<QueryCollectionHeaderProps> =
props => {
const { queryCollection, onDelete, onRename } = props;
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
return (
<>
{isRenameModalOpen && (
<QueryCollectionRenameDialog
currentName={queryCollection.name}
onClose={() => {
setIsRenameModalOpen(false);
}}
onRename={onRename}
/>
)}
{isAddModalOpen && (
<QueryCollectionOperationAdd
queryCollectionName={queryCollection.name}
onClose={() => {
setIsAddModalOpen(false);
}}
/>
)}
<div className="flex items-center mb-sm">
<div>
<h1 className="text-xl font-semibold">{queryCollection.name}</h1>
<p className="text-muted m-0">
Add queries to the collection to create a safe list of operations
which can be run against your GraphQL API.
</p>
</div>
<div className="relative ml-auto mr-sm">
<QueryCollectionHeaderMenu
queryCollection={queryCollection}
onDelete={onDelete}
onRename={onRename}
setIsRenameModalOpen={setIsRenameModalOpen}
/>
</div>
<Button
mode="primary"
icon={<FaPlusCircle />}
onClick={() => setIsAddModalOpen(true)}
>
Add Operation
</Button>
</div>
</>
);
};

View File

@ -0,0 +1,152 @@
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import {
useAddToAllowList,
useRemoveFromAllowList,
} from '@/features/AllowLists';
import { useMetadata } from '@/features/MetadataAPI';
import { QueryCollectionEntry } from '@/metadata/types';
import { Button } from '@/new-components/Button';
import { DropdownMenu } from '@/new-components/DropdownMenu';
import { Tooltip } from '@/new-components/Tooltip';
import { useFireNotification } from '@/new-components/Notifications';
import React from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import { useDeleteQueryCollections } from '../../hooks/useDeleteQueryCollections';
interface QueryCollectionHeaderMenuProps {
queryCollection: QueryCollectionEntry;
onDelete: (name: string) => void;
onRename: (name: string, newName: string) => void;
setIsRenameModalOpen: (isRenameModalOpen: boolean) => void;
}
export const QueryCollectionHeaderMenu: React.FC<QueryCollectionHeaderMenuProps> =
props => {
const { queryCollection, onDelete, setIsRenameModalOpen } = props;
const { deleteQueryCollection, isLoading: deleteLoading } =
useDeleteQueryCollections();
const { fireNotification } = useFireNotification();
const { addToAllowList, isLoading: addLoading } = useAddToAllowList();
const { removeFromAllowList, isLoading: removeLoding } =
useRemoveFromAllowList();
const { data: metadata } = useMetadata();
return queryCollection.name !== 'allowed-queries' ? (
<DropdownMenu
items={[
[
<div
className="font-semibold"
onClick={() => {
// this is a workaround for a weird but caused by interaction of radix ui dialog and dropdown menu
setTimeout(() => {
setIsRenameModalOpen(true);
}, 0);
}}
>
Edit Collection Name
</div>,
metadata?.metadata.allowlist?.find(
entry => entry.collection === queryCollection.name
) ? (
<div
className="font-semibold"
onClick={() => {
removeFromAllowList(queryCollection.name, {
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Success',
message: `Removed ${queryCollection.name} from allow list`,
});
},
onError: () => {
fireNotification({
type: 'error',
title: 'Error',
message: `Failed to remove ${queryCollection.name} from allow list`,
});
},
});
}}
>
Remove from Allow List
</div>
) : (
<div
className="font-semibold"
onClick={() => {
addToAllowList(queryCollection.name, {
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Success',
message: `Added ${queryCollection.name} to allow list`,
});
},
onError: () => {
fireNotification({
type: 'error',
title: 'Error',
message: `Failed to add ${queryCollection.name} to allow list`,
});
},
});
}}
>
Add to Allow List
</div>
),
],
[
<div
className="font-semibold text-red-600"
onClick={() => {
const confirmMessage = `This will permanently delete the query collection "${queryCollection.name}"`;
const isOk = getConfirmation(
confirmMessage,
true,
queryCollection.name
);
if (isOk) {
deleteQueryCollection(queryCollection.name, {
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Collection deleted',
message: `Query collection "${queryCollection.name}" deleted successfully`,
});
onDelete(queryCollection.name);
},
onError: () => {
fireNotification({
type: 'error',
title: 'Error deleting collection',
message: `Error deleting query collection "${queryCollection.name}"`,
});
},
});
}
}}
>
Delete Collection
</div>,
],
]}
>
<Button isLoading={deleteLoading || addLoading || removeLoding}>
<FaEllipsisH />
</Button>
</DropdownMenu>
) : (
<Tooltip
tooltipContentChildren="You cannot rename or delete the default allowed-queries
collection"
>
<Button disabled>
<FaEllipsisH />
</Button>
</Tooltip>
);
};

View File

@ -2,11 +2,13 @@ import React from 'react';
import z from 'zod';
import { Dialog } from '@/new-components/Dialog';
import { Form, InputField } from '@/new-components/Form';
import { useRenameQueryCollection } from '../hooks/useRenameQueryCollection';
import { useFireNotification } from '@/new-components/Notifications';
import { useRenameQueryCollection } from '../../../QueryCollections/hooks/useRenameQueryCollection';
interface QueryCollectionCreateDialogProps {
onClose: () => void;
currentName: string;
onRename: (currentName: string, newName: string) => void;
}
const schema = z.object({
@ -14,8 +16,9 @@ const schema = z.object({
});
export const QueryCollectionRenameDialog: React.FC<QueryCollectionCreateDialogProps> =
props => {
const { onClose, currentName } = props;
const { onClose, currentName, onRename } = props;
const { renameQueryCollection, isLoading } = useRenameQueryCollection();
const { fireNotification } = useFireNotification();
return (
<Form schema={schema} onSubmit={() => {}}>
@ -42,12 +45,23 @@ export const QueryCollectionRenameDialog: React.FC<QueryCollectionCreateDialogPr
renameQueryCollection(currentName, name as string, {
onSuccess: () => {
onClose();
onRename(currentName, name as string);
fireNotification({
type: 'success',
title: 'Collection renamed',
message: `Collection ${currentName} was renamed to ${name}`,
});
},
onError: error => {
setError('name', {
type: 'manual',
message: (error as Error).message,
});
fireNotification({
type: 'error',
title: 'Error renaming collection',
message: (error as Error).message,
});
},
});
}

View File

@ -1,4 +1,6 @@
import React from 'react';
import { useFireNotification } from '@/new-components/Notifications';
import { useAddOperationsToQueryCollection } from '../../hooks';
import { QueryCollectionOperationDialog } from './QueryCollectionOperationDialog';
@ -12,10 +14,11 @@ export const QueryCollectionOperationAdd = (
const { onClose, queryCollectionName } = props;
const { addOperationToQueryCollection, isLoading } =
useAddOperationsToQueryCollection();
const { fireNotification } = useFireNotification();
return (
<QueryCollectionOperationDialog
title="Add Operation"
callToAction="Add operation"
callToAction="Add Operation"
isLoading={isLoading}
onSubmit={values => {
if (values.option === 'write operation') {
@ -23,11 +26,20 @@ export const QueryCollectionOperationAdd = (
queryCollectionName,
[{ name: values.name, query: values.query }],
{
onError: () => {
// TODO: show global error notification
onError: e => {
fireNotification({
type: 'error',
title: 'Error',
message: `Failed to add operation to query collection: ${e.message}`,
});
},
onSuccess: () => {
// TODO: show global success notification
fireNotification({
type: 'success',
title: 'Success',
message: `Successfully added operation to query collection`,
});
onClose();
},
}
);
@ -36,11 +48,20 @@ export const QueryCollectionOperationAdd = (
}
addOperationToQueryCollection(queryCollectionName, values.gqlFile, {
onError: () => {
// TODO: show global error notification
onError: e => {
fireNotification({
type: 'error',
title: 'Error',
message: `Failed to add operation to query collection: ${e.message}`,
});
},
onSuccess: () => {
// TODO: show global success notification
fireNotification({
type: 'success',
title: 'Success',
message: `Successfully added operation to query collection`,
});
onClose();
},
});
}}

View File

@ -82,6 +82,7 @@ export const QueryCollectionOperationDialog = (
size="full"
id="name"
name="name"
className="max-w-full"
label="Operation Name"
/>
<CodeEditorField

View File

@ -1,5 +1,7 @@
import { QueryCollection } from '@/metadata/types';
import React from 'react';
import { QueryCollection } from '@/metadata/types';
import { useFireNotification } from '@/new-components/Notifications';
import { useEditOperationInQueryCollection } from '../../hooks/useEditOperationInQueryCollection';
import { QueryCollectionOperationDialog } from './QueryCollectionOperationDialog';
@ -14,6 +16,7 @@ export const QueryCollectionOperationEdit = (
const { onClose, operation, queryCollectionName } = props;
const { isLoading, editOperationInQueryCollection } =
useEditOperationInQueryCollection();
const { fireNotification } = useFireNotification();
return (
<QueryCollectionOperationDialog
title="Edit Operation"
@ -29,11 +32,20 @@ export const QueryCollectionOperationEdit = (
query: values.query,
},
{
onError: () => {
// TODO: show global error notification
onError: e => {
fireNotification({
type: 'error',
title: 'Error',
message: `Failed to edit operation in query collection: ${e.message}`,
});
},
onSuccess: () => {
// TODO: show global success notification
fireNotification({
type: 'success',
title: 'Success',
message: `Successfully edited operation in query collection`,
});
onClose();
},
}
);

View File

@ -2,7 +2,6 @@ import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ReduxDecorator } from '@/storybook/decorators/redux-decorator';
import { ComponentMeta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { within, userEvent, screen } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { waitForElementToBeRemoved } from '@testing-library/react';
@ -23,12 +22,7 @@ export default {
} as ComponentMeta<typeof QueryCollectionsOperations>;
export const Primary: Story = () => {
return (
<QueryCollectionsOperations
onEdit={action('onEdit')}
collectionName="allowed-queries"
/>
);
return <QueryCollectionsOperations collectionName="allowed-queries" />;
};
Primary.play = async ({ canvasElement }) => {

View File

@ -6,11 +6,14 @@ import { CardedTable } from '@/new-components/CardedTable';
import { QueryCollection } from '@/metadata/types';
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { useFireNotification } from '@/new-components/Notifications';
import { useOperationsFromQueryCollection } from '../../hooks/useOperationsFromQueryCollection';
import { useRemoveOperationsFromQueryCollection } from '../../hooks';
import { QueryCollectionsOperationsHeader } from './QueryCollectionOperationsHeader';
import { QueryCollectionOperationsEmptyState } from './QueryCollectionOperationsEmptyState';
import { QueryCollectionOperationEdit } from '../QueryCollectionOperationDialog/QueryCollectionOperationEdit';
const Check: React.FC<React.ComponentProps<'input'>> = props => (
<input
@ -22,12 +25,11 @@ const Check: React.FC<React.ComponentProps<'input'>> = props => (
interface QueryCollectionsOperationsProps {
collectionName: string;
onEdit: (operation: string) => void;
}
export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProps> =
props => {
const { collectionName, onEdit } = props;
const { collectionName } = props;
const {
data: operations,
@ -38,8 +40,13 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
const { removeOperationsFromQueryCollection, isLoading: deleteLoading } =
useRemoveOperationsFromQueryCollection();
const { fireNotification } = useFireNotification();
const [search, setSearch] = React.useState('');
const [editingOperation, setEditingOperation] =
React.useState<QueryCollection | null>(null);
const [selectedOperations, setSelectedOperations] = React.useState<
QueryCollection[]
>([]);
@ -60,8 +67,12 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
);
}
if (!operations || operations.length === 0) {
return <QueryCollectionOperationsEmptyState />;
}
const filteredOperations = (operations || []).filter(operation =>
operation.name.toLowerCase().includes(search)
operation.name?.toLowerCase().includes(search?.toLowerCase())
);
const data = filteredOperations?.map(operation => [
@ -77,13 +88,15 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
}}
/>,
operation.name,
operation.query.toLowerCase().includes('mutation') ? 'Mutation' : 'Query',
operation.query.toLowerCase().startsWith('mutation')
? 'Mutation'
: 'Query',
<div className="flex justify-end">
<Button
className="mr-1.5"
size="sm"
onClick={() => onEdit(operation.name)}
onClick={() => setEditingOperation(operation)}
>
Edit
</Button>
@ -93,7 +106,22 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
const confirmMessage = `This will permanently delete the operation "${operation.name}"`;
const isOk = getConfirmation(confirmMessage, true, operation.name);
if (isOk) {
removeOperationsFromQueryCollection(collectionName, [operation]);
removeOperationsFromQueryCollection(collectionName, [operation], {
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Operation deleted',
message: `Operation "${operation.name}" was deleted successfully`,
});
},
onError: e => {
fireNotification({
type: 'error',
title: 'Failed to delete operation',
message: `Failed to delete operation "${operation.name}": ${e.message}`,
});
},
});
}
}}
className="mr-1.5"
@ -108,22 +136,33 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
return (
<div>
<QueryCollectionsOperationsHeader
selectedOperations={selectedOperations}
onSearch={searchString => {
setSearch(searchString);
{editingOperation && (
<QueryCollectionOperationEdit
queryCollectionName={collectionName}
operation={editingOperation}
onClose={() => {
setEditingOperation(null);
setSelectedOperations([]);
}}
/>
)}
<QueryCollectionsOperationsHeader
selectedOperations={selectedOperations}
onSearch={setSearch}
setSelectedOperations={setSelectedOperations}
collectionName={collectionName}
/>
<div>
{search && filteredOperations.length === 0 ? (
<div className="text-center text-gray-500">No operations found</div>
) : (
<CardedTable
columns={[
<Check
data-testid="query-collections-select-all"
checked={
selectedOperations.length === filteredOperations.length
}
checked={filteredOperations.every(operation =>
selectedOperations.includes(operation)
)}
onClick={() =>
setSelectedOperations(
selectedOperations.length === 0 ? filteredOperations : []
@ -136,6 +175,7 @@ export const QueryCollectionsOperations: React.FC<QueryCollectionsOperationsProp
]}
data={data}
/>
)}
</div>
</div>
);

View File

@ -0,0 +1,163 @@
import React from 'react';
import {
FaBolt,
FaCogs,
FaDatabase,
FaLink,
FaLock,
FaMobileAlt,
FaPlug,
FaProjectDiagram,
FaServer,
} from 'react-icons/fa';
export const QueryCollectionOperationsEmptyState = () => {
return (
<div className="my-sm relative">
<div className="flex w-max bg-gray-200 border border-gray-300 rounded pt-8 p-md">
<div className="flex items-center relative pr-6">
<span className="absolute -top-3.5 left-0 text-sm font-semibold text-gray-400 uppercase tracking-wider">
Sources
</span>
<div className="absolute h-full right-0 border-r border-gray-400" />
<div className="absolute w-4 border-t border-gray-400 top-0 right-0" />
<div className="absolute w-4 border-b border-gray-400 bottom-0 right-0" />
<div className="space-y-sm py-sm">
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaCogs className="mr-1.5" />
<span>REST Endpoints</span>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaDatabase className="mr-1.5" />
<span>Database</span>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaPlug className="mr-1.5" />
<span>GraphQL Services</span>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center">
<div className="w-4 border-t border-gray-400" />
<span className="flex items-center px-xs text-sm text-gray-400">
<FaLink className="mr-1.5" />
Connected
</span>
<div className="w-4 border-t border-gray-400" />
<svg
className="text-gray-400 w-4 h-full -ml-1.5"
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 16 16"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.56 14L5 13.587V2.393L5.54 2 11 7.627v.827L5.56 14z" />
</svg>
</div>
<div className="mt-20">
<div className="bg-white rounded shadow w-48 hover:shadow-md">
<div className="relative p-sm flex items-center">
<div className="absolute -mt-[8px] top-1/2 -right-[6px] animate-ping rounded-full w-6 h-6 bg-amber-500 z-10" />
<div className="absolute flex items-center justify-center -mt-[8px] top-1/2 -right-[6px] rounded-full w-6 h-6 bg-amber-500 z-10">
<FaLock color="white" className="w-2 h-2" />
</div>
<img
alt="hasura"
className="w-3 h-3 mr-1.5"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODEiIGhlaWdodD0iODQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsPSIjMDAwIj48cGF0aCBkPSJNNzkuNzE5IDI4LjYwMmMyLjQwMy03LjUyOS45NTktMjIuNTY2LTMuNzAzLTI4LjExNC0uNjA5LS43MjYtMS43NTQtLjYyMi0yLjI1OS4xNzZsLTUuNzQ1IDkuMDY0YTQuNDAzIDQuNDAzIDAgMCAxLTUuOS45NjQgMzkuMzI0IDM5LjMyNCAwIDAgMC0yMS42OC02LjQ4MSAzOS4zMjQgMzkuMzI0IDAgMCAwLTIxLjY4IDYuNDgxIDQuNDE0IDQuNDE0IDAgMCAxLTUuOS0uOTY0TDcuMTA3LjY2NEM2LjYwMi0uMTM0IDUuNDU3LS4yMzggNC44NS40ODguMTg3IDYuMDM2LTEuMjU3IDIxLjA3MyAxLjE0NiAyOC42MDJjLjc5NCAyLjUgMS4wMiA1LjE0NC41NDYgNy43MjYtLjQ2NCAyLjU1MS0uOTM4IDUuNjQxLS45MzggNy43NzhDLjc1NCA2Ni4xMzIgMTguNTI1IDg0IDQwLjQzMiA4NCA2Mi4zNSA4NCA4MC4xMSA2Ni4xNDIgODAuMTEgNDQuMTA1YzAtMi4xNDctLjQ2NC01LjIyNy0uOTM4LTcuNzc4LS40NzQtMi41ODItLjI0OC01LjIyNy41NDctNy43MjZabS0zOS4yODcgNDYuNDhjLTE2LjkzNiAwLTMwLjcxNS0xMy44NTUtMzAuNzE1LTMwLjg4MyAwLS41Ni4wMi0xLjExLjA0MS0xLjY2LjYxOS0xMS42MDQgNy42MjItMjEuNTI4IDE3LjU0NC0yNi4yNTdhMzAuMyAzMC4zIDAgMCAxIDEzLjEzLTIuOTY2YzQuNjkzIDAgOS4xNDkgMS4wNjggMTMuMTQgMi45NzYgOS45MjIgNC43MyAxNi45MjYgMTQuNjU0IDE3LjU0NSAyNi4yNDguMDMuNTUuMDQgMS4wOTkuMDQgMS42NTktLjAxIDE3LjAyOC0xMy43OSAzMC44ODMtMzAuNzI1IDMwLjg4M1oiLz48cGF0aCBkPSJtNTMuNzM3IDU2LjA4My03Ljg0OS0xMy42NzgtNi43MzUtMTEuNDA4YS44NzQuODc0IDAgMCAwLS43NjMtLjQzNmgtNi40MzZBLjg4My44ODMgMCAwIDAgMzEuMiAzMS45bDYuNDM2IDEwLjg5LTguNjQzIDEzLjI1MmEuOTAzLjkwMyAwIDAgMC0uMDQyLjkxM2MuMTU1LjI5LjQ1NC40NjcuNzc0LjQ2N2g2LjQ3N2MuMyAwIC41NzgtLjE1Ni43NDMtLjQwNWw0LjY3Mi03LjM0MiA0LjE4OCA3LjNjLjE1NC4yOC40NTMuNDQ3Ljc2My40NDdoNi4zODRjLjMyIDAgLjYwOS0uMTY2Ljc2NC0uNDQ2YS44MjIuODIyIDAgMCAwIC4wMi0uODkyWiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBoODF2ODRIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4="
/>
<span className="font-semibold">Hasura</span>
</div>
<div className="relative">
<span className="flex items-center border-t border-gray-200 bg-gray-100 text-sm rounded-b py-1.5 px-sm">
<FaLock className="mr-1.5" />
<span>Allow List Operations</span>
</span>
</div>
</div>
</div>
<div className="flex items-center">
<div className="w-4 border-t border-gray-400" />
<svg
className="text-gray-400 w-4 h-full -ml-1.5"
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 16 16"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.56 14L5 13.587V2.393L5.54 2 11 7.627v.827L5.56 14z" />
</svg>
<span className="flex items-center px-xs text-sm text-gray-400">
<FaBolt className="mr-1.5" />
Powering
</span>
<div className="w-4 border-t border-gray-400" />
</div>
<div className="flex items-center relative pl-6">
<span className="absolute -top-3.5 right-0 text-sm font-semibold text-gray-400 uppercase tracking-wider">
Consumers
</span>
<div className="absolute h-full left-0 border-r border-gray-400" />
<div className="absolute w-4 border-t border-gray-400 top-0 left-0" />
<div className="absolute w-4 border-b border-gray-400 bottom-0 left-0" />
<div className="space-y-sm py-sm">
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaMobileAlt className="mr-1.5" />
<span>Apps</span>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaServer className="mr-1.5" />
<span>Data Platforms</span>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<div className="relative bg-transparent rounded border border-gray-400 text-gray-400 overflow-hidden p-sm w-48">
<div className="flex items-center">
<FaProjectDiagram className="mr-1.5" />
<span>Other Services</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -5,6 +5,7 @@ import { Button } from '@/new-components/Button';
import { DropdownMenu } from '@/new-components/DropdownMenu';
import { QueryCollection } from '@/metadata/types';
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import { useFireNotification } from '@/new-components/Notifications';
import { QueryCollectionsOperationsSearchForm } from './QueryCollectionOperationsSearchForm';
import { useQueryCollections } from '../../hooks/useQueryCollections';
@ -17,12 +18,18 @@ import {
interface QueryCollectionsOperationsHeaderProps {
collectionName: string;
selectedOperations: QueryCollection[];
setSelectedOperations: (operations: QueryCollection[]) => void;
onSearch: (search: string) => void;
}
export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperationsHeaderProps> =
props => {
const { collectionName, selectedOperations, onSearch } = props;
const {
collectionName,
selectedOperations,
onSearch,
setSelectedOperations,
} = props;
const { data: queryCollections } = useQueryCollections();
const { addOperationToQueryCollection, isLoading: addLoading } =
@ -32,6 +39,8 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
const { removeOperationsFromQueryCollection, isLoading: deleteLoading } =
useRemoveOperationsFromQueryCollection();
const { fireNotification } = useFireNotification();
const otherCollections = (queryCollections || []).filter(
c => c.name !== collectionName
);
@ -44,9 +53,9 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
className="flex items-center"
data-testid="selected-operations-controls"
>
<p className="text-sm text-muted mr-1.5">
<span className="text-sm text-muted mr-1.5">
{selectedOperations.length} Operations:
</p>
</span>
{otherCollections?.length > 0 && (
<>
<DropdownMenu
@ -59,11 +68,20 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
collection.name,
selectedOperations,
{
onError: () => {
// TODO: global notifications
onError: e => {
fireNotification({
type: 'error',
title: 'Failed to move operations',
message: `Failed to move operations to collection ${collection.name}: ${e.message}`,
});
},
onSuccess: () => {
// TODO: global notifications
fireNotification({
type: 'success',
title: 'Operations moved',
message: `Successfully moved ${selectedOperations.length} operations to ${collection.name}`,
});
setSelectedOperations([]);
},
}
)
@ -93,11 +111,19 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
collection.name,
selectedOperations,
{
onError: () => {
// TODO: global notifications
onError: e => {
fireNotification({
type: 'error',
title: 'Failed to add operations',
message: `Failed to add operations to collection ${collection.name}: ${e.message}`,
});
},
onSuccess: () => {
// TODO: global notifications
fireNotification({
type: 'success',
title: 'Operations added',
message: `Successfully added ${selectedOperations.length} operations to ${collection.name}`,
});
},
}
)
@ -110,7 +136,7 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
>
<Button
className="mr-1.5"
size="sm"
size="md"
icon={<FaRegCopy />}
isLoading={addLoading}
>
@ -128,18 +154,27 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
collectionName,
selectedOperations,
{
onError: () => {
// TODO: global notifications
onError: e => {
fireNotification({
type: 'error',
title: 'Failed to delete operations',
message: `Failed to delete operations from collection ${collectionName}: ${e.message}`,
});
},
onSuccess: () => {
// TODO: global notifications
fireNotification({
type: 'success',
title: 'Operations deleted',
message: `Successfully deleted ${selectedOperations.length} operations from ${collectionName}`,
});
setSelectedOperations([]);
},
}
);
}
}}
className="mr-1.5"
size="sm"
size="md"
mode="destructive"
icon={<FaRegTrashAlt />}
isLoading={deleteLoading}
@ -150,7 +185,12 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
)}
</div>
<div className="ml-auto w-3/12 relative">
<QueryCollectionsOperationsSearchForm setSearch={onSearch} />
<QueryCollectionsOperationsSearchForm
setSearch={searchString => {
onSearch(searchString);
setSelectedOperations([]);
}}
/>
</div>
</div>
);

View File

@ -33,7 +33,11 @@ const SearchInput: React.FC<QueryCollectionsOperationsSearchFormProps> = ({
export const QueryCollectionsOperationsSearchForm: React.FC<QueryCollectionsOperationsSearchFormProps> =
({ setSearch }) => {
return (
<Form schema={schema} onSubmit={() => {}} className="p-0 relative top-2">
<Form
schema={schema}
onSubmit={() => {}}
className="pr-0 pt-0 pb-0 relative top-2"
>
{() => <SearchInput setSearch={setSearch} />}
</Form>
);

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMetadata, useMetadataMigration } from '@/features/MetadataAPI';
import { QueryCollection } from '@/metadata/types';
import { createAllowedQueriesIfNeeded } from '../useCreateQueryCollection';
export const useAddOperationsToQueryCollection = () => {
const { mutate, ...rest } = useMetadataMigration();
@ -27,7 +28,9 @@ export const useAddOperationsToQueryCollection = () => {
...(metadata?.resource_version && {
resource_version: metadata.resource_version,
}),
args: queries.map(query => ({
args: [
...createAllowedQueriesIfNeeded(queryCollection, metadata),
...queries.map(query => ({
type: 'add_query_to_collection',
args: {
collection_name: queryCollection,
@ -35,6 +38,7 @@ export const useAddOperationsToQueryCollection = () => {
query: query.query,
},
})),
],
},
},
{

View File

@ -1,4 +1,8 @@
import { useMetadata, useMetadataMigration } from '@/features/MetadataAPI';
import {
MetadataResponse,
useMetadata,
useMetadataMigration,
} from '@/features/MetadataAPI';
export const useCreateQueryCollection = () => {
const { mutate, isSuccess, isLoading, error } = useMetadataMigration();
@ -43,3 +47,31 @@ export const useCreateQueryCollection = () => {
return { createQueryCollection, isSuccess, isLoading, error };
};
export const createAllowedQueriesIfNeeded = (
queryCollection: string,
metadata: MetadataResponse | undefined
) => {
return queryCollection === 'allowed-queries' &&
!metadata?.metadata?.query_collections?.find(
q => q.name === queryCollection
)
? [
{
type: 'create_query_collection',
args: {
name: queryCollection,
definition: {
queries: [],
},
},
},
{
type: 'add_collection_to_allowlist',
args: {
collection: queryCollection,
},
},
]
: [];
};

View File

@ -32,7 +32,7 @@ describe('useCreateQueryCollections', () => {
{ wrapper }
);
await result.current.createQueryCollection('allowed_queries');
await result.current.createQueryCollection('allowed-queries');
await waitForValueToChange(() => result.current.error);
expect(result.current.error).toBeDefined();

View File

@ -54,7 +54,7 @@ export default {
},
argTypes: {
collectionName: {
defaultValue: 'allowed_queries',
defaultValue: 'allowed-queries',
description: 'The name of the query collection to delete',
control: {
type: 'text',

View File

@ -20,7 +20,7 @@ describe('useDeleteQueryCollections', () => {
{ wrapper }
);
await result.current.deleteQueryCollection('allowed_queries');
await result.current.deleteQueryCollection('allowed-queries');
await waitForValueToChange(() => result.current.isSuccess);
expect(result.current.isSuccess).toBe(true);

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMetadata, useMetadataMigration } from '@/features/MetadataAPI';
import { QueryCollection } from '@/metadata/types';
import { createAllowedQueriesIfNeeded } from '../useCreateQueryCollection';
export const useMoveOperationsToQueryCollection = () => {
const { mutate, ...rest } = useMetadataMigration();
@ -35,6 +36,7 @@ export const useMoveOperationsToQueryCollection = () => {
}),
args: queries
.map(query => [
...createAllowedQueriesIfNeeded(toCollection, metadata),
{
type: 'drop_query_from_collection',
args: {

View File

@ -11,13 +11,13 @@ export const createMetadata = (): MetadataResponse => ({
inherited_roles: [],
allowlist: [
{
collection: 'allowed_queries',
collection: 'allowed-queries',
scope: {
global: true,
},
},
{
collection: 'allowed_queries',
collection: 'allowed-queries',
scope: {
global: false,
roles: ['user'],
@ -25,7 +25,7 @@ export const createMetadata = (): MetadataResponse => ({
},
],
query_collections: [
{ name: 'allowed_queries', definition: { queries: [] } },
{ name: 'allowed-queries', definition: { queries: [] } },
{ name: 'other_queries', definition: { queries: [] } },
],
},

View File

@ -24,7 +24,7 @@ describe('useQueryCollections', () => {
const queryCollections = result.current.data;
expect(queryCollections).toHaveLength(2);
expect(queryCollections?.[0]?.name).toEqual('allowed_queries');
expect(queryCollections?.[0]?.name).toEqual('allowed-queries');
expect(queryCollections?.[1]?.name).toEqual('other_queries');
});
});

View File

@ -1,9 +1,16 @@
import { MetadataSelector, useMetadata } from '@/features/MetadataAPI';
export const useQueryCollections = () => {
const { data, isLoading, isError, isRefetching, isSuccess } = useMetadata(
MetadataSelector.getQueryCollections
);
const { data, ...rest } = useMetadata(MetadataSelector.getQueryCollections);
return { data, isLoading: isLoading || isRefetching, isError, isSuccess };
return {
data:
data && data?.find(({ name }) => name === 'allowed-queries')
? data
: [
{ name: 'allowed-queries', definition: { queries: [] } },
...(data || []),
],
...rest,
};
};

View File

@ -57,7 +57,7 @@ export default {
},
argTypes: {
name: {
defaultValue: 'allowed_queries',
defaultValue: 'allowed-queries',
description: 'The name of the query collection to rename',
control: {
type: 'text',

View File

@ -20,7 +20,7 @@ describe('useRenameQueryCollection', () => {
{ wrapper }
);
await result.current.renameQueryCollection('allowed_queries', 'new-name');
await result.current.renameQueryCollection('allowed-queries', 'new-name');
await waitForValueToChange(() => result.current.isSuccess);
expect(result.current.isSuccess).toBe(true);

View File

@ -0,0 +1,3 @@
export { QueryCollectionsOperations } from './components/QueryCollectionOperations';
export { useQueryCollections } from './hooks/useQueryCollections';
export { QueryCollectionHeader } from './components/QueryCollectionHeader';

View File

@ -38,6 +38,7 @@ import {
} from './helpers/versionUtils';
import AuthContainer from './components/Services/Auth/AuthContainer';
import { FeatureFlags } from './features/FeatureFlags';
import { AllowListDetail } from './components/Services/AllowList';
const routes = store => {
// load hasuractl migration status
@ -126,6 +127,13 @@ const routes = store => {
<Route path="details/:name" component={DetailsView} />
<Route path="edit/:name" component={FormRestView} />
</Route>
<Route path="allow-list">
<IndexRedirect to="detail" />
<Route
path="detail(/:name)(/:section)"
component={AllowListDetail}
/>
</Route>
</Route>
<Route