mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
Track nested logical models and Logical models details route
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9497 GitOrigin-RevId: bd5b38b0f8ad3f1f317418631af9f85dd14b9983
This commit is contained in:
parent
b309fc9211
commit
e8c67b1bfc
@ -37,9 +37,11 @@ import { TableEditItemContainer } from './TableEditItem/TableEditItemContainer';
|
||||
import { TableInsertItemContainer } from './TableInsertItem/TableInsertItemContainer';
|
||||
import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
|
||||
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
|
||||
|
||||
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route';
|
||||
import { LogicalModelPermissionsRoute } from '../../../features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage';
|
||||
import {
|
||||
ViewLogicalModelRoute,
|
||||
LogicalModelPermissionsRoute,
|
||||
} from '../../../features/Data/LogicalModels';
|
||||
import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
|
||||
import {
|
||||
UpdateNativeQueryRoute,
|
||||
@ -95,6 +97,7 @@ const makeDataRouter = (
|
||||
<IndexRoute component={NativeQueries} />
|
||||
<Redirect from=":source" to="/data/native-queries/logical-models" />
|
||||
<Route path=":source">
|
||||
<Route path=":name" component={ViewLogicalModelRoute} />
|
||||
<Route
|
||||
path=":name/permissions"
|
||||
component={LogicalModelPermissionsRoute}
|
||||
|
@ -169,7 +169,7 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
|
||||
isLoading={isLoading}
|
||||
onEditClick={model => {
|
||||
push?.(
|
||||
`/data/native-queries/logical-models/${model.source.name}/${model.name}/permissions`
|
||||
`/data/native-queries/logical-models/${model.source.name}/${model.name}`
|
||||
);
|
||||
}}
|
||||
onRemoveClick={handleRemoveLogicalModel}
|
||||
|
@ -8,6 +8,8 @@ import Skeleton from 'react-loading-skeleton';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable';
|
||||
import { LogicalModelWithSource } from '../../types';
|
||||
import { CgDetailsMore } from 'react-icons/cg';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
|
||||
const columnHelper = createColumnHelper<LogicalModelWithSource>();
|
||||
|
||||
@ -39,11 +41,15 @@ export const ListLogicalModels = ({
|
||||
header: 'Actions',
|
||||
cell: ({ cell, row }) => (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button onClick={() => onEditClick(row.original)}>
|
||||
Edit Permissions
|
||||
<Button
|
||||
icon={<CgDetailsMore />}
|
||||
onClick={() => onEditClick(row.original)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
mode="destructive"
|
||||
icon={<FaTrash />}
|
||||
onClick={() => onRemoveClick(row.original)}
|
||||
>
|
||||
Remove
|
||||
|
@ -0,0 +1,86 @@
|
||||
import { InjectedRouter, withRouter } from 'react-router';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { LogicalModelTabs } from '../components/LogicalModelTabs';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { MetadataUtils, useMetadata } from '../../../hasura-metadata-api';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
|
||||
import { logicalModelFieldToFormField } from './utils/logicalModelFieldToFormField';
|
||||
|
||||
export const ViewLogicalModelRoute = withRouter<{
|
||||
location: Location;
|
||||
router: InjectedRouter;
|
||||
params: {
|
||||
source: string;
|
||||
name: string;
|
||||
};
|
||||
}>(({ params }) => {
|
||||
if (!params.source || !params.name) {
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Unable to parse data from url.
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RouteWrapper
|
||||
route={'/data/native-queries/logical-models/{{source}}/{{name}}'}
|
||||
itemSourceName={params.source}
|
||||
itemName={params.name}
|
||||
>
|
||||
<LogicalModelTabs
|
||||
source={params.source}
|
||||
name={params.name}
|
||||
defaultValue="logical-model"
|
||||
/>
|
||||
</RouteWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export const ViewLogicalModelPage = ({
|
||||
source,
|
||||
name,
|
||||
}: {
|
||||
source: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { data: logicalModels, isLoading } = useMetadata(
|
||||
m => MetadataUtils.findMetadataSource(source, m)?.logical_models
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton count={10} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const logicalModel = logicalModels?.find(l => l.name === name);
|
||||
|
||||
if (!logicalModel) {
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Logical Model {name} not found in {source}
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-md max-w">
|
||||
<LogicalModelWidget
|
||||
disabled={{
|
||||
name: true,
|
||||
dataSourceName: true,
|
||||
fields: true,
|
||||
callToAction: true,
|
||||
}}
|
||||
defaultValues={{
|
||||
name: logicalModel.name,
|
||||
dataSourceName: source,
|
||||
fields: logicalModel.fields.map(logicalModelFieldToFormField),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,40 @@
|
||||
import { LogicalModel } from '../../../../hasura-metadata-types';
|
||||
import {
|
||||
isArrayLogicalModelType,
|
||||
isLogicalModelType,
|
||||
isScalarFieldType,
|
||||
} from '../../../../hasura-metadata-types/source/typeGuards';
|
||||
import { AddLogicalModelFormData } from '../../LogicalModelWidget/validationSchema';
|
||||
|
||||
export function logicalModelFieldToFormField(
|
||||
f: LogicalModel['fields'][number]
|
||||
): AddLogicalModelFormData['fields'][number] {
|
||||
if (isScalarFieldType(f.type)) {
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type.scalar,
|
||||
typeClass: 'scalar',
|
||||
nullable: f.type.nullable,
|
||||
array: false,
|
||||
};
|
||||
}
|
||||
if (isLogicalModelType(f.type)) {
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type.logical_model,
|
||||
typeClass: 'logical_model',
|
||||
nullable: f.type.nullable,
|
||||
array: false,
|
||||
};
|
||||
}
|
||||
if (isArrayLogicalModelType(f.type)) {
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type.array.logical_model,
|
||||
typeClass: 'logical_model',
|
||||
nullable: f.type.array.nullable,
|
||||
array: true,
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown field type');
|
||||
}
|
@ -25,8 +25,20 @@ const noPermissionArgs: Partial<
|
||||
tables: [],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'one', nullable: false, type: 'text' },
|
||||
{ name: 'two', nullable: false, type: 'text' },
|
||||
{
|
||||
name: 'one',
|
||||
type: {
|
||||
scalar: 'text',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
type: {
|
||||
scalar: 'text',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'hello_world',
|
||||
},
|
||||
@ -47,8 +59,20 @@ const existingPermissionArgs: Partial<
|
||||
tables: [],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'one', nullable: false, type: 'text' },
|
||||
{ name: 'two', nullable: false, type: 'text' },
|
||||
{
|
||||
name: 'one',
|
||||
type: {
|
||||
scalar: 'text',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
type: {
|
||||
scalar: 'text',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'hello_world',
|
||||
select_permissions: [
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { InjectedRouter, withRouter } from 'react-router';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { Tabs } from '../../../../new-components/Tabs';
|
||||
import { LogicalModelPermissions } from './LogicalModelPermissions';
|
||||
import { useCreateLogicalModelsPermissions } from './hooks/useCreateLogicalModelsPermissions';
|
||||
import { useRemoveLogicalModelsPermissions } from './hooks/useRemoveLogicalModelsPermissions';
|
||||
@ -8,6 +7,7 @@ import { useMetadata } from '../../../hasura-metadata-api';
|
||||
import { usePermissionComparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/hooks/usePermissionComparators';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
|
||||
import { LogicalModelTabs } from '../components/LogicalModelTabs';
|
||||
|
||||
export const LogicalModelPermissionsPage = ({
|
||||
source,
|
||||
@ -32,58 +32,47 @@ export const LogicalModelPermissionsPage = ({
|
||||
logicalModels,
|
||||
source: logicalModel?.source,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
<div
|
||||
className="mt-md"
|
||||
// Recreate the key when the logical model permissions change to reset the form
|
||||
key={logicalModel?.select_permissions?.length}
|
||||
data-testid="logical-model-permissions-tab"
|
||||
defaultValue={'logical-model-permissions'}
|
||||
items={[
|
||||
{
|
||||
value: 'logical-model-permissions',
|
||||
label: `Permissions`,
|
||||
content: (
|
||||
<div className="mt-md">
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center h-64"
|
||||
data-testid="loading-logical-model-permissions"
|
||||
>
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : !logicalModel ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="text-gray-500">
|
||||
Logical model with name {name} and driver {source} not found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<LogicalModelPermissions
|
||||
onSave={async permission => {
|
||||
create({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
onDelete={async permission => {
|
||||
remove({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
isCreating={isCreating}
|
||||
isRemoving={isRemoving}
|
||||
comparators={comparators}
|
||||
logicalModelName={logicalModel?.name}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center h-64"
|
||||
data-testid="loading-logical-model-permissions"
|
||||
>
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : !logicalModel ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="text-gray-500">
|
||||
Logical model with name {name} and driver {source} not found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<LogicalModelPermissions
|
||||
onSave={async permission => {
|
||||
create({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
onDelete={async permission => {
|
||||
remove({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
isCreating={isCreating}
|
||||
isRemoving={isRemoving}
|
||||
comparators={comparators}
|
||||
logicalModelName={logicalModel?.name}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -103,7 +92,11 @@ export const LogicalModelPermissionsRoute = withRouter<{
|
||||
itemSourceName={params.source}
|
||||
itemName={params.name}
|
||||
>
|
||||
<LogicalModelPermissionsPage source={params.source} name={params.name} />
|
||||
<LogicalModelTabs
|
||||
source={params.source}
|
||||
name={params.name}
|
||||
defaultValue={'logical-model-permissions'}
|
||||
/>
|
||||
</RouteWrapper>
|
||||
);
|
||||
});
|
||||
|
@ -1,36 +1,27 @@
|
||||
import { StoryObj, Meta } from '@storybook/react';
|
||||
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||
import { LogicalModelWidget } from './LogicalModelWidget';
|
||||
import { ReduxDecorator } from '../../../../storybook/decorators/redux-decorator';
|
||||
import { fireEvent, userEvent, within } from '@storybook/testing-library';
|
||||
import { handlers } from './mocks/handlers';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { defaultEmptyValues } from './validationSchema';
|
||||
import { waitForRequest } from '../../../../storybook/utils/waitForRequest';
|
||||
|
||||
export default {
|
||||
component: LogicalModelWidget,
|
||||
decorators: [
|
||||
ReactQueryDecorator(),
|
||||
ReduxDecorator({
|
||||
tables: {
|
||||
dataHeaders: {
|
||||
'x-hasura-admin-secret': 'myadminsecretkey',
|
||||
} as any,
|
||||
},
|
||||
}),
|
||||
],
|
||||
decorators: [ReactQueryDecorator()],
|
||||
} as Meta<typeof LogicalModelWidget>;
|
||||
|
||||
export const DefaultView: StoryObj<typeof LogicalModelWidget> = {
|
||||
render: () => <LogicalModelWidget />,
|
||||
|
||||
parameters: {
|
||||
msw: handlers['200'],
|
||||
},
|
||||
};
|
||||
|
||||
export const DialogVariant: StoryObj<typeof LogicalModelWidget> = {
|
||||
render: () => <LogicalModelWidget asDialog />,
|
||||
args: {
|
||||
asDialog: true,
|
||||
},
|
||||
|
||||
parameters: {
|
||||
msw: handlers['200'],
|
||||
@ -39,17 +30,15 @@ export const DialogVariant: StoryObj<typeof LogicalModelWidget> = {
|
||||
|
||||
export const PreselectedAndDisabledInputs: StoryObj<typeof LogicalModelWidget> =
|
||||
{
|
||||
render: () => (
|
||||
<LogicalModelWidget
|
||||
defaultValues={{
|
||||
...defaultEmptyValues,
|
||||
dataSourceName: 'chinook',
|
||||
}}
|
||||
disabled={{
|
||||
dataSourceName: true,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
defaultValues: {
|
||||
...defaultEmptyValues,
|
||||
dataSourceName: 'chinook',
|
||||
},
|
||||
disabled: {
|
||||
dataSourceName: true,
|
||||
},
|
||||
},
|
||||
|
||||
parameters: {
|
||||
msw: handlers['200'],
|
||||
@ -57,8 +46,6 @@ export const PreselectedAndDisabledInputs: StoryObj<typeof LogicalModelWidget> =
|
||||
};
|
||||
|
||||
export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
|
||||
render: () => <LogicalModelWidget />,
|
||||
|
||||
name: '🧪 Basic user flow',
|
||||
|
||||
parameters: {
|
||||
@ -79,14 +66,17 @@ export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
|
||||
|
||||
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
|
||||
await userEvent.selectOptions(
|
||||
canvas.getByTestId('fields[0].type'),
|
||||
'integer'
|
||||
canvas.getByTestId('fields-input-type-0'),
|
||||
'scalar:integer'
|
||||
);
|
||||
|
||||
fireEvent.click(canvas.getByText('Add Field'));
|
||||
|
||||
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name');
|
||||
await userEvent.selectOptions(canvas.getByTestId('fields[1].type'), 'text');
|
||||
await userEvent.selectOptions(
|
||||
canvas.getByTestId('fields-input-type-1'),
|
||||
'scalar:text'
|
||||
);
|
||||
|
||||
fireEvent.click(canvas.getByText('Create Logical Model'));
|
||||
|
||||
@ -97,7 +87,6 @@ export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
|
||||
};
|
||||
|
||||
export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
|
||||
render: () => <LogicalModelWidget />,
|
||||
name: '🧪 Network Error On Submit',
|
||||
|
||||
parameters: {
|
||||
@ -118,14 +107,17 @@ export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
|
||||
|
||||
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
|
||||
await userEvent.selectOptions(
|
||||
canvas.getByTestId('fields[0].type'),
|
||||
'integer'
|
||||
canvas.getByTestId('fields-input-type-0'),
|
||||
'scalar:integer'
|
||||
);
|
||||
|
||||
fireEvent.click(canvas.getByText('Add Field'));
|
||||
|
||||
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name');
|
||||
await userEvent.selectOptions(canvas.getByTestId('fields[1].type'), 'text');
|
||||
await userEvent.selectOptions(
|
||||
canvas.getByTestId('fields-input-type-1'),
|
||||
'scalar:text'
|
||||
);
|
||||
|
||||
fireEvent.click(canvas.getByText('Create Logical Model'));
|
||||
|
||||
@ -134,3 +126,134 @@ export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const defaultLogicalModelValues = {
|
||||
name: 'Logical Model',
|
||||
dataSourceName: 'chinook',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'integer',
|
||||
array: false,
|
||||
nullable: false,
|
||||
typeClass: 'scalar' as const,
|
||||
},
|
||||
{
|
||||
name: 'nested',
|
||||
type: 'logical_model_1',
|
||||
array: false,
|
||||
nullable: false,
|
||||
typeClass: 'logical_model' as const,
|
||||
},
|
||||
{
|
||||
name: 'nested_array',
|
||||
type: 'logical_model_2',
|
||||
array: true,
|
||||
nullable: false,
|
||||
typeClass: 'logical_model' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NestedLogicalModels: StoryObj<typeof LogicalModelWidget> = {
|
||||
args: {
|
||||
defaultValues: defaultLogicalModelValues,
|
||||
},
|
||||
parameters: {
|
||||
msw: handlers['200'],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Establish a request listener but don't resolve it yet.
|
||||
const pendingRequest = waitForRequest(
|
||||
'POST',
|
||||
'http://localhost:8080/v1/metadata',
|
||||
'_track_logical_model'
|
||||
);
|
||||
fireEvent.click(await canvas.findByText('Edit Logical Model'));
|
||||
// Await the request and get its reference.
|
||||
const request = await pendingRequest;
|
||||
const payload = await request.clone().json();
|
||||
expect(payload.args.fields).toMatchObject([
|
||||
{
|
||||
name: 'id',
|
||||
type: {
|
||||
scalar: 'integer',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nested',
|
||||
type: {
|
||||
logical_model: 'logical_model_1',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nested_array',
|
||||
type: {
|
||||
array: {
|
||||
logical_model: 'logical_model_2',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
export const EditingNestedLogicalModels: StoryObj<typeof LogicalModelWidget> = {
|
||||
args: {
|
||||
defaultValues: defaultLogicalModelValues,
|
||||
},
|
||||
parameters: {
|
||||
msw: handlers['200'],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Establish a request listener but don't resolve it yet.
|
||||
const pendingRequest = waitForRequest(
|
||||
'POST',
|
||||
'http://localhost:8080/v1/metadata',
|
||||
'_track_logical_model'
|
||||
);
|
||||
// Change from logical model to scalar, and then back should result in the same initial logical model type
|
||||
await userEvent.click(await canvas.findByTestId('fields-input-type-1'));
|
||||
// Set array to true
|
||||
await userEvent.click(await canvas.findByTestId('fields-input-array-1'));
|
||||
// Change it to scalar
|
||||
await userEvent.selectOptions(
|
||||
await canvas.findByTestId('fields-input-type-1'),
|
||||
'scalar:integer'
|
||||
);
|
||||
fireEvent.click(await canvas.findByText('Edit Logical Model'));
|
||||
// Await the request and get its reference.
|
||||
const request = await pendingRequest;
|
||||
const payload = await request.clone().json();
|
||||
expect(payload.args.fields).toMatchObject([
|
||||
{
|
||||
name: 'id',
|
||||
type: {
|
||||
scalar: 'integer',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nested',
|
||||
type: {
|
||||
scalar: 'integer',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nested_array',
|
||||
type: {
|
||||
array: {
|
||||
logical_model: 'logical_model_2',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
@ -19,13 +19,20 @@ import {
|
||||
AddLogicalModelFormData,
|
||||
addLogicalModelValidationSchema,
|
||||
} from './validationSchema';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
|
||||
import { Link } from 'react-router';
|
||||
import { formFieldToLogicalModelField } from './mocks/utils/formFieldToLogicalModelField';
|
||||
import { useEnvironmentState } from '../../../ConnectDBRedesign/hooks';
|
||||
|
||||
export type AddLogicalModelDialogProps = {
|
||||
defaultValues?: AddLogicalModelFormData;
|
||||
onCancel?: () => void;
|
||||
onSubmit?: () => void;
|
||||
disabled?: CreateBooleanMap<AddLogicalModelFormData>;
|
||||
disabled?: CreateBooleanMap<
|
||||
AddLogicalModelFormData & {
|
||||
callToAction?: boolean;
|
||||
}
|
||||
>;
|
||||
asDialog?: boolean;
|
||||
};
|
||||
|
||||
@ -84,9 +91,19 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: modelsAndQueries } = useMetadata(m =>
|
||||
extractModelsAndQueriesFromMetadata(m)
|
||||
);
|
||||
|
||||
const logicalModels = modelsAndQueries?.models || [];
|
||||
|
||||
const onSubmit = (data: AddLogicalModelFormData) => {
|
||||
trackLogicalModel({
|
||||
data,
|
||||
data: {
|
||||
dataSourceName: data.dataSourceName,
|
||||
name: data.name,
|
||||
fields: data.fields.map(formFieldToLogicalModelField),
|
||||
},
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
@ -116,18 +133,38 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
|
||||
return isMetadataLoading || isIntrospectionLoading ? (
|
||||
<Skeleton count={8} height={20} />
|
||||
) : (
|
||||
<Form onSubmit={onSubmit}>
|
||||
<LogicalModelFormInputs
|
||||
sourceOptions={sourceOptions}
|
||||
typeOptions={typeOptions}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" mode="primary" isLoading={isLoading}>
|
||||
{isEditMode ? 'Edit Logical Model' : 'Create Logical Model'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<>
|
||||
{isEditMode && (
|
||||
<IndicatorCard status="info">
|
||||
The current release does not support editing Logical Models. This
|
||||
feature will be available in a future release. You can still{' '}
|
||||
<Link
|
||||
to={`/data/native-queries/logical-models/${props.defaultValues?.dataSourceName}/${props.defaultValues?.name}/permissions`}
|
||||
>
|
||||
edit permissions
|
||||
</Link>{' '}
|
||||
or edit logical models directly by modifying the Metadata.
|
||||
</IndicatorCard>
|
||||
)}
|
||||
<Form onSubmit={onSubmit}>
|
||||
<LogicalModelFormInputs
|
||||
sourceOptions={sourceOptions}
|
||||
typeOptions={typeOptions}
|
||||
disabled={props.disabled}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={props.disabled?.callToAction}
|
||||
type="submit"
|
||||
mode="primary"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isEditMode ? 'Edit Logical Model' : 'Create Logical Model'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -160,6 +197,7 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
|
||||
sourceOptions={sourceOptions}
|
||||
typeOptions={typeOptions}
|
||||
disabled={props.disabled}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
@ -10,7 +10,6 @@ import { FaPlusCircle } from 'react-icons/fa';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import {
|
||||
GraphQLSanitizedInputField,
|
||||
Select,
|
||||
fieldLabelStyles,
|
||||
} from '../../../../../new-components/Form';
|
||||
import { BooleanInput } from '../../components/BooleanInput';
|
||||
@ -19,6 +18,7 @@ import {
|
||||
AddLogicalModelField,
|
||||
AddLogicalModelFormData,
|
||||
} from '../validationSchema';
|
||||
import { LogicalModel } from '../../../../hasura-metadata-types';
|
||||
|
||||
const columnHelper = createColumnHelper<AddLogicalModelField>();
|
||||
|
||||
@ -26,12 +26,15 @@ export const FieldsInput = ({
|
||||
name,
|
||||
types,
|
||||
disabled,
|
||||
logicalModels,
|
||||
}: {
|
||||
name: string;
|
||||
types: string[];
|
||||
disabled?: boolean;
|
||||
logicalModels: LogicalModel[];
|
||||
}) => {
|
||||
const { control } = useFormContext<AddLogicalModelFormData>();
|
||||
const { control, setValue, watch } =
|
||||
useFormContext<AddLogicalModelFormData>();
|
||||
|
||||
const { append, remove, fields } = useFieldArray({
|
||||
control,
|
||||
@ -58,18 +61,58 @@ export const FieldsInput = ({
|
||||
}),
|
||||
columnHelper.accessor('type', {
|
||||
id: 'type',
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
noErrorPlaceholder
|
||||
dataTestId={`${name}[${row.index}].type`}
|
||||
name={`fields.${row.index}.type`}
|
||||
options={types.map(t => ({ label: t, value: t }))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const typeValue = watch(`fields.${row.index}.type`);
|
||||
const typeClassValue = watch(`fields.${row.index}.typeClass`);
|
||||
return (
|
||||
<select
|
||||
className={clsx(
|
||||
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400',
|
||||
'text-black',
|
||||
disabled
|
||||
? 'cursor-not-allowed bg-gray-200 border-gray-200 hover:border-gray-200'
|
||||
: 'hover:border-gray-400'
|
||||
)}
|
||||
value={`${typeClassValue}:${typeValue}`}
|
||||
data-testid={`fields-input-type-${row.index}`}
|
||||
onChange={e => {
|
||||
const [typeClass, selectedValue] = e.target.value.split(':');
|
||||
setValue(`fields.${row.index}.type`, selectedValue);
|
||||
setValue(`fields.${row.index}.typeClass`, typeClass as any);
|
||||
if (typeClass === 'scalar') {
|
||||
setValue(`fields.${row.index}.array`, false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="" data-default-selected hidden>
|
||||
Select a type
|
||||
</option>
|
||||
<optgroup
|
||||
label="Logical Models"
|
||||
data-testid={`fields-input-type-${row.index}-logical-models`}
|
||||
>
|
||||
{logicalModels.map(l => (
|
||||
<option key={l.name} value={`logical_model:${l.name}`}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Types"
|
||||
data-testid={`fields-input-type-${row.index}-scalars`}
|
||||
>
|
||||
{types.map(t => (
|
||||
<option key={t} value={`scalar:${t}`}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
);
|
||||
},
|
||||
header: 'Type',
|
||||
}),
|
||||
|
||||
columnHelper.accessor('nullable', {
|
||||
id: 'nullable',
|
||||
cell: ({ row }) => (
|
||||
@ -80,6 +123,20 @@ export const FieldsInput = ({
|
||||
),
|
||||
header: 'Nullable',
|
||||
}),
|
||||
columnHelper.accessor('array', {
|
||||
id: 'array',
|
||||
cell: ({ row }) => {
|
||||
const typeClassValue = watch(`fields.${row.index}.typeClass`);
|
||||
return (
|
||||
<BooleanInput
|
||||
disabled={disabled || typeClassValue === 'scalar'}
|
||||
name={`fields.${row.index}.array`}
|
||||
dataTestId={`fields-input-array-${row.index}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: 'Array',
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'action',
|
||||
header: 'Actions',
|
||||
@ -115,9 +172,15 @@ export const FieldsInput = ({
|
||||
<div className={clsx(fieldLabelStyles, 'mb-0')}>Fields</div>
|
||||
<Button
|
||||
icon={<FaPlusCircle />}
|
||||
disabled={!types || types.length === 0}
|
||||
disabled={disabled || !types || types.length === 0}
|
||||
onClick={() => {
|
||||
append({ name: '', type: 'text', nullable: true });
|
||||
append({
|
||||
name: '',
|
||||
type: 'text',
|
||||
typeClass: 'scalar',
|
||||
nullable: true,
|
||||
array: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add Field
|
||||
|
@ -23,7 +23,11 @@ export default {
|
||||
|
||||
export const Basic: StoryFn<typeof LogicalModelFormInputs> = () => (
|
||||
<SimpleForm schema={addLogicalModelValidationSchema} onSubmit={() => {}}>
|
||||
<LogicalModelFormInputs sourceOptions={[]} typeOptions={[]} />
|
||||
<LogicalModelFormInputs
|
||||
logicalModels={[]}
|
||||
sourceOptions={[]}
|
||||
typeOptions={[]}
|
||||
/>
|
||||
</SimpleForm>
|
||||
);
|
||||
|
||||
@ -39,10 +43,12 @@ export const WithDefaultValues: StoryObj<typeof LogicalModelFormInputs> = {
|
||||
{
|
||||
name: 'id',
|
||||
type: 'int',
|
||||
typeClass: 'scalar',
|
||||
},
|
||||
{
|
||||
name: 'first_name',
|
||||
type: 'text',
|
||||
typeClass: 'scalar',
|
||||
},
|
||||
],
|
||||
name: 'foobar',
|
||||
@ -51,6 +57,7 @@ export const WithDefaultValues: StoryObj<typeof LogicalModelFormInputs> = {
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
<LogicalModelFormInputs
|
||||
logicalModels={[]}
|
||||
sourceOptions={[{ value: 'chinook', label: 'chinook' }]}
|
||||
typeOptions={['text', 'int']}
|
||||
/>
|
||||
@ -63,14 +70,14 @@ export const WithDefaultValues: StoryObj<typeof LogicalModelFormInputs> = {
|
||||
|
||||
await expect(await canvas.findByTestId('name')).toHaveValue('foobar');
|
||||
await expect(await canvas.findByTestId('fields[0].name')).toHaveValue('id');
|
||||
await expect(await canvas.findByTestId('fields[0].type')).toHaveValue(
|
||||
'int'
|
||||
await expect(await canvas.findByTestId('fields-input-type-0')).toHaveValue(
|
||||
'scalar:int'
|
||||
);
|
||||
await expect(await canvas.findByTestId('fields[1].name')).toHaveValue(
|
||||
'first_name'
|
||||
);
|
||||
await expect(await canvas.findByTestId('fields[1].type')).toHaveValue(
|
||||
'text'
|
||||
await expect(await canvas.findByTestId('fields-input-type-1')).toHaveValue(
|
||||
'scalar:text'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
GraphQLSanitizedInputField,
|
||||
Select,
|
||||
} from '../../../../../new-components/Form';
|
||||
import { LogicalModel } from '../../../../hasura-metadata-types';
|
||||
import { AddLogicalModelFormData } from '../validationSchema';
|
||||
import { FieldsInput } from './FieldsInput';
|
||||
|
||||
@ -11,6 +12,7 @@ export type LogicalModelFormProps = {
|
||||
sourceOptions: SelectItem[];
|
||||
typeOptions: string[];
|
||||
disabled?: CreateBooleanMap<AddLogicalModelFormData>;
|
||||
logicalModels: LogicalModel[];
|
||||
};
|
||||
|
||||
export const LogicalModelFormInputs = (props: LogicalModelFormProps) => {
|
||||
@ -36,6 +38,7 @@ export const LogicalModelFormInputs = (props: LogicalModelFormProps) => {
|
||||
name="fields"
|
||||
types={props.typeOptions}
|
||||
disabled={props.disabled?.fields}
|
||||
logicalModels={props.logicalModels}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { LogicalModel } from '../../../../../hasura-metadata-types';
|
||||
import { AddLogicalModelFormData } from '../../validationSchema';
|
||||
|
||||
export function formFieldToLogicalModelField(
|
||||
field: AddLogicalModelFormData['fields'][number]
|
||||
): LogicalModel['fields'][number] {
|
||||
let type: LogicalModel['fields'][number]['type'];
|
||||
if (field.typeClass === 'scalar') {
|
||||
type = {
|
||||
scalar: field.type,
|
||||
nullable: field.nullable,
|
||||
};
|
||||
} else {
|
||||
if (field.array) {
|
||||
type = {
|
||||
array: {
|
||||
logical_model: field.type,
|
||||
nullable: field.nullable,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
type = {
|
||||
logical_model: field.type,
|
||||
nullable: field.nullable,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: field.name,
|
||||
type,
|
||||
};
|
||||
}
|
@ -7,6 +7,8 @@ export const addLogicalModelValidationSchema = z.object({
|
||||
.object({
|
||||
name: z.string().min(1, 'Field Name is a required field'),
|
||||
type: z.string().min(1, 'Type is a required field'),
|
||||
typeClass: z.enum(['scalar', 'logical_model']),
|
||||
array: z.boolean(),
|
||||
nullable: z.boolean({ required_error: 'Nullable is a required field' }),
|
||||
})
|
||||
.array(),
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { Tabs } from '../../../../new-components/Tabs';
|
||||
import { ViewLogicalModelPage } from '../LogicalModel/ViewLogicalModelPage';
|
||||
import { LogicalModelPermissionsPage } from '../LogicalModelPermissions/LogicalModelPermissionsPage';
|
||||
|
||||
export const LogicalModelTabs = ({
|
||||
defaultValue,
|
||||
source,
|
||||
name,
|
||||
}: {
|
||||
defaultValue: 'logical-model' | 'logical-model-permissions';
|
||||
source: string;
|
||||
name: string;
|
||||
}) => {
|
||||
return (
|
||||
<Tabs
|
||||
data-testid="logical-model-tabs"
|
||||
defaultValue={defaultValue}
|
||||
items={[
|
||||
{
|
||||
value: 'logical-model',
|
||||
label: `Logical Model`,
|
||||
content: <ViewLogicalModelPage source={source} name={name} />,
|
||||
},
|
||||
{
|
||||
value: 'logical-model-permissions',
|
||||
label: `Permissions`,
|
||||
content: <LogicalModelPermissionsPage source={source} name={name} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export { LogicalModelPermissionsRoute } from './LogicalModelPermissions/LogicalModelPermissionsPage';
|
||||
export { ViewLogicalModelRoute } from './LogicalModel/ViewLogicalModelPage';
|
@ -1,7 +1,20 @@
|
||||
export type ScalarFieldType = {
|
||||
scalar: string;
|
||||
nullable: boolean;
|
||||
};
|
||||
|
||||
export type LogicalModelType = {
|
||||
logical_model: string;
|
||||
nullable: boolean;
|
||||
};
|
||||
|
||||
export type ArrayLogicalModelType = {
|
||||
array: LogicalModelType;
|
||||
};
|
||||
|
||||
export type LogicalModelField = {
|
||||
name: string;
|
||||
nullable: boolean;
|
||||
type: string;
|
||||
type: ScalarFieldType | LogicalModelType | ArrayLogicalModelType;
|
||||
};
|
||||
|
||||
export type LogicalModel = {
|
||||
|
@ -0,0 +1,24 @@
|
||||
import {
|
||||
ArrayLogicalModelType,
|
||||
LogicalModelField,
|
||||
LogicalModelType,
|
||||
ScalarFieldType,
|
||||
} from './logicalModel';
|
||||
|
||||
export const isScalarFieldType = (
|
||||
fieldType: LogicalModelField['type']
|
||||
): fieldType is ScalarFieldType => {
|
||||
return 'scalar' in fieldType;
|
||||
};
|
||||
|
||||
export const isLogicalModelType = (
|
||||
fieldType: LogicalModelField['type']
|
||||
): fieldType is LogicalModelType => {
|
||||
return 'logical_model' in fieldType;
|
||||
};
|
||||
|
||||
export const isArrayLogicalModelType = (
|
||||
fieldType: LogicalModelField['type']
|
||||
): fieldType is ArrayLogicalModelType => {
|
||||
return 'array' in fieldType;
|
||||
};
|
@ -0,0 +1,41 @@
|
||||
import { getWorker } from 'msw-storybook-addon';
|
||||
import { MockedRequest, matchRequestUrl } from 'msw';
|
||||
|
||||
// https://mswjs.io/docs/extensions/life-cycle-events#asserting-request-payload
|
||||
export function waitForRequest(method: string, url: string, suffix: string) {
|
||||
const worker = getWorker();
|
||||
let requestId = '';
|
||||
|
||||
return new Promise<MockedRequest>((resolve, reject) => {
|
||||
worker.events.on('request:start', async req => {
|
||||
const matchesMethod = req.method.toLowerCase() === method.toLowerCase();
|
||||
const matchesUrl = matchRequestUrl(req.url, url).matches;
|
||||
try {
|
||||
// Clone to avoid "locked body stream" error
|
||||
// https://stackoverflow.com/a/54115314
|
||||
const body = await req.clone().json();
|
||||
const matchesSuffix = body.type.endsWith(suffix);
|
||||
|
||||
if (matchesMethod && matchesUrl && matchesSuffix) {
|
||||
requestId = req.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
worker.events.on('request:match', req => {
|
||||
if (req.id === requestId) {
|
||||
resolve(req);
|
||||
}
|
||||
});
|
||||
|
||||
worker.events.on('request:unhandled', req => {
|
||||
if (req.id === requestId) {
|
||||
reject(
|
||||
new Error(`The ${req.method} ${req.url.href} request was unhandled.`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user