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> </td> </tr> <tr> <td><strong>RowPermissionSection.test.tsx</strong><dd><code>Add comprehensive tests for row permissions section</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-2a32fbb9eda12ec8eb93746c5c8b171e8ae20d18e661a5e2eb0c4996fee8376b">+211/-0</a> </td> </tr> <tr> <td><strong>hasuraMetadataQuery.ts</strong><dd><code>Add `hasuraColumnMetadataQuery` mock endpoint</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-2828f4a1163f0d281abf2517e76fc9dd393bb870478aea874019a42f9c4b7ac3">+260/-0</a> </td> </tr> <tr> <td><strong>tableQuery.ts</strong><dd><code>Extend actor table mock with column and row data</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-fdb6ad2a7e58c374f3a6772219e7f7e72ca2927def74ec75893b064caba12639">+40/-0</a> </td> </tr> <tr> <td><strong>testUtils.tsx</strong><dd><code>Add `fireClickEvent` helper to `TestUserEvent`</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-78f29250407edf853a353b48242d3cee59aa5724f38a60bb23bebdfc1ea2f9b5">+13/-0</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-c89efa530042890e7d6277c2e3c763cb7c9b9fc1d7c14c62839f4cf7c42528f7">+6/-1</a> </td> </tr> <tr> <td><strong>RowPermissionsSection.tsx</strong><dd><code>Refactor filter logic and default row check type</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-663956d9adae1f6255151599b1cbd6ad03fea1246e87ab89329fcddcdbec2b20">+12/-28</a> </td> </tr> <tr> <td><strong>RuleEditorRow.tsx</strong><dd><code>Wrap column input with `FormField` and error styling</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-a7a1d2aa882735a2b9cfb41e95b05c6777d706570eec5deec6bf5d2381a51252">+47/-28</a> </td> </tr> <tr> <td><strong>RuleValueInput.tsx</strong><dd><code>Introduce `RuleInputWrapper` with validation messages</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-e3198b245b5963e81e4566758b7d60c8d2784a7ca0ad0b17b354b33074ef1bb0">+43/-6</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-bf3aa91fe39fe48522262f0f908b7d151ce75cb005ec50fe38c2429d0e81ddb1">+4/-5</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-09548f3bfb7c005a1d2f3d9d7f1f5d00c608d821572250400d92eda63ae7251a">+1/-0</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3471/files#diff-25c255427ffb291f4e9d7ab56622f3fee8bc9ea2ca0b38242d9b7e41273bea88">+5/-0</a> </td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___
This commit is contained in:
5
.changeset/brave-fans-sit.md
Normal file
5
.changeset/brave-fans-sit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': patch
|
||||
---
|
||||
|
||||
fix (dashboard): Show errors in row permission rule form
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './EditPermissionsForm';
|
||||
export { default as EditPermissionsForm } from './EditPermissionsForm';
|
||||
export { default as editPermissionFormValidationSchemas } from './validationSchemas';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user