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:
Julian 2023-06-16 15:58:51 -03:00 committed by hasura-bot
parent b309fc9211
commit e8c67b1bfc
19 changed files with 661 additions and 129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { LogicalModelPermissionsRoute } from './LogicalModelPermissions/LogicalModelPermissionsPage';
export { ViewLogicalModelRoute } from './LogicalModel/ViewLogicalModelPage';

View File

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

View File

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

View File

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