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 { TableInsertItemContainer } from './TableInsertItem/TableInsertItemContainer';
import { ModifyTableContainer } from './TableModify/ModifyTableContainer'; import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage'; import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route'; 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 { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
import { import {
UpdateNativeQueryRoute, UpdateNativeQueryRoute,
@ -95,6 +97,7 @@ const makeDataRouter = (
<IndexRoute component={NativeQueries} /> <IndexRoute component={NativeQueries} />
<Redirect from=":source" to="/data/native-queries/logical-models" /> <Redirect from=":source" to="/data/native-queries/logical-models" />
<Route path=":source"> <Route path=":source">
<Route path=":name" component={ViewLogicalModelRoute} />
<Route <Route
path=":name/permissions" path=":name/permissions"
component={LogicalModelPermissionsRoute} component={LogicalModelPermissionsRoute}

View File

@ -169,7 +169,7 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
isLoading={isLoading} isLoading={isLoading}
onEditClick={model => { onEditClick={model => {
push?.( push?.(
`/data/native-queries/logical-models/${model.source.name}/${model.name}/permissions` `/data/native-queries/logical-models/${model.source.name}/${model.name}`
); );
}} }}
onRemoveClick={handleRemoveLogicalModel} onRemoveClick={handleRemoveLogicalModel}

View File

@ -8,6 +8,8 @@ import Skeleton from 'react-loading-skeleton';
import { Button } from '../../../../../new-components/Button'; import { Button } from '../../../../../new-components/Button';
import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable'; import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable';
import { LogicalModelWithSource } from '../../types'; import { LogicalModelWithSource } from '../../types';
import { CgDetailsMore } from 'react-icons/cg';
import { FaTrash } from 'react-icons/fa';
const columnHelper = createColumnHelper<LogicalModelWithSource>(); const columnHelper = createColumnHelper<LogicalModelWithSource>();
@ -39,11 +41,15 @@ export const ListLogicalModels = ({
header: 'Actions', header: 'Actions',
cell: ({ cell, row }) => ( cell: ({ cell, row }) => (
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Button onClick={() => onEditClick(row.original)}> <Button
Edit Permissions icon={<CgDetailsMore />}
onClick={() => onEditClick(row.original)}
>
View
</Button> </Button>
<Button <Button
mode="destructive" mode="destructive"
icon={<FaTrash />}
onClick={() => onRemoveClick(row.original)} onClick={() => onRemoveClick(row.original)}
> >
Remove 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: [], tables: [],
}, },
fields: [ 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', name: 'hello_world',
}, },
@ -47,8 +59,20 @@ const existingPermissionArgs: Partial<
tables: [], tables: [],
}, },
fields: [ 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', name: 'hello_world',
select_permissions: [ select_permissions: [

View File

@ -1,6 +1,5 @@
import { InjectedRouter, withRouter } from 'react-router'; import { InjectedRouter, withRouter } from 'react-router';
import { RouteWrapper } from '../components/RouteWrapper'; import { RouteWrapper } from '../components/RouteWrapper';
import { Tabs } from '../../../../new-components/Tabs';
import { LogicalModelPermissions } from './LogicalModelPermissions'; import { LogicalModelPermissions } from './LogicalModelPermissions';
import { useCreateLogicalModelsPermissions } from './hooks/useCreateLogicalModelsPermissions'; import { useCreateLogicalModelsPermissions } from './hooks/useCreateLogicalModelsPermissions';
import { useRemoveLogicalModelsPermissions } from './hooks/useRemoveLogicalModelsPermissions'; 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 { usePermissionComparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/hooks/usePermissionComparators';
import Skeleton from 'react-loading-skeleton'; import Skeleton from 'react-loading-skeleton';
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors'; import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
import { LogicalModelTabs } from '../components/LogicalModelTabs';
export const LogicalModelPermissionsPage = ({ export const LogicalModelPermissionsPage = ({
source, source,
@ -32,19 +32,12 @@ export const LogicalModelPermissionsPage = ({
logicalModels, logicalModels,
source: logicalModel?.source, source: logicalModel?.source,
}); });
return ( return (
<Tabs <div
className="mt-md"
// Recreate the key when the logical model permissions change to reset the form // Recreate the key when the logical model permissions change to reset the form
key={logicalModel?.select_permissions?.length} 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 ? ( {isLoading ? (
<div <div
className="flex items-center justify-center h-64" className="flex items-center justify-center h-64"
@ -80,10 +73,6 @@ export const LogicalModelPermissionsPage = ({
/> />
)} )}
</div> </div>
),
},
]}
/>
); );
}; };
@ -103,7 +92,11 @@ export const LogicalModelPermissionsRoute = withRouter<{
itemSourceName={params.source} itemSourceName={params.source}
itemName={params.name} itemName={params.name}
> >
<LogicalModelPermissionsPage source={params.source} name={params.name} /> <LogicalModelTabs
source={params.source}
name={params.name}
defaultValue={'logical-model-permissions'}
/>
</RouteWrapper> </RouteWrapper>
); );
}); });

View File

@ -1,36 +1,27 @@
import { StoryObj, Meta } from '@storybook/react'; import { StoryObj, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query'; import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { LogicalModelWidget } from './LogicalModelWidget'; import { LogicalModelWidget } from './LogicalModelWidget';
import { ReduxDecorator } from '../../../../storybook/decorators/redux-decorator';
import { fireEvent, userEvent, within } from '@storybook/testing-library'; import { fireEvent, userEvent, within } from '@storybook/testing-library';
import { handlers } from './mocks/handlers'; import { handlers } from './mocks/handlers';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { defaultEmptyValues } from './validationSchema'; import { defaultEmptyValues } from './validationSchema';
import { waitForRequest } from '../../../../storybook/utils/waitForRequest';
export default { export default {
component: LogicalModelWidget, component: LogicalModelWidget,
decorators: [ decorators: [ReactQueryDecorator()],
ReactQueryDecorator(),
ReduxDecorator({
tables: {
dataHeaders: {
'x-hasura-admin-secret': 'myadminsecretkey',
} as any,
},
}),
],
} as Meta<typeof LogicalModelWidget>; } as Meta<typeof LogicalModelWidget>;
export const DefaultView: StoryObj<typeof LogicalModelWidget> = { export const DefaultView: StoryObj<typeof LogicalModelWidget> = {
render: () => <LogicalModelWidget />,
parameters: { parameters: {
msw: handlers['200'], msw: handlers['200'],
}, },
}; };
export const DialogVariant: StoryObj<typeof LogicalModelWidget> = { export const DialogVariant: StoryObj<typeof LogicalModelWidget> = {
render: () => <LogicalModelWidget asDialog />, args: {
asDialog: true,
},
parameters: { parameters: {
msw: handlers['200'], msw: handlers['200'],
@ -39,17 +30,15 @@ export const DialogVariant: StoryObj<typeof LogicalModelWidget> = {
export const PreselectedAndDisabledInputs: StoryObj<typeof LogicalModelWidget> = export const PreselectedAndDisabledInputs: StoryObj<typeof LogicalModelWidget> =
{ {
render: () => ( args: {
<LogicalModelWidget defaultValues: {
defaultValues={{
...defaultEmptyValues, ...defaultEmptyValues,
dataSourceName: 'chinook', dataSourceName: 'chinook',
}} },
disabled={{ disabled: {
dataSourceName: true, dataSourceName: true,
}} },
/> },
),
parameters: { parameters: {
msw: handlers['200'], msw: handlers['200'],
@ -57,8 +46,6 @@ export const PreselectedAndDisabledInputs: StoryObj<typeof LogicalModelWidget> =
}; };
export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = { export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
render: () => <LogicalModelWidget />,
name: '🧪 Basic user flow', name: '🧪 Basic user flow',
parameters: { parameters: {
@ -79,14 +66,17 @@ export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id'); await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
await userEvent.selectOptions( await userEvent.selectOptions(
canvas.getByTestId('fields[0].type'), canvas.getByTestId('fields-input-type-0'),
'integer' 'scalar:integer'
); );
fireEvent.click(canvas.getByText('Add Field')); fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name'); 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')); fireEvent.click(canvas.getByText('Create Logical Model'));
@ -97,7 +87,6 @@ export const BasicUserFlow: StoryObj<typeof LogicalModelWidget> = {
}; };
export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = { export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
render: () => <LogicalModelWidget />,
name: '🧪 Network Error On Submit', name: '🧪 Network Error On Submit',
parameters: { parameters: {
@ -118,14 +107,17 @@ export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id'); await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
await userEvent.selectOptions( await userEvent.selectOptions(
canvas.getByTestId('fields[0].type'), canvas.getByTestId('fields-input-type-0'),
'integer' 'scalar:integer'
); );
fireEvent.click(canvas.getByText('Add Field')); fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name'); 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')); fireEvent.click(canvas.getByText('Create Logical Model'));
@ -134,3 +126,134 @@ export const NetworkErrorOnSubmit: StoryObj<typeof LogicalModelWidget> = {
).toBeInTheDocument(); ).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, AddLogicalModelFormData,
addLogicalModelValidationSchema, addLogicalModelValidationSchema,
} from './validationSchema'; } 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'; import { useEnvironmentState } from '../../../ConnectDBRedesign/hooks';
export type AddLogicalModelDialogProps = { export type AddLogicalModelDialogProps = {
defaultValues?: AddLogicalModelFormData; defaultValues?: AddLogicalModelFormData;
onCancel?: () => void; onCancel?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
disabled?: CreateBooleanMap<AddLogicalModelFormData>; disabled?: CreateBooleanMap<
AddLogicalModelFormData & {
callToAction?: boolean;
}
>;
asDialog?: 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) => { const onSubmit = (data: AddLogicalModelFormData) => {
trackLogicalModel({ trackLogicalModel({
data, data: {
dataSourceName: data.dataSourceName,
name: data.name,
fields: data.fields.map(formFieldToLogicalModelField),
},
onSuccess: () => { onSuccess: () => {
hasuraToast({ hasuraToast({
type: 'success', type: 'success',
@ -116,18 +133,38 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
return isMetadataLoading || isIntrospectionLoading ? ( return isMetadataLoading || isIntrospectionLoading ? (
<Skeleton count={8} height={20} /> <Skeleton count={8} height={20} />
) : ( ) : (
<>
{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}> <Form onSubmit={onSubmit}>
<LogicalModelFormInputs <LogicalModelFormInputs
sourceOptions={sourceOptions} sourceOptions={sourceOptions}
typeOptions={typeOptions} typeOptions={typeOptions}
disabled={props.disabled} disabled={props.disabled}
logicalModels={logicalModels}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" mode="primary" isLoading={isLoading}> <Button
disabled={props.disabled?.callToAction}
type="submit"
mode="primary"
isLoading={isLoading}
>
{isEditMode ? 'Edit Logical Model' : 'Create Logical Model'} {isEditMode ? 'Edit Logical Model' : 'Create Logical Model'}
</Button> </Button>
</div> </div>
</Form> </Form>
</>
); );
return ( return (
@ -160,6 +197,7 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
sourceOptions={sourceOptions} sourceOptions={sourceOptions}
typeOptions={typeOptions} typeOptions={typeOptions}
disabled={props.disabled} disabled={props.disabled}
logicalModels={logicalModels}
/> />
</Form> </Form>
)} )}

View File

@ -10,7 +10,6 @@ import { FaPlusCircle } from 'react-icons/fa';
import { Button } from '../../../../../new-components/Button'; import { Button } from '../../../../../new-components/Button';
import { import {
GraphQLSanitizedInputField, GraphQLSanitizedInputField,
Select,
fieldLabelStyles, fieldLabelStyles,
} from '../../../../../new-components/Form'; } from '../../../../../new-components/Form';
import { BooleanInput } from '../../components/BooleanInput'; import { BooleanInput } from '../../components/BooleanInput';
@ -19,6 +18,7 @@ import {
AddLogicalModelField, AddLogicalModelField,
AddLogicalModelFormData, AddLogicalModelFormData,
} from '../validationSchema'; } from '../validationSchema';
import { LogicalModel } from '../../../../hasura-metadata-types';
const columnHelper = createColumnHelper<AddLogicalModelField>(); const columnHelper = createColumnHelper<AddLogicalModelField>();
@ -26,12 +26,15 @@ export const FieldsInput = ({
name, name,
types, types,
disabled, disabled,
logicalModels,
}: { }: {
name: string; name: string;
types: string[]; types: string[];
disabled?: boolean; disabled?: boolean;
logicalModels: LogicalModel[];
}) => { }) => {
const { control } = useFormContext<AddLogicalModelFormData>(); const { control, setValue, watch } =
useFormContext<AddLogicalModelFormData>();
const { append, remove, fields } = useFieldArray({ const { append, remove, fields } = useFieldArray({
control, control,
@ -58,18 +61,58 @@ export const FieldsInput = ({
}), }),
columnHelper.accessor('type', { columnHelper.accessor('type', {
id: 'type', id: 'type',
cell: ({ row }) => ( cell: ({ row }) => {
<Select const typeValue = watch(`fields.${row.index}.type`);
noErrorPlaceholder const typeClassValue = watch(`fields.${row.index}.typeClass`);
dataTestId={`${name}[${row.index}].type`} return (
name={`fields.${row.index}.type`} <select
options={types.map(t => ({ label: t, value: t }))} 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} 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', header: 'Type',
}), }),
columnHelper.accessor('nullable', { columnHelper.accessor('nullable', {
id: 'nullable', id: 'nullable',
cell: ({ row }) => ( cell: ({ row }) => (
@ -80,6 +123,20 @@ export const FieldsInput = ({
), ),
header: 'Nullable', 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({ columnHelper.display({
id: 'action', id: 'action',
header: 'Actions', header: 'Actions',
@ -115,9 +172,15 @@ export const FieldsInput = ({
<div className={clsx(fieldLabelStyles, 'mb-0')}>Fields</div> <div className={clsx(fieldLabelStyles, 'mb-0')}>Fields</div>
<Button <Button
icon={<FaPlusCircle />} icon={<FaPlusCircle />}
disabled={!types || types.length === 0} disabled={disabled || !types || types.length === 0}
onClick={() => { onClick={() => {
append({ name: '', type: 'text', nullable: true }); append({
name: '',
type: 'text',
typeClass: 'scalar',
nullable: true,
array: false,
});
}} }}
> >
Add Field Add Field

View File

@ -23,7 +23,11 @@ export default {
export const Basic: StoryFn<typeof LogicalModelFormInputs> = () => ( export const Basic: StoryFn<typeof LogicalModelFormInputs> = () => (
<SimpleForm schema={addLogicalModelValidationSchema} onSubmit={() => {}}> <SimpleForm schema={addLogicalModelValidationSchema} onSubmit={() => {}}>
<LogicalModelFormInputs sourceOptions={[]} typeOptions={[]} /> <LogicalModelFormInputs
logicalModels={[]}
sourceOptions={[]}
typeOptions={[]}
/>
</SimpleForm> </SimpleForm>
); );
@ -39,10 +43,12 @@ export const WithDefaultValues: StoryObj<typeof LogicalModelFormInputs> = {
{ {
name: 'id', name: 'id',
type: 'int', type: 'int',
typeClass: 'scalar',
}, },
{ {
name: 'first_name', name: 'first_name',
type: 'text', type: 'text',
typeClass: 'scalar',
}, },
], ],
name: 'foobar', name: 'foobar',
@ -51,6 +57,7 @@ export const WithDefaultValues: StoryObj<typeof LogicalModelFormInputs> = {
onSubmit={() => {}} onSubmit={() => {}}
> >
<LogicalModelFormInputs <LogicalModelFormInputs
logicalModels={[]}
sourceOptions={[{ value: 'chinook', label: 'chinook' }]} sourceOptions={[{ value: 'chinook', label: 'chinook' }]}
typeOptions={['text', 'int']} 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('name')).toHaveValue('foobar');
await expect(await canvas.findByTestId('fields[0].name')).toHaveValue('id'); await expect(await canvas.findByTestId('fields[0].name')).toHaveValue('id');
await expect(await canvas.findByTestId('fields[0].type')).toHaveValue( await expect(await canvas.findByTestId('fields-input-type-0')).toHaveValue(
'int' 'scalar:int'
); );
await expect(await canvas.findByTestId('fields[1].name')).toHaveValue( await expect(await canvas.findByTestId('fields[1].name')).toHaveValue(
'first_name' 'first_name'
); );
await expect(await canvas.findByTestId('fields[1].type')).toHaveValue( await expect(await canvas.findByTestId('fields-input-type-1')).toHaveValue(
'text' 'scalar:text'
); );
}, },
}; };

View File

@ -4,6 +4,7 @@ import {
GraphQLSanitizedInputField, GraphQLSanitizedInputField,
Select, Select,
} from '../../../../../new-components/Form'; } from '../../../../../new-components/Form';
import { LogicalModel } from '../../../../hasura-metadata-types';
import { AddLogicalModelFormData } from '../validationSchema'; import { AddLogicalModelFormData } from '../validationSchema';
import { FieldsInput } from './FieldsInput'; import { FieldsInput } from './FieldsInput';
@ -11,6 +12,7 @@ export type LogicalModelFormProps = {
sourceOptions: SelectItem[]; sourceOptions: SelectItem[];
typeOptions: string[]; typeOptions: string[];
disabled?: CreateBooleanMap<AddLogicalModelFormData>; disabled?: CreateBooleanMap<AddLogicalModelFormData>;
logicalModels: LogicalModel[];
}; };
export const LogicalModelFormInputs = (props: LogicalModelFormProps) => { export const LogicalModelFormInputs = (props: LogicalModelFormProps) => {
@ -36,6 +38,7 @@ export const LogicalModelFormInputs = (props: LogicalModelFormProps) => {
name="fields" name="fields"
types={props.typeOptions} types={props.typeOptions}
disabled={props.disabled?.fields} 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({ .object({
name: z.string().min(1, 'Field Name is a required field'), name: z.string().min(1, 'Field Name is a required field'),
type: z.string().min(1, 'Type 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' }), nullable: z.boolean({ required_error: 'Nullable is a required field' }),
}) })
.array(), .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 = { export type LogicalModelField = {
name: string; name: string;
nullable: boolean; type: ScalarFieldType | LogicalModelType | ArrayLogicalModelType;
type: string;
}; };
export type LogicalModel = { 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.`)
);
}
});
});
}