fix (dashboard): Show errors in row permission rule form (#3471)

### **PR Type**
Bug fix, Tests, Enhancement


___

### **Description**
- Improve row permission form error handling

- Integrate `FormField` for validation feedback

- Update tests using `TestUserEvent.fireClickEvent`

- Extend MSW mocks for metadata and table queries


___



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Tests</strong></td><td><details><summary>5
files</summary><table>
<tr>
<td><strong>TransferProjectDialog.test.tsx</strong><dd><code>Replace
`asyncFireEvent` with `TestUserEvent.fireClickEvent`</code></dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-d4ebdb8af76a7c9e73606708718c3448445545259ad553d73b6d322408e3eb8c">+3/-16</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>RowPermissionSection.test.tsx</strong><dd><code>Add
comprehensive tests for row permissions section</code>&nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-2a32fbb9eda12ec8eb93746c5c8b171e8ae20d18e661a5e2eb0c4996fee8376b">+211/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>hasuraMetadataQuery.ts</strong><dd><code>Add
`hasuraColumnMetadataQuery` mock endpoint</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-2828f4a1163f0d281abf2517e76fc9dd393bb870478aea874019a42f9c4b7ac3">+260/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>tableQuery.ts</strong><dd><code>Extend actor table mock with
column and row data</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-fdb6ad2a7e58c374f3a6772219e7f7e72ca2927def74ec75893b064caba12639">+40/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>testUtils.tsx</strong><dd><code>Add `fireClickEvent` helper
to `TestUserEvent`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-78f29250407edf853a353b48242d3cee59aa5724f38a60bb23bebdfc1ea2f9b5">+13/-0</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>4
files</summary><table>
<tr>
<td><strong>ColumnAutocomplete.tsx</strong><dd><code>Add `className`
prop and merge via `cn`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-c89efa530042890e7d6277c2e3c763cb7c9b9fc1d7c14c62839f4cf7c42528f7">+6/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>RowPermissionsSection.tsx</strong><dd><code>Refactor filter
logic and default row check type</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-663956d9adae1f6255151599b1cbd6ad03fea1246e87ab89329fcddcdbec2b20">+12/-28</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>RuleEditorRow.tsx</strong><dd><code>Wrap column input with
`FormField` and error styling</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-a7a1d2aa882735a2b9cfb41e95b05c6777d706570eec5deec6bf5d2381a51252">+47/-28</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>RuleValueInput.tsx</strong><dd><code>Introduce
`RuleInputWrapper` with validation messages</code>&nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-e3198b245b5963e81e4566758b7d60c8d2784a7ca0ad0b17b354b33074ef1bb0">+43/-6</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Bug
fix</strong></td><td><details><summary>1 files</summary><table>
<tr>
<td><strong>OperatorComboBox.tsx</strong><dd><code>Reset value and clear
errors on operator change</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-bf3aa91fe39fe48522262f0f908b7d151ce75cb005ec50fe38c2429d0e81ddb1">+4/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Configuration
changes</strong></td><td><details><summary>1 files</summary><table>
<tr>
<td><strong>vitest.config.ts</strong><dd><code>Enable silent logging in
Vitest config</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-09548f3bfb7c005a1d2f3d9d7f1f5d00c608d821572250400d92eda63ae7251a">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>brave-fans-sit.md</strong><dd><code>Add changeset for
dashboard patch release</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3471/files#diff-25c255427ffb291f4e9d7ab56622f3fee8bc9ea2ca0b38242d9b7e41273bea88">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___
This commit is contained in:
robertkasza
2025-09-16 10:45:39 +02:00
committed by GitHub
parent ba3c49e443
commit 73a7ba82ae
12 changed files with 633 additions and 85 deletions

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': patch
---
fix (dashboard): Show errors in row permission rule form

View File

@@ -11,7 +11,6 @@ import { prefetchNewAppQuery } from '@/tests/msw/mocks/graphql/prefetchNewAppQue
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import {
createGraphqlMockResolver,
fireEvent,
mockPointerEvent,
queryClient,
render,
@@ -82,18 +81,6 @@ vi.mock('next/router', () => ({
useRouter: mocks.useRouter,
}));
async function asyncFireEvent(element: Document | Element | Window | Node) {
await waitFor(() => {
fireEvent(
element,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
});
}
export function DialogWrapper({
defaultOpen = true,
}: {
@@ -155,7 +142,7 @@ describe('TransferProjectDialog', () => {
const submitButton = await screen.findByText('Continue');
expect(submitButton).toHaveTextContent('Continue');
asyncFireEvent(submitButton);
await TestUserEvent.fireClickEvent(submitButton);
await waitFor(() => {
expect(submitButton).not.toBeInTheDocument();
@@ -164,7 +151,7 @@ describe('TransferProjectDialog', () => {
const newOrgTitle = await screen.findByText('New Organization');
expect(newOrgTitle).toBeInTheDocument();
const closeButton = await screen.findByText('Close');
asyncFireEvent(closeButton);
await TestUserEvent.fireClickEvent(closeButton);
await waitFor(() => {
expect(newOrgTitle).not.toBeInTheDocument();
});
@@ -200,7 +187,7 @@ describe('TransferProjectDialog', () => {
const closeButton = await screen.findByText('Close');
asyncFireEvent(closeButton);
await TestUserEvent.fireClickEvent(closeButton);
await waitFor(() => {});
expect(closeButton).toBeInTheDocument();

View File

@@ -53,6 +53,10 @@ export interface ColumnAutocompleteProps
* Determines if the autocomplete should allow relationships.
*/
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
/**
* Custom classes
*/
className?: string;
}
function ColumnAutocomplete(
@@ -62,6 +66,7 @@ function ColumnAutocomplete(
value: externalValue,
disableRelationships,
onChange,
className,
onInitialized,
}: ColumnAutocompleteProps,
ref: ForwardedRef<HTMLButtonElement>,
@@ -211,7 +216,7 @@ function ColumnAutocomplete(
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
className={cn('w-full justify-between', className)}
>
{buttonPrefix ? (
<div className="flex min-w-0 flex-shrink items-center gap-0">

View File

@@ -1,2 +1,3 @@
export * from './EditPermissionsForm';
export { default as EditPermissionsForm } from './EditPermissionsForm';
export { default as editPermissionFormValidationSchemas } from './validationSchemas';

View File

@@ -0,0 +1,211 @@
import { Form } from '@/components/form/Form';
import { editPermissionFormValidationSchemas } from '@/features/orgs/projects/database/dataGrid/components/EditPermissionsForm';
import type { RolePermissionEditorFormValues } from '@/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/RolePermissionEditorForm';
import type { DatabaseAction } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
import { hasuraColumnMetadataQuery } from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
import {
mockPointerEvent,
render,
screen,
TestUserEvent,
waitFor,
} from '@/tests/testUtils';
import { yupResolver } from '@hookform/resolvers/yup';
import { setupServer } from 'msw/node';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { describe, vi } from 'vitest';
import RowPermissionsSection from './RowPermissionsSection';
mockPointerEvent();
const mocks = vi.hoisted(() => ({
useRouter: vi.fn(),
onSubmit: vi.fn(),
}));
vi.mock('next/router', () => ({
useRouter: mocks.useRouter,
}));
function TestWrapper({
children,
defaultValues,
action,
}: {
children: ReactNode;
defaultValues?: Partial<RolePermissionEditorFormValues>;
action: DatabaseAction;
}) {
const methods = useForm<RolePermissionEditorFormValues>({
defaultValues: {
filter: {},
limit: null,
...defaultValues,
},
resolver: yupResolver(editPermissionFormValidationSchemas[action]),
});
return (
<FormProvider {...methods}>
<Form onSubmit={mocks.onSubmit}>{children}</Form>
</FormProvider>
);
}
function renderRowPermissionsSection(
props: Partial<{
role: string;
action: DatabaseAction;
schema: string;
table: string;
disabled: boolean;
}> = {},
formDefaultValues?: Partial<RolePermissionEditorFormValues>,
) {
const defaultProps = {
role: 'user',
action: 'insert' as DatabaseAction,
schema: 'public',
table: 'actor',
disabled: false,
...props,
};
const { action } = props;
return render(
<TestWrapper defaultValues={formDefaultValues} action={action!}>
<RowPermissionsSection {...defaultProps} />
<button type="submit" data-testid="submitButton">
Submit
</button>
</TestWrapper>,
);
}
const server = setupServer(
tableQuery,
hasuraColumnMetadataQuery,
getProjectQuery,
permissionVariablesQuery,
);
function getRouter() {
return {
basePath: '',
pathname: '/orgs/xyz/projects/test-project',
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
asPath: '/orgs/xyz/projects/test-project',
isLocaleDomain: false,
isReady: true,
isPreview: false,
query: {
orgSlug: 'xyz',
appSubdomain: 'test-project',
dataSourceSlug: 'default',
},
push: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
beforePopState: vi.fn(),
events: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
isFallback: false,
};
}
describe('RowPermissionsSection', () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_ENV = 'dev';
server.listen();
});
afterAll(() => {
server.close();
});
it('the Without any checks selected when there are no filters', () => {
mocks.useRouter.mockImplementation(() => getRouter());
renderRowPermissionsSection();
expect(screen.getByLabelText('Without any checks')).toBeChecked();
});
it('the Without any checks NOT selected when there are filters', () => {
mocks.useRouter.mockImplementation(() => getRouter());
waitFor(async () => {
renderRowPermissionsSection(undefined, {
filter: {
rules: [{ column: 'id', operator: '_eq', value: 'x-hasura-user-id' }],
},
});
});
expect(screen.getByLabelText('Without any checks')).not.toBeChecked();
expect(screen.getByLabelText('With custom check')).toBeChecked();
});
it('should show validation errors and clear on column and operator change', async () => {
mocks.useRouter.mockImplementation(() => getRouter());
renderRowPermissionsSection({ action: 'insert' });
await TestUserEvent.fireClickEvent(
screen.getByLabelText('With custom check'),
);
expect(screen.getByLabelText('With custom check')).toBeChecked();
expect(await screen.findByText('Select a column')).toBeInTheDocument();
// await TestUserEvent.fireClickEvent(screen.getByText('Select a column'));
await TestUserEvent.fireClickEvent(screen.getByTestId('submitButton'));
expect(screen.getByText('Please select a column.')).toBeInTheDocument();
expect(screen.getByText('Please enter a value.')).toBeInTheDocument();
});
it('should clear errors when columns or operator changes', async () => {
mocks.useRouter.mockImplementation(() => getRouter());
renderRowPermissionsSection({ action: 'insert' });
await TestUserEvent.fireClickEvent(
screen.getByLabelText('With custom check'),
);
expect(screen.getByLabelText('With custom check')).toBeChecked();
expect(await screen.findByText('Select a column')).toBeInTheDocument();
await TestUserEvent.fireClickEvent(screen.getByTestId('submitButton'));
expect(screen.getByText('Please select a column.')).toBeInTheDocument();
expect(screen.getByText('Please enter a value.')).toBeInTheDocument();
await TestUserEvent.fireClickEvent(screen.getByText('Select a column'));
const idOption = screen.getByText('id');
await TestUserEvent.fireClickEvent(idOption);
expect(idOption).not.toBeInTheDocument();
const selectedColumn = screen.getByText('id');
expect(selectedColumn).toBeInTheDocument();
expect(
screen.queryByText('Please select a column.'),
).not.toBeInTheDocument();
expect(screen.queryByText('Please enter a value.')).not.toBeInTheDocument();
await TestUserEvent.fireClickEvent(screen.getByTestId('submitButton'));
expect(screen.getByText('Please enter a value.')).toBeInTheDocument();
await TestUserEvent.fireClickEvent(screen.getByText('_eq'));
await TestUserEvent.fireClickEvent(screen.getByText('_is_null'));
expect(screen.getByText('Is null?')).toBeInTheDocument();
expect(screen.queryByText('Please enter a value.')).not.toBeInTheDocument();
});
});

View File

@@ -5,10 +5,8 @@ import { RadioGroup } from '@/components/ui/v2/RadioGroup';
import { Text } from '@/components/ui/v2/Text';
import type { RolePermissionEditorFormValues } from '@/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/RolePermissionEditorForm';
import { RuleGroupEditor } from '@/features/orgs/projects/database/dataGrid/components/RuleGroupEditor';
import type {
DatabaseAction,
RuleGroup,
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import type { DatabaseAction } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { isNotEmptyValue } from '@/lib/utils';
import type { FocusEvent, ReactNode } from 'react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
@@ -53,44 +51,30 @@ export default function RowPermissionsSection({
const { filter } = getValues();
const defaultRowCheckType =
filter &&
'rules' in filter &&
'groups' in filter &&
(filter.rules.length > 0 ||
filter.groups.length > 0 ||
filter.unsupported?.length > 0)
isNotEmptyValue(filter?.rules) ||
isNotEmptyValue(filter?.groups) ||
isNotEmptyValue(filter?.unsupported)
? 'custom'
: 'none';
const [temporaryPermissions, setTemporaryPermissions] =
useState<RuleGroup | null>(null);
const [rowCheckType, setRowCheckType] = useState<'none' | 'custom' | null>(
filter ? defaultRowCheckType : null,
const [rowCheckType, setRowCheckType] = useState<'none' | 'custom'>(
defaultRowCheckType,
);
function handleCheckTypeChange(value: typeof rowCheckType) {
setRowCheckType(value);
if (value === 'none') {
setTemporaryPermissions(getValues().filter as RuleGroup);
// Note: https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
// @ts-ignore
setValue('filter', {});
return;
}
setRowCheckType(value);
setValue(
'filter',
temporaryPermissions || {
} else {
setValue('filter', {
operator: '_and',
rules: [{ column: '', operator: '_eq', value: '' }],
rules: [{ column: null, operator: '_eq', value: null }],
groups: [],
},
);
});
}
}
return (

View File

@@ -41,7 +41,7 @@ const ruleGroupSchema = Yup.object().shape({
});
const baseValidationSchema = Yup.object().shape({
filter: ruleGroupSchema.nullable().required('Please select a filter type.'),
filter: ruleGroupSchema.nullable(),
columns: Yup.array().of(Yup.string()).nullable(),
});

View File

@@ -66,7 +66,7 @@ export default function OperatorComboBox({
selectedColumnType,
}: OperatorComboBoxProps) {
const [open, setOpen] = useState(false);
const { watch, setValue } = useFormContext();
const { watch, setValue, clearErrors } = useFormContext();
const operator = watch(`${name}.operator`);
@@ -76,11 +76,10 @@ export default function OperatorComboBox({
];
const handleSelect = (value: string) => {
if (['_in', '_nin'].includes(value)) {
setValue(`${name}.value`, [], { shouldDirty: true });
}
const newValue = ['_in', '_nin'].includes(value) ? [] : null;
setValue(`${name}.value`, newValue, { shouldDirty: true });
setValue(`${name}.operator`, value, { shouldDirty: true });
clearErrors();
setOpen(false);
};

View File

@@ -1,4 +1,6 @@
import { FormField, FormMessage } from '@/components/ui/v3/form';
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
import { cn, isNotEmptyValue } from '@/lib/utils';
import type { DetailedHTMLProps, HTMLProps } from 'react';
import { useState } from 'react';
import { useController, useFormContext } from 'react-hook-form';
@@ -32,7 +34,7 @@ export default function RuleEditorRow({
...props
}: RuleEditorRowProps) {
const { schema, table } = useRuleGroupEditor();
const { control, setValue } = useFormContext();
const { control, setValue, clearErrors } = useFormContext();
const rowName = `${name}.rules.${index}`;
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
@@ -50,38 +52,55 @@ export default function RuleEditorRow({
)}
{...props}
>
<ColumnAutocomplete
{...autocompleteField}
schema={schema}
table={table}
onChange={({ value, columnMetadata, disableReset }) => {
setSelectedTablePath(
`${columnMetadata?.table_schema}.${columnMetadata?.table_name}`,
);
setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
<FormField
name={`${rowName}.column`}
control={control}
render={({ fieldState }) => {
const hasError = isNotEmptyValue(fieldState.error?.message);
return (
<div className="flex flex-col gap-2">
<ColumnAutocomplete
{...autocompleteField}
schema={schema}
table={table}
className={cn({
'border-destructive text-destructive': hasError,
})}
onChange={({ value, columnMetadata, disableReset }) => {
setSelectedTablePath(
`${columnMetadata?.table_schema}.${columnMetadata?.table_name}`,
);
setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
if (disableReset) {
return;
}
if (disableReset) {
return;
}
setValue(`${rowName}.operator`, '_eq', {
shouldDirty: true,
});
setValue(`${rowName}.value`, '', { shouldDirty: true });
}}
onInitialized={({ value, columnMetadata }) => {
setSelectedTablePath(
`${columnMetadata?.table_schema}.${columnMetadata?.table_name}`,
setValue(`${rowName}.operator`, '_eq', {
shouldDirty: true,
});
setValue(`${rowName}.value`, null, { shouldDirty: true });
clearErrors();
}}
onInitialized={({ value, columnMetadata }) => {
setSelectedTablePath(
`${columnMetadata?.table_schema}.${columnMetadata?.table_name}`,
);
setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
}}
/>
<FormMessage />
</div>
);
setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
}}
/>
<OperatorComboBox
name={rowName}
selectedColumnType={selectedColumnType}

View File

@@ -11,6 +11,7 @@ import {
CommandList,
} from '@/components/ui/v3/command';
import { FancyMultiSelect } from '@/components/ui/v3/fancy-multi-select';
import { FormField, FormItem, FormMessage } from '@/components/ui/v3/form';
import {
Popover,
PopoverContent,
@@ -32,7 +33,7 @@ import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { getAllPermissionVariables } from '@/features/orgs/projects/permissions/settings/utils/getAllPermissionVariables';
import { cn } from '@/lib/utils';
import { cn, isNotEmptyValue } from '@/lib/utils';
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { CommandLoading } from 'cmdk';
import { useState } from 'react';
@@ -93,7 +94,7 @@ export interface RuleValueInputProps {
selectedTablePath: string;
}
export default function RuleValueInput({
function RuleValueInput({
name,
selectedTablePath,
className,
@@ -106,7 +107,6 @@ export default function RuleValueInput({
name: inputName,
control,
});
const [open, setOpen] = useState(false);
const comboboxValue = useWatch({ name: inputName });
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
@@ -121,7 +121,9 @@ export default function RuleValueInput({
});
if (operator === '_is_null') {
const defaultValue = !Array.isArray(comboboxValue) ? comboboxValue : null;
const defaultValue = comboboxValue ?? undefined;
const triggerClasses =
'border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
return (
<Select
disabled={disabled}
@@ -131,7 +133,7 @@ export default function RuleValueInput({
}}
defaultValue={defaultValue}
>
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<SelectTrigger className={cn(triggerClasses, className)}>
<SelectValue placeholder="Is null?" />
</SelectTrigger>
<SelectContent>
@@ -181,6 +183,7 @@ export default function RuleValueInput({
schema={schema}
table={table}
name={inputName}
className={className}
/>
);
}
@@ -198,7 +201,7 @@ export default function RuleValueInput({
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between"
className={cn('w-full justify-between', className)}
>
<span className="truncate">{comboboxLabel}</span>
<ChevronsUpDown className="h-5 min-h-5 w-5 min-w-5 opacity-50" />
@@ -248,3 +251,37 @@ export default function RuleValueInput({
</Popover>
);
}
export default function RuleInputWrapper(props: RuleValueInputProps) {
const { name, className } = props;
const { control } = useFormContext();
const inputName = `${name}.value`;
return (
<FormField
name={inputName}
control={control}
render={({ fieldState }) => {
const hasError = isNotEmptyValue(fieldState.error?.message);
const mergedProps = {
...props,
className: cn(
{
'border-destructive text-destructive': hasError,
},
className,
),
};
return (
<div className="flex w-full flex-col gap-2">
<FormItem>
<RuleValueInput {...mergedProps} />
</FormItem>
<FormMessage />
</div>
);
}}
/>
);
}

View File

@@ -156,4 +156,264 @@ export const hasuraRelationShipsMetadataQuery = rest.post(
),
);
export const hasuraColumnMetadataQuery = rest.post(
'https://local.hasura.local.nhost.run/v1/metadata',
(_req, res, ctx) =>
res(
ctx.json({
resource_version: 389,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{
table: {
name: 'actor',
schema: 'public',
},
object_relationships: [
{
name: 'actor_movie',
using: {
foreign_key_constraint_on: {
column: 'actor_id',
table: {
name: 'actor_movie',
schema: 'public',
},
},
},
},
],
},
{
table: {
name: 'actor_movie',
schema: 'public',
},
object_relationships: [
{
name: 'actor',
using: {
foreign_key_constraint_on: 'actor_id',
},
},
{
name: 'movie',
using: {
foreign_key_constraint_on: 'movie_id',
},
},
],
},
{
table: {
name: 'director',
schema: 'public',
},
array_relationships: [
{
name: 'movies',
using: {
foreign_key_constraint_on: {
column: 'director_id',
table: {
name: 'movies',
schema: 'public',
},
},
},
},
],
},
{
table: {
name: 'movies',
schema: 'public',
},
object_relationships: [
{
name: 'author',
using: {
foreign_key_constraint_on: 'director_id',
},
},
],
array_relationships: [
{
name: 'actor_movie',
using: {
foreign_key_constraint_on: {
column: 'movie_id',
table: {
name: 'actor_movie',
schema: 'public',
},
},
},
},
],
},
{
table: {
name: 'notes',
schema: 'public',
},
object_relationships: [
{
name: 'user',
using: {
foreign_key_constraint_on: 'owner',
},
},
],
insert_permissions: [
{
role: 'user',
permission: {
check: {
owner: {
_eq: 'X-Hasura-User-Id',
},
},
columns: ['id', 'note', 'owner'],
},
},
],
select_permissions: [
{
role: 'user',
permission: {
columns: ['id', 'note'],
filter: {
owner: {
_eq: 'X-Hasura-User-Id',
},
},
},
},
],
update_permissions: [
{
role: 'user',
permission: {
columns: ['note'],
filter: {
owner: {
_eq: 'X-Hasura-User-Id',
},
},
check: null,
},
},
],
delete_permissions: [
{
role: 'user',
permission: {
filter: {
id: {
_eq: 'X-Hasura-User-Id',
},
},
},
},
],
},
{
table: {
name: 'buckets',
schema: 'storage',
},
configuration: {
column_config: {
cache_control: {
custom_name: 'cacheControl',
},
created_at: {
custom_name: 'createdAt',
},
download_expiration: {
custom_name: 'downloadExpiration',
},
id: {
custom_name: 'id',
},
max_upload_file_size: {
custom_name: 'maxUploadFileSize',
},
min_upload_file_size: {
custom_name: 'minUploadFileSize',
},
presigned_urls_enabled: {
custom_name: 'presignedUrlsEnabled',
},
updated_at: {
custom_name: 'updatedAt',
},
},
custom_column_names: {
cache_control: 'cacheControl',
created_at: 'createdAt',
download_expiration: 'downloadExpiration',
id: 'id',
max_upload_file_size: 'maxUploadFileSize',
min_upload_file_size: 'minUploadFileSize',
presigned_urls_enabled: 'presignedUrlsEnabled',
updated_at: 'updatedAt',
},
custom_name: 'buckets',
custom_root_fields: {
delete: 'deleteBuckets',
delete_by_pk: 'deleteBucket',
insert: 'insertBuckets',
insert_one: 'insertBucket',
select: 'buckets',
select_aggregate: 'bucketsAggregate',
select_by_pk: 'bucket',
update: 'updateBuckets',
update_by_pk: 'updateBucket',
},
},
array_relationships: [
{
name: 'files',
using: {
foreign_key_constraint_on: {
column: 'bucket_id',
table: {
name: 'files',
schema: 'storage',
},
},
},
},
],
},
],
configuration: {
connection_info: {
database_url: {
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
},
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
idle_timeout: 180,
max_connections: 50,
retries: 1,
},
use_prepared_statements: true,
},
},
},
],
},
}),
),
);
export default hasuraMetadataQuery;

View File

@@ -80,6 +80,46 @@ const tableQuery = rest.post(
);
}
if (/table_name = 'actor'/gim.exec(body.args[0].args.sql) !== null) {
return res(
ctx.json([
{
result_type: 'TuplesOk',
result: [
['row_to_json'],
[
'{"table_catalog":"klkudrtrpapfrseiidkp","table_schema":"public","table_name":"actor","column_name":"id","ordinal_position":1,"column_default":"gen_random_uuid()","is_nullable":"NO","data_type":"uuid","character_maximum_length":null,"character_octet_length":null,"numeric_precision":null,"numeric_precision_radix":null,"numeric_scale":null,"datetime_precision":null,"interval_type":null,"interval_precision":null,"character_set_catalog":null,"character_set_schema":null,"character_set_name":null,"collation_catalog":null,"collation_schema":null,"collation_name":null,"domain_catalog":null,"domain_schema":null,"domain_name":null,"udt_catalog":"klkudrtrpapfrseiidkp","udt_schema":"pg_catalog","udt_name":"uuid","scope_catalog":null,"scope_schema":null,"scope_name":null,"maximum_cardinality":null,"dtd_identifier":"1","is_self_referencing":"NO","is_identity":"NO","identity_generation":null,"identity_start":null,"identity_increment":null,"identity_maximum":null,"identity_minimum":null,"identity_cycle":"NO","is_generated":"NEVER","generation_expression":null,"is_updatable":"YES","full_data_type":"uuid","is_primary":true,"is_unique":true,"column_comment":null}',
],
[
'{"table_catalog":"klkudrtrpapfrseiidkp","table_schema":"public","table_name":"actor","column_name":"name","ordinal_position":2,"column_default":null,"is_nullable":"NO","data_type":"text","character_maximum_length":null,"character_octet_length":1073741824,"numeric_precision":null,"numeric_precision_radix":null,"numeric_scale":null,"datetime_precision":null,"interval_type":null,"interval_precision":null,"character_set_catalog":null,"character_set_schema":null,"character_set_name":null,"collation_catalog":null,"collation_schema":null,"collation_name":null,"domain_catalog":null,"domain_schema":null,"domain_name":null,"udt_catalog":"klkudrtrpapfrseiidkp","udt_schema":"pg_catalog","udt_name":"text","scope_catalog":null,"scope_schema":null,"scope_name":null,"maximum_cardinality":null,"dtd_identifier":"2","is_self_referencing":"NO","is_identity":"NO","identity_generation":null,"identity_start":null,"identity_increment":null,"identity_maximum":null,"identity_minimum":null,"identity_cycle":"NO","is_generated":"NEVER","generation_expression":null,"is_updatable":"YES","full_data_type":"text","is_primary":false,"is_unique":false,"column_comment":null}',
],
],
},
{
result_type: 'TuplesOk',
result: [
['row_to_json'],
['{"id":"1902e481-b080-4340-abe3-27b0a60973c6","name":"There"}'],
['{"id":"a486b088-50e8-41d0-88b0-5bf9a3e7b5e7","name":"hello"}'],
],
},
{
result_type: 'TuplesOk',
result: [
['row_to_json'],
[
'{"constraint_name":"actor_pkey","constraint_type":"p","constraint_definition":"PRIMARY KEY (id)","column_name":"id"}',
],
],
},
{
result_type: 'TuplesOk',
result: [['count'], ['2']],
},
]),
);
}
return res(
ctx.delay(250),
ctx.json([