mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
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:
parent
118b03e329
commit
85d5082322
@ -85,3 +85,5 @@ export {
|
||||
SampleDBBanner,
|
||||
newSampleDBTrial,
|
||||
} from '../src/components/Services/Data/DataSources/SampleDatabase';
|
||||
|
||||
export { AllowListDetail } from '../src/components/Services/AllowList/AllowListDetail';
|
||||
|
123
console/src/components/Services/AllowList/AllowListDetail.tsx
Normal file
123
console/src/components/Services/AllowList/AllowListDetail.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
console/src/components/Services/AllowList/index.ts
Normal file
1
console/src/components/Services/AllowList/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './AllowListDetail';
|
@ -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>
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
.notifications-wrapper .notification {
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notifications-wrapper .notifications-tr,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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: {
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1 @@
|
||||
export * from './AllowListPermissions';
|
@ -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"
|
||||
/>
|
||||
);
|
@ -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>
|
||||
)}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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>;
|
||||
};
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -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
|
||||
)}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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),
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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),
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
|
@ -1,2 +1,4 @@
|
||||
export { useAddToAllowList } from './hooks/useAddToAllowList';
|
||||
export { useRemoveFromAllowList } from './hooks/useRemoveFromAllowList';
|
||||
export { AllowListSidebar } from './components/AllowListSidebar';
|
||||
export { AllowListPermissions } from './components/AllowListPermissions';
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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]}
|
||||
/>
|
||||
)
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -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();
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
@ -82,6 +82,7 @@ export const QueryCollectionOperationDialog = (
|
||||
size="full"
|
||||
id="name"
|
||||
name="name"
|
||||
className="max-w-full"
|
||||
label="Operation Name"
|
||||
/>
|
||||
<CodeEditorField
|
||||
|
@ -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();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -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 }) => {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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: {
|
||||
|
@ -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: [] } },
|
||||
],
|
||||
},
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
3
console/src/features/QueryCollections/index.ts
Normal file
3
console/src/features/QueryCollections/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { QueryCollectionsOperations } from './components/QueryCollectionOperations';
|
||||
export { useQueryCollections } from './hooks/useQueryCollections';
|
||||
export { QueryCollectionHeader } from './components/QueryCollectionHeader';
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user