feature(console): Rebuild Permissions Picker

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7662
Co-authored-by: Julian <843342+okjulian@users.noreply.github.com>
GitOrigin-RevId: c4d5a1d8d24124d435931c48389d6086410ed8ba
This commit is contained in:
Erik Magnusson 2023-02-13 14:11:32 +02:00 committed by hasura-bot
parent 0166f1892b
commit b20a712443
69 changed files with 17498 additions and 1231 deletions

View File

@ -75,7 +75,6 @@ export const useRows = ({
columns,
options,
});
console.log({ queryKey });
return useQuery({
queryKey,

View File

@ -26,9 +26,14 @@ describe('getTableDisplayName', () => {
});
describe('when table is object and includes "name"', () => {
it('returns .name', () => {
it('returns .name if object has a schema key (Postgres)', () => {
expect(getTableDisplayName({ name: 'aName' })).toBe('aName');
});
it('returns name and other keys concatenated (non Postgres DBs)', () => {
expect(getTableDisplayName({ name: 'aName', dataset: 'aDataset' })).toBe(
'aDataset.aName'
);
});
});
describe('when table is object without name', () => {

View File

@ -1,5 +1,6 @@
import { Table } from '@/features/hasura-metadata-types';
import isObject from 'lodash.isobject';
import { isSchemaTable } from '../components/RelationshipForm/utils';
/*
this function isn't entirely generic but it will hold for the current set of native DBs we have & GDC as well
@ -17,8 +18,8 @@ export const getTableDisplayName = (table: Table): string => {
return table;
}
if (typeof table === 'object' && 'name' in table) {
return (table as { name: string }).name;
if (typeof table === 'object' && isSchemaTable(table)) {
return table.name;
}
if (isObject(table)) {

View File

@ -1,5 +1,6 @@
import { MetadataTable, Source } from '@/features/hasura-metadata-types';
import { AllowedQueryOperation } from '../query';
import { TableEntry } from '../../../metadata/types';
export const getTypeName = ({
defaultQueryRoot,
@ -8,7 +9,7 @@ export const getTypeName = ({
sourceCustomization,
}: {
defaultQueryRoot: string | never[];
configuration?: MetadataTable['configuration'];
configuration?: TableEntry['configuration'] | MetadataTable['configuration'];
operation: AllowedQueryOperation;
sourceCustomization?: Source['customization'];
defaultSchema?: string;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useConsoleForm } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
@ -8,7 +8,6 @@ import {
useRoles,
useSupportedQueryTypes,
} from '@/features/MetadataAPI';
import { getTableDisplayName } from '@/features/DatabaseRelationships';
import { PermissionsSchema, schema } from './../schema';
import { AccessType, QueryType } from '../types';
@ -24,6 +23,8 @@ import {
import { useFormData, useUpdatePermissions } from './hooks';
import ColumnRootFieldPermissions from './components/RootFieldPermissions/RootFieldPermissions';
import { useListAllTableColumns } from '@/features/Data';
import { useMetadataSource } from '@/features/MetadataAPI';
export interface ComponentProps {
dataSourceName: string;
@ -79,9 +80,8 @@ const Component = (props: ComponentProps) => {
const rowPermissions = queryType === 'update' ? ['pre', 'post'] : [queryType];
const { formData, defaultValues } = data || {};
const {
methods: { getValues },
methods: { getValues, reset },
Form,
} = useConsoleForm({
schema,
@ -90,6 +90,13 @@ const Component = (props: ComponentProps) => {
},
});
// Reset form when default values change
// E.g. when switching tables
useEffect(() => {
const newValues = getValues();
reset({ ...newValues, ...defaultValues });
}, [roleName, defaultValues]);
// allRowChecks relates to other queries and is for duplicating from others
const allRowChecks = defaultValues?.allRowChecks;
@ -134,6 +141,9 @@ const Component = (props: ComponentProps) => {
}
allRowChecks={allRowChecks || []}
dataSourceName={dataSourceName}
supportedOperators={
data?.defaultValues?.supportedOperators ?? []
}
/>
</React.Fragment>
))}
@ -220,11 +230,18 @@ export interface PermissionsFormProps {
export const PermissionsForm = (props: PermissionsFormProps) => {
const { dataSourceName, table, queryType, roleName } = props;
const { columns: tableColumns, isLoading: isLoadingTables } =
useListAllTableColumns(dataSourceName, table);
const { data: metadataSource } = useMetadataSource(dataSourceName);
const { data, isError, isLoading } = useFormData({
dataSourceName,
table,
queryType,
roleName,
tableColumns,
metadataSource,
});
if (isError) {
@ -233,7 +250,15 @@ export const PermissionsForm = (props: PermissionsFormProps) => {
);
}
if (isLoading || !data) {
if (
isLoading ||
!data ||
isLoadingTables ||
!tableColumns ||
tableColumns?.length === 0 ||
!metadataSource ||
!data.defaultValues
) {
return <IndicatorCard status="info">Loading...</IndicatorCard>;
}

View File

@ -0,0 +1,163 @@
import { TableColumn } from '@/features/DataSource';
import { RelationshipType } from '../../RelationshipsTable/types';
import { MetadataDataSource } from '../../../metadata/types';
import { ManualObjectRelationship } from '@/features/hasura-metadata-types';
const boolOperators = ['_and', '_or', '_not'];
export const getBoolOperators = () => {
const boolMap = boolOperators.map(boolOperator => ({
name: boolOperator,
kind: 'boolOperator',
meta: null,
}));
return boolMap;
};
const getExistOperators = () => {
return ['_exists'];
};
export const formatTableColumns = (columns: TableColumn[]) => {
if (!columns) return [];
return columns?.map(column => {
return {
kind: 'column',
name: column.name,
meta: { name: column.name, type: column.consoleDataType },
};
});
};
export const formatTableRelationships = (
metadataTables: MetadataDataSource['tables']
) => {
if (!metadataTables) return [];
const met = metadataTables.reduce(
(tally: RelationshipType[], curr: RelationshipType) => {
const object_relationships = curr.object_relationships;
if (!object_relationships) return tally;
const relations = object_relationships
.map(
(relationship: {
using: {
manual_configuration: {
remote_table: { dataset: string; name: string };
};
};
name: string;
}) => {
if (!relationship?.using) return undefined;
return {
kind: 'relationship',
name: relationship?.name,
meta: {
name: relationship?.name,
type: `${relationship?.using?.manual_configuration?.remote_table?.dataset}_${relationship?.using?.manual_configuration?.remote_table?.name}`,
isObject: true,
},
};
}
)
.filter(Boolean);
return [...tally, ...relations];
},
[]
);
return met;
};
export interface CreateOperatorsArgs {
tableName: string;
existingPermission?: Record<string, any>;
tableColumns: TableColumn[];
sourceMetadataTables: MetadataDataSource['tables'] | undefined;
}
export const createOperatorsObject = ({
tableName = '',
existingPermission,
tableColumns,
sourceMetadataTables,
}: CreateOperatorsArgs): Record<string, any> => {
if (!existingPermission) {
return {};
}
const data = {
boolOperators: boolOperators,
existOperators: getExistOperators(),
columns: formatTableColumns(tableColumns),
relationships: sourceMetadataTables
? formatTableRelationships(sourceMetadataTables)
: [],
};
const colNames = data.columns.map(col => col.name);
const relationships = data.relationships.map(
(rel: ManualObjectRelationship) => rel.name
);
const operators = Object.entries(existingPermission).reduce(
(_acc, [key, value]) => {
if (boolOperators.includes(key)) {
return {
name: key,
typeName: key,
type: 'boolOperator',
[key]: Array.isArray(value)
? value.map((each: Record<string, any>) =>
createOperatorsObject({
tableName,
tableColumns,
existingPermission: each,
sourceMetadataTables,
})
)
: createOperatorsObject({
tableName,
tableColumns,
existingPermission: value,
sourceMetadataTables,
}),
};
}
if (relationships.includes(key)) {
const rel = data.relationships.find(
(relationship: ManualObjectRelationship) => key === relationship.name
);
const typeName = rel?.meta?.type?.type;
return {
name: key,
typeName,
type: 'relationship',
[key]: createOperatorsObject({
tableName,
existingPermission: value,
tableColumns,
sourceMetadataTables,
}),
};
}
if (colNames.includes(key)) {
return {
name: key,
typeName: key,
type: 'column',
columnOperator: createOperatorsObject({
tableName,
existingPermission: value,
tableColumns,
sourceMetadataTables,
}),
};
}
return key;
},
{}
);
return operators;
};

View File

@ -2,7 +2,7 @@ import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { AccessType, QueryType } from '../../types';
import { PermissionsSchema } from '../../schema';
import { createInsertArgs } from './utils';
import { createInsertArgs, ExistingPermission } from './utils';
import { Table } from '@/features/hasura-metadata-types';
interface CreateBodyArgs {
@ -104,7 +104,7 @@ interface CreateInsertBodyArgs extends CreateBodyArgs {
queryType: QueryType;
formData: PermissionsSchema;
accessType: AccessType;
existingPermissions: any;
existingPermissions: ExistingPermission[];
driver: string;
tables: Table[];
}

View File

@ -14,8 +14,8 @@ type SelectPermissionMetadata = {
filter: Record<string, any>;
allow_aggregations?: boolean;
limit?: number;
query_root_fields?: any[];
subscription_root_fields?: any[];
query_root_fields?: string[];
subscription_root_fields?: string[];
};
const createSelectObject = (input: PermissionsSchema) => {
@ -26,11 +26,14 @@ const createSelectObject = (input: PermissionsSchema) => {
// in row permissions builder an extra input is rendered automatically
// this will always be empty and needs to be removed
const filter = Object.entries(input.filter).reduce<Record<string, any>>(
(acc, [operator, value]) => {
if (operator === '_and' || operator === '_or') {
const newValue = (value as any[])?.slice(0, -1);
acc[operator] = newValue;
const filteredEmptyObjects = (value as any[]).filter(
p => Object.keys(p).length !== 0
);
acc[operator] = filteredEmptyObjects;
return acc;
}
@ -95,10 +98,10 @@ export interface CreateInsertArgs {
driver: string;
}
interface ExistingPermission {
export interface ExistingPermission {
table: unknown;
role: string;
queryType: any;
queryType: string;
}
/**
* creates the insert arguments to update permissions

View File

@ -19,7 +19,7 @@ import {
QueryRootPermissionType,
} from './RootFieldPermissions/types';
const getAccessText = (queryType: any) => {
const getAccessText = (queryType: string) => {
if (queryType === 'insert') {
return 'to set input for';
}
@ -94,6 +94,8 @@ export const ColumnPermissionsSection: React.FC<
const [showConfirmation, setShowConfirmationModal] = useState<string | null>(
null
);
const all = watch();
const [selectedColumns, queryRootFields, subscriptionRootFields] = watch([
'columns',
'query_root_fields',

View File

@ -4,7 +4,7 @@ import { useFormContext } from 'react-hook-form';
import { Table } from '@/features/hasura-metadata-types';
import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
import { DataSource, exportMetadata } from '@/features/DataSource';
import { DataSource, exportMetadata, Operator } from '@/features/DataSource';
import { areTablesEqual } from '@/features/RelationshipsTable';
import { getTypeName } from '@/features/GraphQLUtils';
import { InputField } from '@/new-components/Form';
@ -40,6 +40,7 @@ export interface RowPermissionsProps {
subQueryType?: string;
allRowChecks: Array<{ queryType: QueryType; value: string }>;
dataSourceName: string;
supportedOperators: Operator[];
}
enum SelectedSection {
@ -132,6 +133,7 @@ export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
subQueryType,
allRowChecks,
dataSourceName,
supportedOperators,
}) => {
const { data: tableName, isLoading } = useTypeName({ table, dataSourceName });
const { register, watch, setValue } = useFormContext();
@ -245,7 +247,6 @@ export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
<div className="pt-4">
{!isLoading && tableName ? (
<RowPermissionBuilder
tableName={tableName}
nesting={['filter']}
table={table}
dataSourceName={dataSourceName}

View File

@ -9,6 +9,7 @@ import { createDefaultValues } from './utils';
import {
complicatedExample,
exampleWithBoolOperator,
exampleWithNotOperator,
exampleWithRelationship,
handlers,
schema,
@ -105,6 +106,38 @@ WithDefaultsBool.decorators = [
},
];
export const WithDefaultsNot: ComponentStory<
typeof RowPermissionBuilder
> = args => <RowPermissionBuilder {...args} />;
WithDefaultsNot.args = {
tableName: 'user',
nesting: ['filter'],
};
WithDefaultsNot.decorators = [
Component => {
return (
<div style={{ width: 800 }}>
<SimpleForm
schema={z.any()}
options={{
defaultValues: createDefaultValues({
tableName: 'user',
schema,
existingPermission: exampleWithNotOperator,
tableConfig: {},
}),
}}
onSubmit={console.log}
>
<Component />
</SimpleForm>
</div>
);
},
];
export const WithDefaultsRelationship: ComponentStory<
typeof RowPermissionBuilder
> = args => <RowPermissionBuilder {...args} />;

View File

@ -1,73 +1,49 @@
import { Table } from '@/features/hasura-metadata-types';
import React from 'react';
import AceEditor from 'react-ace';
import { useFormContext } from 'react-hook-form';
import { Builder, JsonItem } from './components';
import { useIntrospectSchema } from './hooks';
import { createDisplayJson } from './utils';
import { RowPermissionsInput } from './components';
import { usePermissionTables } from './hooks/usePermissionTables';
import { usePermissionComparators } from './hooks/usePermissionComparators';
import { useMetadataTable } from '../../../../hasura-metadata-api/metadataHooks';
import { getMetadataTableCustomName } from './utils/getMetadataTableCustomName';
interface Props {
tableName: string;
/**
* The builder is a recursive structure
* the nesting describes the level of the structure
* so react hook form can correctly register the fields
* e.g. ['filter', 'Title', '_eq'] would be registered as 'filter.Title._eq'
*/
nesting: string[];
table: Table;
dataSourceName: string;
}
export const RowPermissionBuilder = ({
tableName,
nesting,
table,
dataSourceName,
}: Props) => {
const { watch } = useFormContext();
const { data: schema } = useIntrospectSchema();
const { watch, setValue } = useFormContext();
// by watching the top level of nesting we can get the values for the whole builder
// this value will always be 'filter' or 'check' depending on the query type
const value = watch(nesting[0]);
const json = createDisplayJson(value || {});
const permissionsKey = nesting[0];
if (!schema) {
return null;
}
const value = watch(permissionsKey);
const { data: tableConfig } = useMetadataTable(dataSourceName, table);
const tables = usePermissionTables({
dataSourceName,
tableCustomName: getMetadataTableCustomName(tableConfig),
});
const comparators = usePermissionComparators();
if (!tables) return <>Loading</>;
return (
<div key={tableName} className="flex flex-col space-y-4 w-full">
<div className="p-6 rounded-lg bg-white border border-gray-200 min-h-32 w-full">
<AceEditor
mode="json"
minLines={1}
fontSize={14}
height="18px"
width="100%"
theme="github"
name={`${tableName}-json-editor`}
value={json}
editorProps={{ $blockScrolling: true }}
setOptions={{ useWorker: false }}
/>
</div>
<div className="p-6 rounded-lg bg-white border border-gray-200w-full">
<JsonItem text="{" />
<div className="py-2">
<Builder
tableName={tableName}
nesting={nesting}
schema={schema}
dataSourceName={dataSourceName}
table={table}
/>
</div>
<JsonItem text="}" />
</div>
</div>
<RowPermissionsInput
onPermissionsChange={permissions => {
setValue(permissionsKey, permissions);
}}
table={table}
tables={tables}
permissions={value}
comparators={comparators}
/>
);
};

View File

@ -1,232 +0,0 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GraphQLSchema } from 'graphql';
import { Table } from '@/features/hasura-metadata-types';
import { RenderFormElement } from './RenderFormElement';
import { CustomField } from './Fields';
import { JsonItem } from './Elements';
import { getColumnOperators } from '../utils';
import { useData } from '../hooks';
const createKey = (inputs: string[]) => inputs.filter(Boolean).join('.');
interface Args {
value: string;
data: ReturnType<typeof useData>['data'];
}
/**
* return value to be set for dropdown state
*/
const getNewValues = ({ value, data }: Args) => {
const allItemsArray = [
...data.boolOperators,
...data.columns,
...data.relationships,
];
const selectedItem = allItemsArray.find(item => item.name === value);
switch (selectedItem?.kind) {
case 'boolOperator':
return {
name: selectedItem.name,
typeName: selectedItem.name,
type: 'boolOperator',
columnOperator: '_eq',
};
case 'column':
return {
name: selectedItem.name,
typeName: selectedItem.name,
type: 'column',
columnOperator: '_eq',
};
case 'relationship':
return {
name: selectedItem.name,
// for relationships the type name will be different from the name
// for example if the relationship name is things the type name will be thing
// therefore we need both to
// 1. correctly display the relationship name in the permission i.e. things
// 2. find the information needed about the type from the schema using the type name i.e. thing
typeName: selectedItem?.meta?.type?.type,
type: 'relationship',
columnOperator: '_eq',
};
default:
throw new Error('Case not handled');
}
};
interface RenderJsonDisplayProps {
dropDownState: { name: string; type: string };
}
/**
*
* needed to render the next level of the form differently depending on which item is selected
*/
const RenderJsonDisplay = (props: RenderJsonDisplayProps) => {
const { dropDownState } = props;
const isObjectType =
dropDownState?.type === 'column' ||
dropDownState?.type === 'relationship' ||
dropDownState?.name === '_not';
// if nothing is selected render a disabled input
if (!dropDownState?.type) {
return <CustomField.Input disabled />;
}
if (isObjectType) {
return (
<>
<JsonItem text="{" />
</>
);
}
if (dropDownState?.name === '_and' || dropDownState?.name === '_or') {
return (
<>
<JsonItem text="[" />
</>
);
}
return null;
};
interface Props {
tableName: string;
/**
* The builder is a recursive structure
* the nesting describes the level of the structure
* so react hook form can correctly register the fields
* e.g. ['filter', 'Title', '_eq'] would be registered as 'filter.Title._eq'
*/
nesting: string[];
schema: GraphQLSchema;
dataSourceName: string;
table: Table;
}
export const Builder = (props: Props) => {
const { tableName, nesting, schema, dataSourceName, table } = props;
const { data, tableConfig } = useData({
tableName,
schema,
// we have to pass in table like this because if it is a relationship if will
// fetch the wrong table config otherwise
table,
dataSourceName,
});
const { unregister, setValue, getValues } = useFormContext();
// the selections from the dropdowns are stored on the form state under the key "operators"
// this will be removed for submitting the form
// and is generated from the permissions object when rendering the form from existing data
const operatorsKey = createKey(['operators', ...nesting]);
const dropDownState = getValues(operatorsKey);
const permissionsKey = createKey([...nesting, dropDownState?.name]);
const columnKey = createKey([
...nesting,
dropDownState?.name,
dropDownState?.columnOperator || '_eq',
]);
const columnOperators = React.useMemo(() => {
if (dropDownState?.name && dropDownState?.type === 'column' && schema) {
return getColumnOperators({
tableName,
columnName: dropDownState.name,
schema,
tableConfig,
});
}
return [];
}, [tableName, dropDownState, schema, tableConfig]);
const handleDropdownChange: React.ChangeEventHandler<
HTMLSelectElement
> = e => {
const value = e.target.value;
// as the form is populated a json object is built up
// when the dropdown changes at a specific level
// everything below that level needs to be removed
// set value undefined is necessary to remove field arrays
if (dropDownState?.name === '_and' || dropDownState?.name === '_or') {
setValue(permissionsKey, undefined);
}
// when the dropdown changes both the permissions object
// and operators object need to be unregistered below this level
unregister(permissionsKey);
unregister(operatorsKey);
const newValue = getNewValues({ value, data });
return setValue(operatorsKey, newValue);
};
const handleColumnChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const target = e.target.value;
// when the dropdown value changes the previous field needs to be unregistered
// so it is removed from the form state
unregister(columnKey);
setValue(operatorsKey, {
...dropDownState,
columnOperator: target,
});
};
return (
<div className="inline-flex pl-6 flex-col w-full border-dashed border-l border-gray-200">
<div className="flex items-center">
<CustomField.Select
title="Relationship"
value={dropDownState?.name || '-'}
onChange={handleDropdownChange}
>
<option key="-" value="-">
-
</option>
{Object.entries(data).map(([section, list]) => {
return (
<optgroup label={section} key={section}>
{list.map(item => (
<option key={item.name} value={item.name}>
{item.name}
</option>
))}
</optgroup>
);
})}
</CustomField.Select>
<JsonItem text=":" className="mr-4" />
<RenderJsonDisplay dropDownState={dropDownState} />
</div>
{/* depending on the selection from the drop down different form elements need to render */}
{/* for example if "_and" is selected a field array needs to render */}
<RenderFormElement
key={permissionsKey}
columnKey={columnKey}
tableName={tableName}
dropDownState={dropDownState}
columnOperators={columnOperators}
handleColumnChange={handleColumnChange}
nesting={nesting}
schema={schema}
table={table}
dataSourceName={dataSourceName}
/>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { useContext } from 'react';
import { allOperators } from '@/components/Common/FilterQuery/utils';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { tableContext } from './TableProvider';
const defaultOperators = allOperators.map(o => ({
name: o.name,
operator: o.alias,
}));
export const Comparator = ({
comparator,
path,
noValue,
}: {
comparator: string;
path: string[];
noValue?: boolean;
}) => {
const { setKey, comparators } = useContext(rowPermissionsContext);
const comparatorLevelId = `${path?.join('.')}-select${
noValue ? '-is-empty' : ''
}`;
const { columns } = useContext(tableContext);
const columnName = path[path.length - 2];
const column = columns.find(c => c.name === columnName);
const operators =
column?.type && comparators[column.type]?.operators
? comparators[column.type].operators
: defaultOperators;
return (
<select
data-testid={comparatorLevelId}
className="border border-gray-200 rounded-md p-2"
value={comparator}
onChange={e => {
setKey({ path, key: e.target.value, type: 'comparator' });
}}
>
{operators.map((o, index) => (
<option key={index} value={o.operator}>
{o.name}
</option>
))}
</select>
);
};

View File

@ -1,32 +0,0 @@
import React from 'react';
import clsx from 'clsx';
interface Props extends React.ComponentProps<'div'> {
text: JsonOptions;
}
type JsonOptions = '{' | '}' | '[' | ']' | '},' | ',' | '"' | ':';
const chooseColor = (text: JsonOptions) => {
switch (text) {
case '{':
case '}':
case '},':
return 'text-blue-800';
case '[':
case ']':
return 'text-yellow-500';
default:
return 'text-black';
}
};
export const JsonItem = (props: Props) => {
const color = chooseColor(props.text);
return (
<span className={clsx('font-bold text-lg', color, props.className)}>
{props.text}
</span>
);
};

View File

@ -0,0 +1,15 @@
import { Key } from './Key';
import { ValueInput } from './ValueInput';
export const EmptyEntry = ({ path }: { path: string[] }) => {
return (
<div className="ml-6">
<div className="p-2 flex gap-4">
<span className="flex gap-4">
<Key k={''} path={path} noValue />
</span>
<ValueInput value={''} path={path} noValue />
</div>
</div>
);
};

View File

@ -0,0 +1,94 @@
import { Fragment, useContext, ReactNode } from 'react';
import { get, isEmpty, isPlainObject } from 'lodash';
import { isComparator, isPrimitive } from './utils/helpers';
import { tableContext, TableProvider } from './TableProvider';
import { typesContext } from './TypesProvider';
import { Key } from './Key';
import { Token } from './Token';
import { PermissionsInput } from './PermissionsInput';
import { EmptyEntry } from './EmptyEntry';
export const Entry = ({
k,
v,
path,
}: {
k: string;
v: any;
path: string[];
}) => {
const { table } = useContext(tableContext);
const isDisabled = k === '_where' && isEmpty(table);
const { types } = useContext(typesContext);
const { relationships } = useContext(tableContext);
let Wrapper: any = Fragment;
const type = get(types, path)?.type;
if (type === 'relationship') {
const relationship = relationships.find(
r => r.name === path[path.length - 1]
);
if (relationship) {
const relationshipTable = relationship.table;
Wrapper = ({ children }: { children?: ReactNode | undefined }) => (
<TableProvider table={relationshipTable}>{children}</TableProvider>
);
}
}
return (
<div
style={{ marginLeft: 8 + (path.length + 1) * 4 + 'px' }}
className={`my-2 ${isDisabled ? 'bg-gray-50' : ''}`}
>
<div className={`p-2 ${isPrimitive(v) ? ' flex gap-4' : ''}`}>
<span className="flex gap-4">
<Key k={k} path={path} />
<span>: </span>
{Array.isArray(v) ? (
<Token token={'['} inline />
) : isPrimitive(v) ? null : (
<Token token={'{'} inline />
)}
</span>
{k === '_exists' ? (
<TableProvider>
<Entry k="_where" v={v._where} path={[...path, '_where']} />
<Entry k="_table" v={v._table} path={[...path, '_table']} />
</TableProvider>
) : (
<Wrapper>
<div
className={
!isComparator(k) ? `border-dashed border-l border-gray-200` : ''
}
>
<PermissionsInput permissions={v} path={path} />
{Array.isArray(v) && k !== '_table' ? (
<div className="p-2 ml-6">
<Token token={'{'} />
<EmptyEntry path={[...path, `${v.length}`]} />
<Token token={'}'} />
</div>
) : null}
{isEmpty(v) && isPlainObject(v) && k !== '_table' ? (
<EmptyEntry path={path} />
) : null}
</div>
</Wrapper>
)}
{Array.isArray(v) ? (
<div className="flex gap-2">
<Token token={']'} inline />
<Token token={','} inline />
</div>
) : isPrimitive(v) ? null : (
<div className="flex gap-2">
<Token token={'}'} inline />
<Token token={','} inline />
</div>
)}
</div>
</div>
);
};

View File

@ -1,144 +0,0 @@
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GraphQLSchema } from 'graphql';
import { Table } from '@/features/hasura-metadata-types';
import { Builder } from './Builder';
import { JsonItem } from './Elements';
interface FieldArrayElementProps {
index: number;
arrayKey: string;
tableName: string;
nesting: string[];
field: Field;
fields: Field[];
append: ReturnType<typeof useFieldArray>['append'];
schema: GraphQLSchema;
dataSourceName: string;
table: Table;
// tableConfig: ReturnType<typeof useTableConfiguration>['data'];
}
type Field = Record<'id', string>;
export const FieldArrayElement = (props: FieldArrayElementProps) => {
const {
index,
arrayKey,
tableName,
field,
nesting,
fields,
append,
schema,
table,
dataSourceName,
// tableConfig,
} = props;
const { watch } = useFormContext();
// from this we can determine if the dropdown has been selected
// if it has and the element is the final field
// another element needs to be appended
const currentField = watch(`operators.${arrayKey}.${index}`);
const isFinalField = fields.length - 1 === index;
if (currentField && isFinalField) {
append({});
}
if (isFinalField) {
return (
<>
<div className="px-6">
<JsonItem text="{" />
<Builder
key={field.id}
tableName={tableName}
nesting={[...nesting, index.toString()]}
schema={schema}
dataSourceName={dataSourceName}
table={table}
// tableConfig={tableConfig}
/>
<JsonItem text="}" />
</div>
<JsonItem text="]" />
</>
);
}
return (
<div className="px-6">
<JsonItem text="{" />
<Builder
key={field.id}
tableName={tableName}
nesting={[...nesting, index.toString()]}
schema={schema}
dataSourceName={dataSourceName}
table={table}
// tableConfig={tableConfig}
/>
<JsonItem text="}," />
</div>
);
};
interface Props {
tableName: string;
nesting: string[];
schema: GraphQLSchema;
dataSourceName: string;
table: Table;
// tableConfig: ReturnType<typeof useTableConfiguration>['data'];
}
export const FieldArray = (props: Props) => {
const {
tableName,
nesting,
schema,
dataSourceName,
table,
// tableConfig
} = props;
const arrayKey = nesting.join('.');
const { fields, append } = useFieldArray({
name: arrayKey,
});
// automatically append a new field when the array is empty
// necessary to render an element
if (fields.length === 0) {
append({});
}
return (
<div
key={arrayKey}
className="flex flex-col w-full border-dashed border-l border-gray-200"
>
<div className="flex flex-col py-2">
{fields.map((field, index) => (
<FieldArrayElement
key={field.id}
index={index}
arrayKey={arrayKey}
tableName={tableName}
field={field}
fields={fields}
nesting={nesting}
append={append}
schema={schema}
dataSourceName={dataSourceName}
table={table}
// tableConfig={tableConfig}
/>
))}
</div>
</div>
);
};

View File

@ -1,48 +0,0 @@
import React from 'react';
import { JsonItem } from './Elements';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
(props, ref) => {
return (
<div className="flex gap-1">
{props.type === 'text' && <JsonItem text={`"`} />}
<label
htmlFor={props.id}
className="block text-sm font-medium text-gray-700"
>
{/* <strong>{props.title}</strong> */}
<input
ref={ref}
className="block w-36 h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
{...props}
/>
</label>
{props.type === 'text' && <JsonItem text={`"`} />}
</div>
);
}
);
const Select = (props: React.ComponentProps<'select'>) => {
return (
<div className="flex gap-1">
<JsonItem text={`"`} />
<label
htmlFor={props.id}
className="block text-sm font-medium text-gray-700"
>
{/* <strong>{props.title}</strong> */}
<select
className="block w-32 h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
{...props}
/>
</label>
<JsonItem text={`"`} />
</div>
);
};
export const CustomField = {
Input,
Select,
};

View File

@ -0,0 +1,35 @@
import AceEditor from 'react-ace';
import { useContext } from 'react';
import { getTableDisplayName } from '@/features/DatabaseRelationships';
import { rowPermissionsContext } from './RowPermissionsProvider';
export const JsonEditor = () => {
const { permissions, table, setPermissions } = useContext(
rowPermissionsContext
);
return (
<div className="p-6 rounded-lg bg-white border border-gray-200 min-h-32 w-full">
<AceEditor
mode="json"
onChange={value => {
try {
// Only set new permissions on valid JSON
setPermissions(JSON.parse(value));
} catch (error) {
console.error(error);
}
}}
minLines={1}
fontSize={14}
height="18px"
width="100%"
theme="github"
name={`${getTableDisplayName(table)}-json-editor`}
value={JSON.stringify(permissions)}
editorProps={{ $blockScrolling: true }}
setOptions={{ useWorker: false }}
/>
</div>
);
};

View File

@ -0,0 +1,22 @@
import { isComparator } from './utils';
import { Operator } from './Operator';
import { Comparator } from './Comparator';
export const Key = ({
k,
path,
noValue,
}: {
k: string;
path: string[];
noValue?: boolean;
}) => {
if (k === '_where' || k === '_table') {
return <span className="font-bold">{k}</span>;
}
if (isComparator(k)) {
return <Comparator noValue={noValue} comparator={k} path={path} />;
}
return <Operator noValue={noValue} operator={k} path={path} />;
};

View File

@ -0,0 +1,83 @@
import { isEmpty } from 'lodash';
import { useContext } from 'react';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { tableContext } from './TableProvider';
import { PermissionType } from './types';
export const Operator = ({
operator,
path,
noValue,
}: {
operator: string;
path: string[];
noValue?: boolean;
}) => {
const { operators, setKey } = useContext(rowPermissionsContext);
const { columns, table, relationships } = useContext(tableContext);
const parent = path[path.length - 1];
const operatorLevelId = `${path?.join('.')}-select${noValue ? '-empty' : ''}`;
return (
<select
data-testid={operatorLevelId || 'root-operator-picker'}
className="border border-gray-200 rounded-md p-2"
value={operator}
disabled={parent === '_where' && isEmpty(table)}
onChange={e => {
const type = e.target.selectedOptions[0].dataset.type as PermissionType;
setKey({ path, key: e.target.value, type });
}}
>
PermissionType
<option value="">-</option>
{operators.boolean?.items.length ? (
<optgroup label="Bool operators">
{operators.boolean.items.map((item, index) => (
<option
data-type="boolean"
key={'boolean' + index}
value={item.value}
>
{item.name}
</option>
))}
</optgroup>
) : null}
{columns.length ? (
<optgroup label="Columns">
{columns.map((column, index) => (
<option
data-type="column"
key={'column' + index}
value={column.name}
>
{column.name}
</option>
))}
</optgroup>
) : null}
{operators.exist?.items.length ? (
<optgroup label="Exist operators">
{operators.exist.items.map((item, index) => (
<option data-type="exist" key={'exist' + index} value={item.value}>
{item.name}
</option>
))}
</optgroup>
) : null}
{relationships?.length ? (
<optgroup label="Relationships">
{relationships.map((item, index) => (
<option
data-type="relationship"
key={'relationship' + index}
value={item.name}
>
{item.name}
</option>
))}
</optgroup>
) : null}
</select>
);
};

View File

@ -0,0 +1,47 @@
import { isEmpty } from 'lodash';
import { Entry } from './Entry';
import { Operator } from './Operator';
import { isPrimitive } from './utils';
import { ValueInput } from './ValueInput';
import { Permissions } from './types';
export const PermissionsInput = ({
permissions,
path,
}: {
permissions: Permissions;
path: string[];
}) => {
const currentPath = path[path.length - 1];
if (isEmpty(permissions) && path.length === 0) {
return <Operator operator={'_eq'} path={[]} />;
}
if (isPrimitive(permissions) || currentPath === '_table') {
return <ValueInput value={permissions} path={path} />;
}
if (Array.isArray(permissions)) {
return (
<>
{permissions.map((v, i) => {
const index = isEmpty(v) ? 0 : i;
return (
<PermissionsInput
key={path.join('.') + '.' + index}
permissions={v}
path={[...path, `${index}`]}
/>
);
})}
</>
);
}
const entries = Object.entries(permissions);
return (
<>
{entries.map(([k, v]) => {
const childPath = [...path, k];
return <Entry key={k} path={childPath} k={k} v={v} />;
})}
</>
);
};

View File

@ -1,166 +0,0 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GraphQLSchema } from 'graphql';
import { Table } from '@/features/hasura-metadata-types';
import { CustomField } from './Fields';
import { FieldArray } from './FieldArray';
import { Builder } from './Builder';
import { JsonItem } from './Elements';
import { findColumnOperator, getColumnOperators } from '../utils';
interface Props {
columnKey: string;
tableName: string;
dropDownState: any;
columnOperators: ReturnType<typeof getColumnOperators>;
handleColumnChange: (i: any) => void;
/**
* The builder is a recursive structure
* the nesting describes the level of the structure
* so react hook form can correctly register the fields
* e.g. ['filter', 'Title', '_eq'] would be registered as 'filter.Title._eq'
*/
nesting: string[];
schema: GraphQLSchema;
table: Table;
dataSourceName: string;
}
export const RenderFormElement = (props: Props) => {
const {
columnKey,
tableName,
dropDownState,
columnOperators,
handleColumnChange,
nesting,
schema,
table,
dataSourceName,
// tableConfig,
} = props;
const { register, setValue, watch } = useFormContext();
const selectedOperator = React.useMemo(
() => findColumnOperator({ columnKey, columnOperators }),
[columnOperators, columnKey]
);
const val = watch(columnKey);
const selectedOperatorType = React.useMemo(() => {
if (typeof val === 'string' && val.startsWith('X-Hasura-')) {
return 'text';
}
return selectedOperator?.type?.type === 'String' ? 'text' : 'number';
}, [selectedOperator, val]);
const setValueAs = (value: 'text' | 'number') => {
const isNumber = !Number.isNaN(parseInt(value, 10));
if (isNumber) {
return parseInt(value, 10);
}
return value;
};
switch (dropDownState?.type) {
case 'column':
if (!selectedOperator) {
return null;
}
return (
<div className="flex flex-col">
<div className="flex items-center px-6 border-dashed border-l border-gray-200 py-4">
{!!columnOperators.length && (
<CustomField.Select
title="Column Operator"
value={dropDownState.columnOperator}
onChange={handleColumnChange}
>
{columnOperators.map(({ name }) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</CustomField.Select>
)}
<JsonItem text=":" className="mr-4" />
<div className="flex gap-2">
<CustomField.Input
key={`${columnKey}-${selectedOperatorType}`}
title="Column value"
type={selectedOperatorType}
{...register(columnKey, {
setValueAs,
})}
/>
<button
type="button"
className="text-blue-800 font-bold text-sm"
onClick={() => setValue(columnKey, 'X-Hasura-User-Id')}
>
[X-Hasura-User-Id]
</button>
</div>
</div>
<JsonItem text="}" />
</div>
);
// when rendering a relationship the name of the table needs to be updated
// to look for values from the connected table
case 'relationship':
return (
<div className="border-l-dashed border-gray-200">
<div className="py-4">
<Builder
key={columnKey}
tableName={dropDownState.typeName}
nesting={[...nesting, dropDownState.name]}
schema={schema}
table={table}
dataSourceName={dataSourceName}
/>
</div>
<JsonItem text="}" />
</div>
);
case 'boolOperator':
return (
<>
{dropDownState.name === '_not' && (
<div className="border-l-dashed border-gray-200">
<div className="py-4">
<Builder
key={columnKey}
tableName={tableName}
nesting={[...nesting, dropDownState.name]}
schema={schema}
table={table}
dataSourceName={dataSourceName}
/>
</div>
<JsonItem text="}" />
</div>
)}
{(dropDownState.name === '_and' || dropDownState.name === '_or') && (
<FieldArray
key={columnKey}
tableName={tableName}
nesting={[...nesting, dropDownState.name]}
schema={schema}
table={table}
dataSourceName={dataSourceName}
/>
)}
</>
);
default:
return null;
}
};

View File

@ -0,0 +1,25 @@
import clsx from 'clsx';
import { isEmpty } from 'lodash';
import { useContext } from 'react';
import { PermissionsInput } from './PermissionsInput';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { Token } from './Token';
export const RootInput = () => {
const { permissions } = useContext(rowPermissionsContext);
return (
<div className="p-6 rounded-lg bg-white border border-gray-200w-full">
<Token token={'{'} />
<div
className={clsx(
`py-2 border-dashed border-l border-gray-200 `,
isEmpty(permissions) && 'pl-6'
)}
id="permissions-form-builder"
>
<PermissionsInput permissions={permissions} path={[]} />
</div>
<Token token={'}'} />
</div>
);
};

View File

@ -0,0 +1,438 @@
import { ComponentStory, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { RowPermissionsInput } from './RowPermissionsInput';
import { within } from '@testing-library/dom';
import { userEvent, waitFor } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { tables } from './__tests__/fixtures/tables';
import { comparators } from './__tests__/fixtures/comparators';
export default {
title: 'Features/Permissions/Form/Row Permissions Input',
component: RowPermissionsInput,
} as Meta;
export const SetRootLevelPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetExistsPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetMultilevelExistsPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetAndPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetMultilevelAndPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetNotPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetOrPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const SetMultilevelOrPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const Empty: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{}}
/>
);
export const Exists: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_exists: {
_table: {},
_where: {},
},
}}
/>
);
export const SetDisabledExistsPermission: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_exists: {
_table: {},
_where: {},
},
}}
/>
);
export const ExistsWhere: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_exists: {
_table: { dataset: 'bigquery_sample', name: 'sample_table' },
_where: {
_and: [
{ STATUS: { _eq: 'X-Hasura-User-Id' } },
{ Period: { _eq: 'Period' } },
],
},
},
}}
/>
);
export const EmptyExists: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_exists: {
_table: {},
_where: {},
},
}}
/>
);
export const And: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_and: [
{ STATUS: { _eq: 'X-Hasura-User-Id' } },
{ Period: { _eq: 'Period' } },
],
}}
/>
);
export const EmptyAnd: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_and: [{}],
}}
/>
);
export const Not: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_not: { STATUS: { _eq: 'X-Hasura-User-Id' } },
}}
/>
);
export const EmptyNot: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{
_not: {},
}}
/>
);
export const Relationships: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={['Album']}
tables={tables}
comparators={comparators}
permissions={{ Author: { name: { _eq: '' } } }}
/>
);
export const RelationshipsColumns: ComponentStory<
typeof RowPermissionsInput
> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={['Album']}
tables={tables}
comparators={comparators}
permissions={{ Label: { id: { _eq: '' } } }}
/>
);
export const ColumnTypes: ComponentStory<typeof RowPermissionsInput> = args => (
<RowPermissionsInput
onPermissionsChange={action('onPermissionsChange')}
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables}
comparators={comparators}
permissions={{ Series_reference: { _eq: '' } }}
/>
);
SetRootLevelPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByTestId('-select'));
await userEvent.selectOptions(canvas.getByTestId('-select'), 'Subject');
};
SetExistsPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_exists');
await userEvent.selectOptions(
canvas.getByTestId('_exists._table-select'),
'Label'
);
await userEvent.selectOptions(
canvas.getByTestId('_exists._where-select-empty'),
'id'
);
await userEvent.type(
canvas.getByTestId('_exists._where.id._eq-input'),
'1337'
);
};
SetMultilevelExistsPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_exists');
await userEvent.selectOptions(
canvas.getByTestId('_exists._table-select'),
'Label'
);
await userEvent.selectOptions(
canvas.getByTestId('_exists._where-select-empty'),
'_exists'
);
await userEvent.selectOptions(
canvas.getByTestId('_exists._where._exists._table-select'),
'Label'
);
await userEvent.selectOptions(
canvas.getByTestId('_exists._where._exists._where-select-empty'),
'id'
);
await userEvent.type(
canvas.getByTestId('_exists._where._exists._where.id._eq-input'),
'1337'
);
};
SetAndPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_and');
await userEvent.selectOptions(
canvas.getByTestId('_and.1-select-empty'),
'Series_reference'
);
await userEvent.type(
canvas.getByTestId('_and.1.Series_reference._eq-input'),
'1337',
{ delay: 300 }
);
};
SetMultilevelAndPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_and');
await userEvent.selectOptions(
canvas.getByTestId('_and.1-select-empty'),
'Series_reference'
);
await userEvent.type(
canvas.getByTestId('_and.1.Series_reference._eq-input'),
'1337',
{ delay: 300 }
);
await userEvent.selectOptions(
canvas.getByTestId('_and.2-select-empty'),
'STATUS'
);
await userEvent.type(canvas.getByTestId('_and.2.STATUS._eq-input'), '1338');
};
SetNotPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_not');
await userEvent.selectOptions(
canvas.getByTestId('_not-select-empty'),
'Period'
);
await userEvent.selectOptions(
canvas.getByTestId('_not.Period._eq-select'),
'_neq'
);
};
SetOrPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_or');
await userEvent.selectOptions(
canvas.getByTestId('_or.1-select-empty'),
'Period'
);
await userEvent.type(canvas.getByTestId('_or.1.Period._eq-input'), '1337');
};
SetMultilevelOrPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.selectOptions(canvas.getByTestId('-select'), '_or');
await userEvent.selectOptions(
canvas.getByTestId('_or.1-select-empty'),
'Series_reference'
);
await userEvent.type(
canvas.getByTestId('_or.1.Series_reference._eq-input'),
'1337',
{ delay: 300 }
);
await userEvent.selectOptions(
canvas.getByTestId('_or.2-select-empty'),
'STATUS'
);
await userEvent.type(canvas.getByTestId('_or.2.STATUS._eq-input'), '1338', {
delay: 300,
});
};
SetDisabledExistsPermission.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const existElement = canvas.getByTestId('_exists._where-input-no-value');
expect(existElement).toHaveAttribute('disabled');
await userEvent.selectOptions(
canvas.getByTestId('_exists._table-select'),
'Label'
);
expect(existElement).not.toHaveAttribute('disabled');
};

View File

@ -0,0 +1,55 @@
import { Table } from '@/features/hasura-metadata-types';
import { Tables, Operators, Permissions, Comparators } from './types';
import { RowPermissionsProvider } from './RowPermissionsProvider';
import { TypesProvider } from './TypesProvider';
import { TableProvider } from './TableProvider';
import { RootInput } from './RootInput';
import { JsonEditor } from './JsonEditor';
export const RowPermissionsInput = ({
permissions,
tables,
table,
onPermissionsChange,
comparators,
}: {
permissions: Permissions;
tables: Tables;
table: Table;
onPermissionsChange?: (permissions: Permissions) => void;
comparators: Comparators;
}) => {
const operators: Operators = {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
};
return (
<RowPermissionsProvider
operators={operators}
permissions={permissions}
table={table}
tables={tables}
comparators={comparators}
onPermissionsChange={onPermissionsChange}
>
<TypesProvider>
<TableProvider table={table}>
<div className="flex flex-col space-y-4 w-full">
<JsonEditor />
<RootInput />
</div>
</TableProvider>
</TypesProvider>
</RowPermissionsProvider>
);
};

View File

@ -0,0 +1,91 @@
import { set } from 'lodash';
import { useCallback, useEffect, useState, createContext } from 'react';
import { Permissions, RowPermissionsState } from './types';
import { updateKey } from './utils/helpers';
export const rowPermissionsContext = createContext<RowPermissionsState>({
table: {},
tables: [],
comparators: {},
operators: {},
permissions: {},
setValue: () => {},
setKey: () => {},
setPermissions: () => {},
});
export const RowPermissionsProvider = ({
children,
operators,
permissions,
table,
tables,
comparators,
onPermissionsChange,
}: Pick<
RowPermissionsState,
'permissions' | 'operators' | 'table' | 'tables' | 'comparators'
> & {
children?: React.ReactNode | undefined;
onPermissionsChange?: (permissions: Permissions) => void;
}) => {
const [permissionsState, setPermissionsState] = useState<
Pick<RowPermissionsState, 'permissions' | 'operators'>
>({ operators, permissions });
const stringifiedPermissions = JSON.stringify(permissionsState.permissions);
const stringifiedServerPermissionsValue = JSON.stringify(permissions);
const setValue = useCallback<RowPermissionsState['setValue']>(
(path, value) => {
const clone = { ...permissionsState };
const newPermissions = set(clone, ['permissions', ...path], value);
setPermissionsState(newPermissions);
onPermissionsChange?.(newPermissions.permissions);
},
[permissionsState, setPermissionsState, onPermissionsChange]
);
const setKey = useCallback<RowPermissionsState['setKey']>(
({ key, path, type }) => {
const newPermissions = updateKey({
permissionsState,
newKey: key,
keyPath: path,
type,
});
setPermissionsState(newPermissions);
onPermissionsChange?.(newPermissions.permissions);
},
[permissionsState, setPermissionsState, onPermissionsChange]
);
const setPermissions = useCallback<RowPermissionsState['setPermissions']>(
permissions => {
setPermissionsState({ ...permissionsState, permissions });
// Set outside permissions when internals change
onPermissionsChange?.(permissions);
},
[permissionsState, setPermissionsState, onPermissionsChange]
);
// Set internal state when outside permissions change
useEffect(() => {
if (JSON.stringify(permissions) !== stringifiedPermissions) {
setPermissionsState({ ...permissionsState, permissions });
}
}, [stringifiedServerPermissionsValue, setPermissionsState]);
return (
<rowPermissionsContext.Provider
value={{
...permissionsState,
setValue,
setKey,
table,
setPermissions,
tables,
comparators,
}}
>
{children}
</rowPermissionsContext.Provider>
);
};

View File

@ -0,0 +1,58 @@
import { useState, useContext, useEffect, createContext } from 'react';
import { areTablesEqual } from '@/features/hasura-metadata-api';
import { Table } from '@/features/hasura-metadata-types';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { Columns, Relationships, TableContext } from './types';
import { getTableDisplayName } from '../../../../../DatabaseRelationships/utils/helpers';
export const tableContext = createContext<TableContext>({
table: {},
setTable: () => {},
comparator: undefined,
setComparator: () => {},
columns: [],
setColumns: () => {},
relationships: [],
setRelationships: () => {},
});
export const TableProvider = ({
children,
table: defaultTable,
}: {
children: React.ReactNode;
table?: Table;
}) => {
const [table, setTableName] = useState<Table>(defaultTable || {});
const [comparator, setComparator] = useState<string | undefined>();
const [columns, setColumns] = useState<Columns>([]);
const [relationships, setRelationships] = useState<Relationships>([]);
const { tables } = useContext(rowPermissionsContext);
// Stringify values to get a stable value for useEffect
const stringifiedTable = JSON.stringify(table);
const stringifiedTables = JSON.stringify(tables);
useEffect(() => {
const foundTable = tables.find(t => areTablesEqual(t.table, table));
if (foundTable) {
setColumns(foundTable.columns);
setRelationships(foundTable.relationships);
}
}, [stringifiedTable, stringifiedTables, setColumns, setRelationships]);
return (
<tableContext.Provider
value={{
columns,
setColumns,
table,
setTable: setTableName,
relationships,
setRelationships,
comparator,
setComparator,
}}
>
{children}
</tableContext.Provider>
);
};

View File

@ -0,0 +1,9 @@
export const Token = ({ token, inline }: { token: any; inline?: boolean }) => {
return (
<div
className={`font-bold text-lg text-black ${inline ? 'inline-block' : ''}`}
>
{token}
</div>
);
};

View File

@ -0,0 +1,62 @@
import { TypesContext, PermissionType } from './types';
import {
useState,
createContext,
useCallback,
useContext,
useEffect,
} from 'react';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { set, unset } from 'lodash';
import { getPermissionTypes } from './utils/typeProviderHelpers';
export const typesContext = createContext<TypesContext>({
types: {},
setType: () => {},
});
// Provides types for permissions
// This is used to determine if a permission is a column or relationship
export const TypesProvider = ({ children }: { children: React.ReactNode }) => {
const [types, setTypes] = useState<Record<string, { type: PermissionType }>>(
{}
);
const { permissions, tables, table } = useContext(rowPermissionsContext);
const setType = useCallback(
({
type,
path,
value,
}: {
type: PermissionType;
path: string[];
value: any;
}) => {
setTypes(prev => {
// Remove old path
const newTypes = { ...prev };
unset(newTypes, path.join('.'));
// Set new type on new path
set(newTypes, [...path.slice(0, -1), value].join('.'), { type });
return newTypes;
});
},
[]
);
// Stringify values to get a stable value for useEffect
const jsonPermissions = JSON.stringify(permissions);
const stringifiedTable = JSON.stringify(table);
// Recursively set types
useEffect(() => {
const newTypes = getPermissionTypes(tables, table, permissions);
setTypes(newTypes);
}, [jsonPermissions, setTypes, stringifiedTable]);
return (
<typesContext.Provider value={{ types, setType }}>
{children}
</typesContext.Provider>
);
};

View File

@ -0,0 +1,106 @@
import { areTablesEqual } from '@/features/hasura-metadata-api';
import { useContext, useEffect } from 'react';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { tableContext } from './TableProvider';
import { Table } from '@/features/hasura-metadata-types';
import { getTableDisplayName } from '@/features/DatabaseRelationships';
import { isEmpty } from 'lodash';
import { Button } from '@/new-components/Button';
import { graphQLTypeToJsType, isComparator } from './utils';
export const ValueInput = ({
value,
path,
noValue,
}: {
value: any;
path: string[];
noValue?: boolean;
}) => {
const { setValue, tables, comparators } = useContext(rowPermissionsContext);
const { table, columns, setTable, setComparator } = useContext(tableContext);
const comparatorName = path[path.length - 1];
const componentLevelId = `${path.join('.')}-select${
noValue ? '-no-value' : ''
}`;
const componentLevelInputId = `${path.join('.')}-input${
noValue ? '-no-value' : ''
}`;
const stringifiedTable = JSON.stringify(table);
// Sync table name with ColumnsContext table value
useEffect(() => {
if (comparatorName === '_table' && !areTablesEqual(value, table)) {
setTable(value);
}
}, [comparatorName, stringifiedTable, value, setTable, setComparator]);
if (comparatorName === '_table') {
// Select table
return (
<div className="ml-6">
<div className="p-2 flex gap-4">
<select
data-testid={componentLevelId}
className="border border-gray-200 rounded-md"
value={JSON.stringify(value)}
onChange={e => {
setValue(path, JSON.parse(e.target.value) as Table);
}}
>
<option value="">-</option>
{tables.map(t => {
const tableDisplayName = getTableDisplayName(t.table);
return (
// Call JSON.stringify becayse value cannot be array or object. Will be parsed in setValue
<option key={tableDisplayName} value={JSON.stringify(t.table)}>
{tableDisplayName}
</option>
);
})}
</select>
</div>
</div>
);
}
const parent = path[path.length - 2];
const column = columns.find(c => c.name === parent);
const comparator = comparators[column?.type || '']?.operators.find(
o => o.operator === comparatorName
);
const jsType = typeof graphQLTypeToJsType(value, comparator?.type);
const inputType =
jsType === 'boolean' ? 'checkbox' : jsType === 'string' ? 'text' : 'number';
return (
<>
<input
data-testid={`${componentLevelInputId}`}
disabled={comparatorName === '_where' && isEmpty(table)}
className="border border-gray-200 rounded-md p-2 !mr-4"
// Set checked attribute if jsType is boolean, value otherwise
{...(jsType === 'boolean' ? { checked: value } : { value })}
type={inputType}
onChange={e => {
if (jsType === 'boolean') {
setValue(path, e.target.checked);
} else {
setValue(
path,
graphQLTypeToJsType(e.target.value, comparator?.type)
);
}
}}
/>
{isComparator(comparatorName) && (
<Button
disabled={comparatorName === '_where' && isEmpty(table)}
onClick={() => setValue(path, 'X-Hasura-User-Id')}
mode="default"
>
[x-hasura-user-id]
</Button>
)}
</>
);
};

View File

@ -0,0 +1,475 @@
import { Comparators } from '../../types';
import {
BooleanType,
FloatType,
IntType,
StringType,
stringType,
} from './graphql';
export const comparators: Comparators = {
number_SQLite_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: IntType,
},
{
name: '>',
operator: '_gt',
type: IntType,
},
{
name: '>=',
operator: '_gte',
type: IntType,
},
{
name: 'in',
operator: '_in',
type: IntType,
},
{
name: 'is null',
operator: '_is_null',
type: IntType,
},
{
name: '<',
operator: '_lt',
type: IntType,
},
{
name: '<=',
operator: '_lte',
type: IntType,
},
{
name: 'not equals',
operator: '_neq',
type: IntType,
},
{
name: 'not in',
operator: '_nin',
type: IntType,
},
],
},
string_SQLite_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: stringType,
},
{
name: '>',
operator: '_gt',
type: stringType,
},
{
name: '>=',
operator: '_gte',
type: stringType,
},
{
name: 'in',
operator: '_in',
type: stringType,
},
{
name: 'is null',
operator: '_is_null',
type: stringType,
},
{
name: '<',
operator: '_lt',
type: stringType,
},
{
name: '<=',
operator: '_lte',
type: stringType,
},
{
name: 'not equals',
operator: '_neq',
type: stringType,
},
{
name: 'not in',
operator: '_nin',
type: stringType,
},
],
},
String_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: StringType,
},
{
name: '>',
operator: '_gt',
type: StringType,
},
{
name: '>=',
operator: '_gte',
type: StringType,
},
{
name: 'like (case-insensitive)',
operator: '_ilike',
type: StringType,
},
{
name: 'in',
operator: '_in',
type: StringType,
},
{
name: '~*',
operator: '_iregex',
type: StringType,
},
{
name: 'is null',
operator: '_is_null',
type: StringType,
},
{
name: 'like',
operator: '_like',
type: StringType,
},
{
name: '<',
operator: '_lt',
type: StringType,
},
{
name: '<=',
operator: '_lte',
type: StringType,
},
{
name: 'not equals',
operator: '_neq',
type: StringType,
},
{
name: 'not like (case-insensitive)',
operator: '_nilike',
type: StringType,
},
{
name: 'not in',
operator: '_nin',
type: StringType,
},
{
name: '!~*',
operator: '_niregex',
type: StringType,
},
{
name: 'not like',
operator: '_nlike',
type: StringType,
},
{
name: '!~',
operator: '_nregex',
type: StringType,
},
{
name: 'not similar',
operator: '_nsimilar',
type: StringType,
},
{
name: '~',
operator: '_regex',
type: StringType,
},
{
name: 'similar',
operator: '_similar',
type: StringType,
},
],
},
Int_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: IntType,
},
{
name: '>',
operator: '_gt',
type: IntType,
},
{
name: '>=',
operator: '_gte',
type: IntType,
},
{
name: 'in',
operator: '_in',
type: IntType,
},
{
name: 'is null',
operator: '_is_null',
type: IntType,
},
{
name: '<',
operator: '_lt',
type: IntType,
},
{
name: '<=',
operator: '_lte',
type: IntType,
},
{
name: 'not equals',
operator: '_neq',
type: IntType,
},
{
name: 'not in',
operator: '_nin',
type: IntType,
},
],
},
String_BigQuery_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: StringType,
},
{
name: '>',
operator: '_gt',
type: StringType,
},
{
name: '>=',
operator: '_gte',
type: StringType,
},
{
name: 'in',
operator: '_in',
type: StringType,
},
{
name: 'is null',
operator: '_is_null',
type: StringType,
},
{
name: 'like',
operator: '_like',
type: StringType,
},
{
name: '<',
operator: '_lt',
type: StringType,
},
{
name: '<=',
operator: '_lte',
type: StringType,
},
{
name: 'not equals',
operator: '_neq',
type: StringType,
},
{
name: 'not in',
operator: '_nin',
type: StringType,
},
{
name: 'not like',
operator: '_nlike',
type: StringType,
},
],
},
Float_BigQuery_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: FloatType,
},
{
name: '>',
operator: '_gt',
type: FloatType,
},
{
name: '>=',
operator: '_gte',
type: FloatType,
},
{
name: 'in',
operator: '_in',
type: FloatType,
},
{
name: 'is null',
operator: '_is_null',
type: FloatType,
},
{
name: '<',
operator: '_lt',
type: FloatType,
},
{
name: '<=',
operator: '_lte',
type: FloatType,
},
{
name: 'not equals',
operator: '_neq',
type: FloatType,
},
{
name: 'not in',
operator: '_nin',
type: FloatType,
},
],
},
Boolean_BigQuery_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: BooleanType,
},
{
name: '>',
operator: '_gt',
type: BooleanType,
},
{
name: '>=',
operator: '_gte',
type: BooleanType,
},
{
name: 'in',
operator: '_in',
type: BooleanType,
},
{
name: 'is null',
operator: '_is_null',
type: BooleanType,
},
{
name: '<',
operator: '_lt',
type: BooleanType,
},
{
name: '<=',
operator: '_lte',
type: BooleanType,
},
{
name: 'not equals',
operator: '_neq',
type: BooleanType,
},
{
name: 'not in',
operator: '_nin',
type: BooleanType,
},
],
},
Int_BigQuery_comparison_exp: {
operators: [
{
name: 'equals',
operator: '_eq',
type: IntType,
},
{
name: '>',
operator: '_gt',
type: IntType,
},
{
name: '>=',
operator: '_gte',
type: IntType,
},
{
name: 'in',
operator: '_in',
type: IntType,
},
{
name: 'is null',
operator: '_is_null',
type: IntType,
},
{
name: '<',
operator: '_lt',
type: IntType,
},
{
name: '<=',
operator: '_lte',
type: IntType,
},
{
name: 'not equals',
operator: '_neq',
type: IntType,
},
{
name: 'not in',
operator: '_nin',
type: IntType,
},
],
},
};

View File

@ -0,0 +1,35 @@
import {
buildClientSchema,
GraphQLSchema,
IntrospectionQuery,
NamedTypeNode,
parseType,
typeFromAST,
} from 'graphql';
import { introspection } from './introspection';
export const schema = new GraphQLSchema(
buildClientSchema(
introspection.data as unknown as IntrospectionQuery
).toConfig()
);
export function createType(typeName: string) {
const type = typeFromAST(schema, parseType(typeName) as NamedTypeNode);
if (!type) {
throw new Error(`Type ${typeName} not found in schema`);
}
return type;
}
export const StringType = createType('String');
export const BooleanType = createType('Boolean');
export const IntType = createType('Int');
export const FloatType = createType('Float');
export const numberType = createType('number');
export const stringType = createType('string');

View File

@ -0,0 +1,65 @@
import { Tables } from '../../types';
export const tables: Tables = [
{
table: { schema: 'public', name: 'Label' },
columns: [
{ name: 'id', type: 'Int_comparison_exp' },
{ name: 'name', type: 'String_comparison_exp' },
],
relationships: [],
},
{
table: ['Artist'],
columns: [
{ name: 'id', type: 'number_SQLite_comparison_exp' },
{ name: 'name', type: 'string_SQLite_comparison_exp' },
{ name: 'surname', type: 'string_SQLite_comparison_exp' },
],
relationships: [],
},
{
table: ['Album'],
columns: [
{ name: 'id', type: 'number_SQLite_comparison_exp' },
{ name: 'title', type: 'string_SQLite_comparison_exp' },
],
relationships: [
{
name: 'Author',
table: { schema: 'public', name: 'Artist' },
type: 'object',
},
{
name: 'Label',
table: { schema: 'public', name: 'Label' },
type: 'object',
},
],
},
{
table: ['Customer'],
columns: [],
relationships: [],
},
{
table: { dataset: 'bigquery_sample', name: 'sample_table' },
columns: [
{ name: 'Series_reference', type: 'String_BigQuery_comparison_exp' },
{ name: 'Period', type: 'Float_BigQuery_comparison_exp' },
{ name: 'Data_value', type: 'Float_BigQuery_comparison_exp' },
{ name: 'Suppressed', type: 'Boolean_BigQuery_comparison_exp' },
{ name: 'STATUS', type: 'String_BigQuery_comparison_exp' },
{ name: 'UNITS', type: 'String_BigQuery_comparison_exp' },
{ name: 'Magnitude', type: 'Int_comparison_exp' },
{ name: 'Subject', type: 'String_BigQuery_comparison_exp' },
{ name: 'Group', type: 'String_BigQuery_comparison_exp' },
{ name: 'Series_title_1', type: 'String_BigQuery_comparison_exp' },
{ name: 'Series_title_2', type: 'String_BigQuery_comparison_exp' },
{ name: 'Series_title_3', type: 'String_BigQuery_comparison_exp' },
{ name: 'Series_title_4', type: 'String_BigQuery_comparison_exp' },
{ name: 'Series_title_5', type: 'String_BigQuery_comparison_exp' },
],
relationships: [],
},
];

View File

@ -1,3 +1,2 @@
export * from './Builder';
export * from './FieldArray';
export * from './Elements';
export * from './types';
export * from './RowPermissionsInput';

View File

@ -0,0 +1,80 @@
import { Table } from '@/features/hasura-metadata-types';
import { GraphQLType } from 'graphql';
export type Operators = Record<
string,
{ label: string; items: Array<{ name: string; value: string }> }
>;
export type Permissions = Record<string, any>;
export type Columns = Array<{
name: string;
type: string;
graphQLType: GraphQLType;
}>;
export type Relationships = Array<{
name: string;
table: Table;
type: 'object' | 'array';
}>;
export type Tables = Array<{
table: Table;
columns: Columns;
relationships: Relationships;
}>;
export type Comparator = {
operators: Array<{
name: string;
operator: string;
defaultValue?: string;
type: GraphQLType;
}>;
};
export type Comparators = Record<string, Comparator>;
export type PermissionType =
| 'column'
| 'exist'
| 'relationship'
| 'value'
| 'comparator';
export type RowPermissionsState = {
operators: Operators;
permissions: Permissions;
comparators: Comparators;
table: Table;
tables: Tables;
setValue: (path: string[], value: any) => void;
setKey: (props: { path: string[]; key: any; type: PermissionType }) => void;
setPermissions: (permissions: Permissions) => void;
};
export type TypesContext = {
types: Record<string, { type: PermissionType }>;
setType: ({
type,
path,
value,
}: {
type: PermissionType;
path: string[];
value: any;
}) => void;
};
export type TableContext = {
table: Table;
setTable: (table: Table) => void;
comparator?: string | undefined;
setComparator: (comparator: string | undefined) => void;
columns: Columns;
setColumns: (columns: Columns) => void;
relationships: Relationships;
setRelationships: (relationships: Relationships) => void;
};

View File

@ -0,0 +1,21 @@
import { columnsFromSchema } from './columnsFromSchema';
import { schema, createType } from '../__tests__/fixtures/graphql';
describe('columnsFromSchema', () => {
it('should return columns from schema', () => {
const result = columnsFromSchema(schema);
const columns = result['Artist'];
expect(columns).toEqual([
{
name: 'ArtistId',
type: 'number_SQLite_comparison_exp',
graphQLType: createType('number_SQLite_comparison_exp'),
},
{
name: 'Name',
type: 'string_SQLite_comparison_exp',
graphQLType: createType('string_SQLite_comparison_exp'),
},
]);
});
});

View File

@ -0,0 +1,32 @@
import { GraphQLSchema, isInputObjectType } from 'graphql';
import { Columns } from '../types';
export function columnsFromSchema(
schema: GraphQLSchema | undefined
): Record<string, Columns> {
if (!schema) {
return {};
}
// Get input types ending in `_bool_exp`. They represent tables
// E.g: Artist_bool_exp
// For each table input type, get the fields that end with `_comparison_exp`
// Return a map of table name to columns
const inputObjectTypes = Object.values(schema.getTypeMap()).filter(
type => isInputObjectType(type) && type.name.endsWith('_bool_exp')
);
return inputObjectTypes.reduce((acc, inputType) => {
if (!isInputObjectType(inputType)) {
return acc;
}
const columns: Columns = Object.values(inputType.getFields())
.filter(field => field.type.toString().endsWith('_comparison_exp'))
.map(field => {
return {
name: field.name,
type: field.type.toString(),
graphQLType: field.type,
};
});
return { ...acc, [inputType.name.replace('_bool_exp', '')]: columns };
}, {} as Record<string, Columns>);
}

View File

@ -0,0 +1,57 @@
import { comparatorsFromSchema } from './comparatorsFromSchema';
import { NamedTypeNode, parseType, typeFromAST } from 'graphql';
import { schema } from '../__tests__/fixtures/graphql';
describe('comparatorsFromSchema', () => {
it('should return comparators from schema', () => {
const result = comparatorsFromSchema(schema);
const operator = result['number_SQLite_comparison_exp'];
expect(operator?.operators).toEqual([
{
name: 'equals',
operator: '_eq',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: '>',
operator: '_gt',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: '>=',
operator: '_gte',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: 'in',
operator: '_in',
type: typeFromAST(schema, parseType('[number!]') as NamedTypeNode),
},
{
name: 'is null',
operator: '_is_null',
type: typeFromAST(schema, parseType('Boolean') as NamedTypeNode),
},
{
name: '<',
operator: '_lt',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: '<=',
operator: '_lte',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: 'not equals',
operator: '_neq',
type: typeFromAST(schema, parseType('number') as NamedTypeNode),
},
{
name: 'not in',
operator: '_nin',
type: typeFromAST(schema, parseType('[number!]') as NamedTypeNode),
},
]);
});
});

View File

@ -0,0 +1,26 @@
import { allOperators } from '@/components/Common/FilterQuery/utils';
import { GraphQLSchema, isInputObjectType } from 'graphql';
import { lowerCase } from 'lodash';
import { Comparators } from '../types';
export function comparatorsFromSchema(schema: GraphQLSchema): Comparators {
// Get input types ending in `_comparison_exp`
// E.g: String_BigQuery_comparison_exp, string_SQLite_comparison_exp, String_comparison_exp, etc...
const inputObjectTypes = Object.values(schema.getTypeMap()).filter(
type => isInputObjectType(type) && type.name.endsWith('_comparison_exp')
);
return inputObjectTypes.reduce((acc, inputType) => {
if (!isInputObjectType(inputType)) {
return acc;
}
const operators = Object.values(inputType.getFields()).map(field => {
const name = field.name;
return {
name: allOperators.find(o => o.alias === name)?.name ?? lowerCase(name),
operator: name,
type: field.type,
};
});
return { ...acc, [inputType.name]: { operators } };
}, {});
}

View File

@ -0,0 +1,273 @@
import { PermissionType } from '../types';
import { updateKey } from './helpers';
describe('RowPermissionInput -> updateKey should', () => {
test('create root value object', () => {
const createRootValueInput = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _exists: { _table: {}, _where: {} } },
},
newKey: 'Series_reference',
keyPath: ['_exists'],
type: 'column' as PermissionType,
};
const result = updateKey(createRootValueInput);
expect(result).toEqual({
operators: {
boolean: {
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
label: 'Bool operators',
},
exist: {
items: [{ name: '_exists', value: '_exists' }],
label: 'Exist operators',
},
},
permissions: { Series_reference: { _eq: '' } },
});
});
test('remove previous key value if the new is as operator', () => {
const removePreviousInput = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { Series_reference: { _eq: '' } },
},
newKey: '_neq',
keyPath: ['Series_reference', '_eq'],
type: 'comparator' as PermissionType,
};
const result = updateKey(removePreviousInput);
expect(result).toEqual({
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { Series_reference: { _neq: '' } },
});
});
test('strip object from array when removing permission', () => {
const stripArrayLevelEmptyValuesInput = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _and: [{}, { Data_value: { _eq: '' } }] },
},
newKey: '',
keyPath: ['_and', '1', 'Data_value'],
};
const result = updateKey(stripArrayLevelEmptyValuesInput);
expect(result).toEqual({
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _and: [{}] },
});
});
test('reset root level state when picking empty value', () => {
const resetRootLevelWhenPickingEmptyValue = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: {
_not: { Series_reference: { _eq: 'X-Hasura-User-Id' } },
},
},
newKey: '',
keyPath: ['_not'],
};
const result = updateKey(resetRootLevelWhenPickingEmptyValue);
expect(result).toEqual({
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: {},
});
});
test('reset nested level state when picking empty value', () => {
const resetNestedLevelWhenPickingEmptyValue = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: {
_not: {
_and: [{}, { Series_reference: { _eq: 'X-Hasura-User-Id' } }],
},
},
},
newKey: '',
keyPath: ['_not', '_and'],
};
const result = updateKey(resetNestedLevelWhenPickingEmptyValue);
expect(result).toEqual({
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _not: {} },
});
});
test('switch from _not to _and', () => {
const resetNestedLevelWhenPickingEmptyValue = {
permissionsState: {
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _not: {} },
},
newKey: '',
keyPath: ['_not', '_and'],
};
const result = updateKey(resetNestedLevelWhenPickingEmptyValue);
expect(result).toEqual({
operators: {
boolean: {
label: 'Bool operators',
items: [
{ name: '_and', value: '_and' },
{ name: '_not', value: '_not' },
{ name: '_or', value: '_or' },
],
},
exist: {
label: 'Exist operators',
items: [{ name: '_exists', value: '_exists' }],
},
},
permissions: { _not: { _and: {} } },
});
});
});

View File

@ -0,0 +1,117 @@
import { get, isEmpty, set, unset, isObjectLike } from 'lodash';
import { RowPermissionsState, PermissionType } from '../types';
import { allOperators } from '@/components/Common/FilterQuery/utils';
import { GraphQLType, isScalarType } from 'graphql';
const getKeyPath = ({
keyPath,
newKey,
permissionsState,
type,
}: {
permissionsState: Pick<RowPermissionsState, 'permissions' | 'operators'>;
keyPath: string[];
newKey: string;
type?: PermissionType;
}) => {
// Store value before deleting key
const value = get(permissionsState, ['permissions', ...keyPath]);
let path = keyPath;
if (!isEmpty(value) || type === 'relationship') {
unset(permissionsState, ['permissions', ...keyPath]);
path = keyPath.slice(0, -1);
}
if (isComparator(newKey) && path.length >= 1) {
unset(permissionsState, ['permissions', ...keyPath]);
path = keyPath.slice(0, -1);
}
const previousKey = keyPath[keyPath.length - 1];
if ((previousKey === '_not' && newKey === '_and') || newKey === '_or') {
unset(permissionsState, ['permissions', ...keyPath]);
path = keyPath.slice(0, -1);
}
if (newKey === '') return ['permissions', ...path];
return ['permissions', ...path, newKey];
};
const getInitialValue = (key: string, type?: PermissionType) => {
switch (key) {
case '_and':
return [{}];
case '_or':
return [{}];
case '_not':
return {};
case '_exists':
return {
_where: {},
_table: {},
};
}
switch (type) {
case 'column':
// Depends on column type
return { _eq: '' };
case 'comparator':
// Depends on comparator type
return '';
}
return {};
};
export const updateKey = ({
permissionsState,
newKey,
keyPath,
type,
}: {
permissionsState: Pick<RowPermissionsState, 'permissions' | 'operators'>;
newKey: string; // New key to be set
keyPath: string[]; // Path to the key to be deleted
type?: PermissionType;
}) => {
const clone = { ...permissionsState };
const path = getKeyPath({ permissionsState: clone, keyPath, newKey, type });
const value = getInitialValue(newKey, type);
const parentKey = path[path.length - 1];
const parentIsArray = parseInt(parentKey);
if (parentIsArray) {
const prevPath = path.slice(0, -1);
const obj = get(clone, prevPath);
const filtered = obj.filter((o: Record<string, string>) => !isEmpty(o));
return set(clone, prevPath, filtered.length ? filtered : [{}]);
}
return set(clone, path, value);
};
export const isComparator = (k: string) => {
return allOperators.find(o => o.alias === k);
};
export const isPrimitive = (value: any) => {
return !isObjectLike(value);
};
export function graphQLTypeToJsType(
value: string,
type: GraphQLType | undefined
): boolean | string | number {
if (!isScalarType(type)) {
return value;
}
if (type.name === 'Int' || type.name === 'ID' || type.name === 'Float') {
return Number(value);
} else if (type.name === 'Boolean') {
return Boolean(value);
}
// Default to string on custom scalars since we have no way of knowing if they map to a number or boolean
return value;
}

View File

@ -0,0 +1,55 @@
import { Tables } from '../types';
import { getPermissionTypes } from './typeProviderHelpers';
describe('RowPermissionInput -> updateKey should', () => {
test('create root value object', () => {
const tables = [
{
table: {
dataset: 'bigquery_sample',
name: 'sample_table',
},
relationships: [
{
name: 'bq_test_relation',
type: 'object',
table: {
dataset: 'bigquery_sample',
name: 'sample_table',
type: 'object',
},
},
{
name: 'test_2',
table: {
dataset: 'bigquery_sample',
name: 'sample_table',
},
},
],
columns: [
{ name: 'Series_reference', type: 'STRING' },
{ name: 'Period', type: 'FLOAT64' },
{ name: 'Data_value', type: 'FLOAT64' },
{ name: 'Suppressed', type: 'BOOL' },
{ name: 'STATUS', type: 'STRING' },
{ name: 'UNITS', type: 'STRING' },
{ name: 'Magnitude', type: 'INT64' },
{ name: 'Subject', type: 'STRING' },
{ name: 'Group', type: 'STRING' },
{ name: 'Series_title_1', type: 'STRING' },
{ name: 'Series_title_2', type: 'STRING' },
{ name: 'Series_title_3', type: 'STRING' },
{ name: 'Series_title_4', type: 'STRING' },
{ name: 'Series_title_5', type: 'STRING' },
],
},
] as Tables;
const table = { dataset: 'bigquery_sample', name: 'sample_table' };
const permissions = { Series_reference: { _eq: '' } };
const result = getPermissionTypes(tables, table, permissions);
expect(result).toEqual({});
});
});

View File

@ -0,0 +1,33 @@
import { areTablesEqual } from '@/features/hasura-metadata-api';
import { Table } from '@/features/hasura-metadata-types';
import { isObjectLike, set } from 'lodash';
import { Permissions, Tables } from '../types';
export const getPermissionTypes = (
tables: Tables,
table: Table,
permissions: Permissions
) => {
const newTypes = {};
const setPermissionTypes = (value: any, path: string[]) => {
const relationshipTable = tables.find(t => areTablesEqual(t.table, table));
if (
relationshipTable?.relationships.find(
r => r.name === path[path.length - 1]
)
) {
set(newTypes, path.join('.'), { type: 'relationship' });
}
if (isObjectLike(value)) {
if (Array.isArray(value)) {
setPermissionTypes(value[0], [...path, '0']);
} else {
Object.keys(value).forEach(key => {
setPermissionTypes(value[key], [...path, key]);
});
}
}
};
setPermissionTypes(permissions, []);
return newTypes;
};

View File

@ -73,6 +73,7 @@ export const useData = ({ tableName, schema, table, dataSourceName }: Args) => {
return {
data: {
boolOperators: [],
existOperators: [],
columns: [],
relationships: [],
},

View File

@ -0,0 +1,7 @@
import { useIntrospectSchema } from '.';
import { comparatorsFromSchema } from '../components/utils/comparatorsFromSchema';
export function usePermissionComparators() {
const { data: schema } = useIntrospectSchema();
return schema ? comparatorsFromSchema(schema) : {};
}

View File

@ -0,0 +1,73 @@
import { MetadataSelectors, useMetadata } from '@/features/hasura-metadata-api';
import { Tables } from '../components';
import { MetadataTable } from '@/features/hasura-metadata-types';
import { columnsFromSchema } from '../components/utils/columnsFromSchema';
import { getTableDisplayName } from '@/features/DatabaseRelationships';
import { useIntrospectSchema } from '.';
export const usePermissionTables = ({
dataSourceName,
tableCustomName,
}: {
dataSourceName: string;
tableCustomName: string | undefined;
}) => {
const { data: tables } = useMetadata(
MetadataSelectors.getTables(dataSourceName)
);
const { data: schema } = useIntrospectSchema();
if (!tables) return [];
const allColumns = columnsFromSchema(schema);
return tables.map(table => {
// Table name. Replace . with _ because GraphQL doesn't allow . in field names
const tableName = getTableDisplayName(table.table).replace(/\./g, '_');
const customName = tableCustomName ?? tableName;
return {
table: table.table,
dataSource: dataSourceName,
relationships: tableRelationships(table),
columns: allColumns[customName] ?? [],
};
});
};
type Relationships = Tables[number]['relationships'];
function tableRelationships(table: MetadataTable): Relationships {
const relationships = [] as Relationships;
if (table.array_relationships) {
relationships.push(
...table.array_relationships.map(r => {
const relatedTable =
'manual_configuration' in r.using
? r.using.manual_configuration.remote_table
: r.using.foreign_key_constraint_on.table;
return {
name: r.name,
type: 'array' as const,
table: relatedTable,
};
})
);
}
if (table.object_relationships) {
relationships.push(
...table.object_relationships.map(r => {
const relatedTable =
'manual_configuration' in r.using
? r.using.manual_configuration.remote_table
: Array.isArray(r.using.foreign_key_constraint_on) ||
typeof r.using.foreign_key_constraint_on === 'string'
? r.using.foreign_key_constraint_on
: r.using.foreign_key_constraint_on.table;
return {
name: r.name,
type: 'object' as const,
table: relatedTable,
};
})
);
}
return relationships;
}

View File

@ -1,62 +1,80 @@
export const simpleExample = {
Title: {
_eq: 'hello',
import { TableColumn } from '@/features/DataSource';
import { MetadataDataSource } from '@/metadata/types';
export const tableColumns = [
{
name: 'Series_reference',
dataType: 'STRING',
consoleDataType: 'string',
nullable: false,
},
};
export const exampleWithBoolOperator = {
_and: [
{
age: {
_eq: 8,
},
},
{
email: {
_eq: 'adsff',
},
},
],
};
export const exampleWithRelationship = {
things: {
name: {
_eq: 'asdas',
},
{
name: 'Period',
dataType: 'FLOAT64',
consoleDataType: 'number',
nullable: false,
},
};
{
name: 'Data_value',
dataType: 'FLOAT64',
consoleDataType: 'number',
nullable: false,
},
] as TableColumn[];
export const complicatedExample = {
_and: [
export const sourceMetadata = {
name: 'bigquery_test1',
kind: 'bigquery',
tables: [
{
things: {
_and: [
{
user: {
age: {
_eq: 22,
table: { dataset: 'bigquery_sample', name: 'sample_table' },
object_relationships: [
{
name: 'bq_test_relation',
using: {
manual_configuration: {
column_mapping: { Period: 'STATUS' },
remote_table: {
dataset: 'bigquery_sample',
name: 'sample_table',
},
},
},
{
_or: [
{
fk_user_id: {
_eq: 'X-Hasura-User-Id',
},
},
{
name: 'test_2',
using: {
manual_configuration: {
column_mapping: { Period: 'Series_title_2' },
insertion_order: null,
remote_table: {
dataset: 'bigquery_sample',
name: 'sample_table',
},
{
user: {
age: {
_gte: 44,
},
},
},
],
},
},
],
},
},
],
select_permissions: [
{
role: 'user',
permission: {
columns: ['Series_reference', 'Period', 'Data_value'],
filter: {
_and: [{ Series_reference: { _eq: 'X-Hasura-User-Id' } }],
},
},
},
],
},
],
};
configuration: {
datasets: ['bigquery_sample'],
global_select_limit: 1,
project_id: 'sensei',
service_account: {
client_email: '@mail.com',
private_key: '',
project_id: 'sensei',
},
},
} as MetadataDataSource;

View File

@ -1,37 +1,55 @@
import {
schema,
simpleExample,
exampleWithBoolOperator,
exampleWithRelationship,
complicatedExample,
} from '../mocks';
import { tableColumns, sourceMetadata } from '../mocks';
import { createDefaultValues } from './createDefaultValues';
const tableName = 'bigquery_sample_sample_table';
type Expected = {
operators: Record<string, any> | undefined;
filter: Record<string, any> | undefined;
};
test('renders basic permission', () => {
test('renders root value permission', () => {
const result = createDefaultValues({
tableName: 'Album',
schema,
existingPermission: simpleExample,
tableConfig: {},
tableName,
tableColumns,
sourceMetadata,
existingPermission: { Series_reference: { _eq: 'X-Hasura-User-Id' } },
});
const expected: Expected = {
filter: {
Title: {
_eq: 'hello',
},
},
operators: {
filter: {
columnOperator: '_eq',
name: 'Title',
name: 'Series_reference',
typeName: 'Series_reference',
type: 'column',
typeName: 'Title',
columnOperator: '_eq',
},
},
filter: { Series_reference: { _eq: 'X-Hasura-User-Id' } },
};
expect(result).toEqual(expected);
});
test('renders _exist permission', () => {
const result = createDefaultValues({
tableName,
tableColumns,
sourceMetadata,
existingPermission: {
_exists: {
_table: { dataset: 'bigquery_sample', name: 'sample_table' },
_where: { Data_value: { _eq: 'X-Hasura-User-Id' } },
},
},
});
const expected: Expected = {
operators: { filter: '_exists' },
filter: {
_exists: {
_table: { dataset: 'bigquery_sample', name: 'sample_table' },
_where: { Data_value: { _eq: 'X-Hasura-User-Id' } },
},
},
};
@ -39,164 +57,69 @@ test('renders basic permission', () => {
expect(result).toEqual(expected);
});
test('renders bool operator permission', () => {
test('renders _and operator permission', () => {
const result = createDefaultValues({
tableName: 'Album',
schema,
existingPermission: exampleWithBoolOperator,
tableConfig: {},
tableName,
tableColumns,
sourceMetadata,
existingPermission: {
_and: [
{ Data_value: { _eq: 'X-Hasura-User-Id' } },
{ Group: { _eq: 'X-Hasura-User-Id' } },
],
},
});
const expected = {
filter: {
_and: [
{
age: {
_eq: 8,
},
},
{
email: {
_eq: 'adsff',
},
},
],
},
operators: {
filter: {
_and: ['age', 'email'],
name: '_and',
type: 'boolOperator',
typeName: '_and',
},
},
};
expect(result).toEqual(expected);
});
test('renders permission with relationship', () => {
const result = createDefaultValues({
tableName: 'Album',
schema,
existingPermission: exampleWithRelationship,
tableConfig: {},
});
const expected: Expected = {
filter: {
things: {
name: {
_eq: 'asdas',
},
},
},
operators: {
filter: 'things',
},
};
expect(result).toEqual(expected);
});
test('renders complex permission', () => {
const result = createDefaultValues({
tableName: 'user',
schema,
existingPermission: complicatedExample,
tableConfig: {},
});
const expected: Expected = {
filter: {
_and: [
{
things: {
_and: [
{
user: {
age: {
_eq: 22,
},
},
},
{
_or: [
{
fk_user_id: {
_eq: 'X-Hasura-User-Id',
},
},
{
user: {
age: {
_gte: 44,
},
},
},
],
},
],
},
},
],
},
operators: {
filter: {
type: 'boolOperator',
_and: [
{
name: 'things',
things: {
_and: [
{
name: 'user',
type: 'relationship',
typeName: 'user',
user: {
columnOperator: '_eq',
name: 'age',
type: 'column',
typeName: 'age',
},
},
{
_or: [
{
columnOperator: '_eq',
name: 'fk_user_id',
type: 'column',
typeName: 'fk_user_id',
},
{
name: 'user',
type: 'relationship',
typeName: 'user',
user: {
columnOperator: '_gte',
name: 'age',
type: 'column',
typeName: 'age',
},
},
],
name: '_or',
type: 'boolOperator',
typeName: '_or',
},
],
name: '_and',
type: 'boolOperator',
typeName: '_and',
},
type: 'relationship',
typeName: 'thing',
name: 'Data_value',
typeName: 'Data_value',
type: 'column',
columnOperator: '_eq',
},
'Group',
],
name: '_and',
type: 'boolOperator',
typeName: '_and',
},
},
filter: {
_and: [
{ Data_value: { _eq: 'X-Hasura-User-Id' } },
{ Group: { _eq: 'X-Hasura-User-Id' } },
],
},
};
expect(result).toEqual(expected);
});
test('renders _not operator with number value permission', () => {
const result = createDefaultValues({
tableName,
tableColumns,
sourceMetadata,
existingPermission: { _not: { Data_value: { _eq: 1337 } } },
});
const expected = {
operators: {
filter: {
name: '_not',
typeName: '_not',
type: 'boolOperator',
_not: {
name: 'Data_value',
typeName: 'Data_value',
type: 'column',
columnOperator: '_eq',
},
},
},
filter: { _not: { Data_value: { _eq: 1337 } } },
};
expect(result).toEqual(expected);

View File

@ -1,111 +1,29 @@
import { MetadataTable } from '@/features/hasura-metadata-types';
import { GraphQLSchema } from 'graphql';
import { getAllColumnsAndOperators } from '.';
import { createOperatorsObject } from '../../../PermissionsForm.utils';
import { TableColumn } from '@/features/DataSource';
import { MetadataDataSource } from '../../../../../../metadata/types';
export interface CreateOperatorsArgs {
tableName: string;
schema?: GraphQLSchema;
existingPermission?: Record<string, any>;
tableConfig: MetadataTable['configuration'];
}
export const createOperatorsObject = ({
tableName,
schema,
existingPermission,
tableConfig,
}: CreateOperatorsArgs): Record<string, any> => {
if (!existingPermission || !schema) {
return {};
}
const data = getAllColumnsAndOperators({ tableName, schema, tableConfig });
const colNames = data.columns.map(col => col.name);
const boolOperators = data.boolOperators.map(bo => bo.name);
const relationships = data.relationships.map(rel => rel.name);
const operators = Object.entries(existingPermission).reduce(
(_acc, [key, value]) => {
if (boolOperators.includes(key)) {
return {
name: key,
typeName: key,
type: 'boolOperator',
[key]: Array.isArray(value)
? value.map((each: Record<string, any>) =>
createOperatorsObject({
tableName,
schema,
existingPermission: each,
tableConfig,
})
)
: createOperatorsObject({
tableName,
schema,
existingPermission: value,
tableConfig,
}),
};
}
if (relationships.includes(key)) {
const rel = data.relationships.find(r => key === r.name);
const typeName = rel?.meta?.type?.type;
return {
name: key,
typeName,
type: 'relationship',
[key]: createOperatorsObject({
tableName: typeName || '',
schema,
existingPermission: value,
tableConfig,
}),
};
}
if (colNames.includes(key)) {
return {
name: key,
typeName: key,
type: 'column',
columnOperator: createOperatorsObject({
tableName,
schema,
existingPermission: value,
tableConfig,
}),
};
}
return key;
},
{}
);
return operators;
};
export interface CreateDefaultsArgs {
tableName: string;
schema?: GraphQLSchema;
existingPermission?: Record<string, any>;
tableConfig: MetadataTable['configuration'];
sourceMetadata: MetadataDataSource | undefined;
tableColumns: TableColumn[];
}
export const createDefaultValues = (props: CreateDefaultsArgs) => {
const { tableName, schema, existingPermission, tableConfig } = props;
const { tableName, existingPermission, tableColumns, sourceMetadata } = props;
if (!existingPermission) {
return {};
}
const operators = createOperatorsObject({
tableName,
schema,
existingPermission,
tableConfig,
tableColumns,
sourceMetadataTables: sourceMetadata?.tables,
});
return {

View File

@ -0,0 +1,7 @@
import { MetadataTable } from '@/features/hasura-metadata-types';
export const getMetadataTableCustomName = (
metadataTable: MetadataTable | undefined
) => {
return metadataTable?.configuration?.custom_name;
};

View File

@ -1,6 +1,5 @@
import {
getAllColumnsAndOperators,
findColumnOperator,
getColumnOperators,
} from './graphqlParsers';
import { schema } from '../mocks';
@ -93,21 +92,3 @@ test('correctly fetches operators for a given column', () => {
expect(result).toEqual(expected);
});
test('correctly fetches information about a column operator', () => {
const columnOperators = getColumnOperators({
tableName: 'user',
schema,
columnName: 'age',
tableConfig: {},
});
const result = findColumnOperator({ columnKey: '_eq', columnOperators });
const expected: ReturnType<typeof findColumnOperator> = {
name: '_eq',
type: {
type: 'float8',
},
};
expect(result).toEqual(expected);
});

View File

@ -1,3 +1,4 @@
import { Operator } from '@/features/DataSource';
import { MetadataTable } from '@/features/hasura-metadata-types';
import {
GraphQLFieldMap,
@ -52,6 +53,7 @@ export const getFields = (tableName: string, schema: GraphQLSchema) => {
if (isObjectType(type) || isInputObjectType(type)) {
const fields = type.getFields();
return fields;
}
@ -140,24 +142,15 @@ export const getBoolOperators = () => {
return ['_and', '_or', '_not'];
};
const getExistOperators = () => {
return ['_exists'];
};
interface FindColumnArgs {
columnKey: string;
columnOperators: ReturnType<typeof getColumnOperators>;
columnOperators: Operator[];
}
/**
*
* finds the name and type of specified column
*/
export const findColumnOperator = ({
columnKey,
columnOperators,
}: FindColumnArgs) => {
const columnOperatorArray = columnKey.split('.');
const value = columnOperatorArray[columnOperatorArray.length - 1];
return columnOperators.find(({ name }) => name === value);
};
interface Args {
tableName: string;
schema: GraphQLSchema;
@ -188,6 +181,7 @@ export const getAllColumnsAndOperators = ({
const metadataTableName = tableConfig?.custom_name ?? tableName;
const fields = getFields(metadataTableName, schema);
const boolOperators = getBoolOperators();
const existOperators = getExistOperators();
const columns = getColumns(fields);
const relationships = getRelationships(fields);
@ -196,6 +190,11 @@ export const getAllColumnsAndOperators = ({
kind: 'boolOperator',
meta: null,
}));
const existMap = existOperators.map(existOperator => ({
name: existOperator,
kind: 'existOperator',
meta: null,
}));
const colMap = columns.map(column => ({
name: getOriginalTableNameFromCustomName(tableConfig, column.name),
kind: 'column',
@ -206,5 +205,10 @@ export const getAllColumnsAndOperators = ({
kind: 'relationship',
meta: relationship,
}));
return { boolOperators: boolMap, columns: colMap, relationships: relMap };
return {
boolOperators: boolMap,
existOperators: existMap,
columns: colMap,
relationships: relMap,
};
};

View File

@ -1,5 +1,3 @@
import { GraphQLSchema } from 'graphql';
import isEqual from 'lodash.isequal';
import { TableColumn } from '@/features/DataSource';
@ -10,26 +8,25 @@ import { Metadata } from '@/features/hasura-metadata-types';
import { PermissionsSchema } from '../../../../../schema';
import type { QueryType } from '../../../../../types';
import { SourceCustomization } from '../../../../../../hasura-metadata-types/source/source';
import { Operator } from '../../../../../../DataSource/types';
import {
MetadataDataSource,
TableEntry,
} from '../../../../../../../metadata/types';
import {
createPermissionsObject,
getRowPermissionsForAllOtherQueriesMatchingSelectedRole,
} from './utils';
interface GetMetadataTableArgs {
dataSourceName: string;
table: unknown;
metadata: Metadata;
trackedTables: TableEntry[] | undefined;
}
const getMetadataTable = ({
dataSourceName,
table,
metadata,
}: GetMetadataTableArgs) => {
const trackedTables = metadata.metadata?.sources?.find(
source => source.name === dataSourceName
)?.tables;
const getMetadataTable = ({ table, trackedTables }: GetMetadataTableArgs) => {
// find selected table
const currentTable = trackedTables?.find(trackedTable =>
isEqual(trackedTable.table, table)
@ -38,45 +35,36 @@ const getMetadataTable = ({
return currentTable;
};
interface Args {
export interface CreateDefaultValuesArgs {
queryType: QueryType;
roleName: string;
table: unknown;
dataSourceName: string;
metadata: Metadata;
tableColumns: TableColumn[];
schema: GraphQLSchema;
defaultQueryRoot: string | never[];
metadataSource: MetadataDataSource | undefined;
supportedOperators: Operator[];
}
export const createDefaultValues = ({
queryType,
roleName,
table,
dataSourceName,
metadata,
tableColumns,
schema,
defaultQueryRoot,
}: Args) => {
metadataSource,
supportedOperators,
}: CreateDefaultValuesArgs) => {
const selectedTable = getMetadataTable({
dataSourceName,
table,
metadata,
trackedTables: metadataSource?.tables,
});
const metadataSource = metadata.metadata.sources.find(
s => s.name === dataSourceName
);
/**
* This is GDC specific, we have to move this to DAL later
*/
const tableName = getTypeName({
defaultQueryRoot,
operation: 'select',
sourceCustomization: metadataSource?.customization,
sourceCustomization: metadataSource?.customization as SourceCustomization,
configuration: selectedTable?.configuration,
});
@ -91,15 +79,17 @@ export const createDefaultValues = ({
filterType: 'none',
columns: {},
allRowChecks,
supportedOperators,
};
if (selectedTable) {
const permissionsObject = createPermissionsObject({
queryType,
selectedTable,
roleName,
tableColumns,
schema,
tableName,
metadataSource,
});
return { ...baseDefaultValues, ...permissionsObject };

View File

@ -8,6 +8,7 @@ import type {
MetadataTable,
Permission,
SelectPermissionDefinition,
Source,
UpdatePermissionDefinition,
} from '@/features/hasura-metadata-types';
@ -19,6 +20,10 @@ import {
import { createDefaultValues } from '../../../../components/RowPermissionsBuilder';
import type { QueryType } from '../../../../../types';
import {
MetadataDataSource,
TableEntry,
} from '../../../../../../../metadata/types';
export const getCheckType = (
check?: Record<string, unknown> | null
@ -111,7 +116,7 @@ export interface UseDefaultValuesArgs {
export const getRowPermissionsForAllOtherQueriesMatchingSelectedRole = (
selectedQuery: QueryType,
selectedRole: string,
table?: MetadataTable
table?: TableEntry
) => {
const res = Object.entries(table || {}).reduce<
Array<{ queryType: QueryType; value: any }>
@ -163,20 +168,20 @@ export const createPermission = {
select: (
permission: SelectPermissionDefinition,
tableColumns: TableColumn[],
schema: GraphQLSchema,
tableName: string,
tableConfig: MetadataTable['configuration']
metadataSource: MetadataDataSource | undefined
) => {
const { filter, operators } = createDefaultValues({
const { filter, operators: ops } = createDefaultValues({
tableName,
existingPermission: permission.filter,
schema,
tableConfig,
tableColumns,
sourceMetadata: metadataSource,
});
const filterType = getCheckType(permission?.filter);
const columns = getColumns(permission?.columns || [], tableColumns);
const rowCount = getRowCount({
currentQueryPermissions: permission,
});
@ -190,7 +195,7 @@ export const createPermission = {
columns,
rowCount,
aggregationEnabled,
operators,
operators: ops,
query_root_fields: permission.query_root_fields || null,
subscription_root_fields: permission.subscription_root_fields || null,
};
@ -252,7 +257,7 @@ export const createPermission = {
};
interface GetCurrentPermissionArgs {
table?: MetadataTable;
table?: TableEntry;
roleName: string;
queryType: QueryType;
}
@ -284,11 +289,11 @@ export const getCurrentPermission = ({
interface ObjArgs {
queryType: QueryType;
selectedTable: MetadataTable;
selectedTable: TableEntry;
tableColumns: TableColumn[];
roleName: string;
schema: any;
tableName: string;
metadataSource: MetadataDataSource | undefined;
}
export const createPermissionsObject = ({
@ -296,8 +301,8 @@ export const createPermissionsObject = ({
selectedTable,
tableColumns,
roleName,
schema,
tableName,
metadataSource,
}: ObjArgs) => {
const selectedPermission = getCurrentPermission({
table: selectedTable,
@ -315,9 +320,9 @@ export const createPermissionsObject = ({
return createPermission.select(
selectedPermission.permission as SelectPermissionDefinition,
tableColumns,
schema,
tableName,
selectedTable.configuration
// selectedTable.configuration,
metadataSource
);
case 'update':
return createPermission.update(

View File

@ -1,7 +1,10 @@
import { TableColumn } from '@/features/DataSource';
import { Metadata, MetadataTable } from '@/features/hasura-metadata-types';
import { Metadata } from '@/features/hasura-metadata-types';
import { isPermission } from '../../../../../utils';
import {
MetadataDataSource,
TableEntry,
} from '../../../../../../../metadata/types';
type Operation = 'insert' | 'select' | 'update' | 'delete';
@ -23,15 +26,11 @@ export const getAllowedFilterKeys = (
type GetMetadataTableArgs = {
dataSourceName: string;
table: unknown;
metadata: Metadata;
trackedTables: TableEntry[] | undefined;
};
const getMetadataTable = (args: GetMetadataTableArgs) => {
const { dataSourceName, table, metadata } = args;
const trackedTables = metadata.metadata?.sources?.find(
source => source.name === dataSourceName
)?.tables;
const { table, trackedTables } = args;
const selectedTable = trackedTables?.find(
trackedTable => JSON.stringify(trackedTable.table) === JSON.stringify(table)
@ -46,7 +45,7 @@ const getMetadataTable = (args: GetMetadataTableArgs) => {
};
};
const getRoles = (metadataTables?: MetadataTable[]) => {
const getRoles = (metadataTables?: TableEntry[]) => {
// go through all tracked tables
const res = metadataTables?.reduce<Set<string>>((acc, each) => {
// go through all permissions
@ -66,20 +65,22 @@ const getRoles = (metadataTables?: MetadataTable[]) => {
return Array.from(res || []);
};
interface Args {
export interface CreateFormDataArgs {
dataSourceName: string;
table: unknown;
metadata: Metadata;
tableColumns: TableColumn[];
metadataSource: MetadataDataSource | undefined;
trackedTables: TableEntry[] | undefined;
}
export const createFormData = (props: Args) => {
const { dataSourceName, table, metadata, tableColumns } = props;
export const createFormData = (props: CreateFormDataArgs) => {
const { dataSourceName, table, tableColumns, trackedTables } = props;
// find the specific metadata table
const metadataTable = getMetadataTable({
dataSourceName,
table,
metadata,
trackedTables: trackedTables,
});
const roles = getRoles(metadataTable.tables);

View File

@ -1,48 +1,87 @@
import { createFormData } from './createFormData';
import { createDefaultValues } from './createDefaultValues';
import { defaultValuesInput, formDataInput } from './mock';
import { useFormDataCreateDefaultValuesMock, createFormDataMock } from './mock';
const formDataMockResult: ReturnType<typeof createFormData> = {
columns: ['ArtistId', 'Name'],
roles: ['user'],
const formDataMockResult = {
roles: ['asdf', 'new', 'sdfsf', 'testrole', 'user'],
supportedQueries: ['select'],
tableNames: [['Album'], ['Artist']],
tableNames: [{ dataset: 'bigquery_sample', name: 'sample_table' }],
columns: [
'Series_reference',
'Period',
'Data_value',
'Suppressed',
'STATUS',
'UNITS',
'Magnitude',
'Subject',
'Group',
'Series_title_1',
'Series_title_2',
'Series_title_3',
'Series_title_4',
'Series_title_5',
],
};
test('returns correctly formatted formData', () => {
const result = createFormData(formDataInput);
const result = createFormData(createFormDataMock);
expect(result).toEqual(formDataMockResult);
});
const defaultValuesMockResult: ReturnType<typeof createDefaultValues> = {
aggregationEnabled: true,
allRowChecks: [],
columns: {
ArtistId: false,
Name: true,
},
filter: {
ArtistId: {
_gt: 5,
},
},
queryType: 'select',
filterType: 'custom',
columns: {
Series_reference: false,
Period: false,
Data_value: false,
Suppressed: false,
STATUS: false,
UNITS: false,
Magnitude: false,
Subject: false,
Group: false,
Series_title_1: false,
Series_title_2: false,
Series_title_3: false,
Series_title_4: false,
Series_title_5: false,
},
allRowChecks: [],
supportedOperators: [
{ name: 'equals', value: '_eq' },
{ name: 'not equals', value: '_neq' },
{ name: 'in', value: '_in', defaultValue: '[]' },
{ name: 'nin', value: '_nin', defaultValue: '[]' },
{ name: '>', value: '_gt' },
{ name: '<', value: '_lt' },
{ name: '>=', value: '_gte' },
{ name: '<=', value: '_lte' },
{ name: 'like', value: '_like', defaultValue: '%%' },
{ name: 'not like', value: '_nlike', defaultValue: '%%' },
],
filter: { _not: { Data_value: { _eq: 1337 } } },
rowCount: '0',
aggregationEnabled: false,
operators: {
filter: {
columnOperator: '_gt',
name: 'ArtistId',
type: 'column',
typeName: 'ArtistId',
name: '_not',
typeName: '_not',
type: 'boolOperator',
_not: {
name: 'Data_value',
typeName: 'Data_value',
type: 'column',
columnOperator: '_eq',
},
},
},
query_root_fields: null,
subscription_root_fields: ['select', 'select_by_pk'],
queryType: 'select',
rowCount: '3',
subscription_root_fields: null,
};
test('use default values returns values correctly', () => {
const result = createDefaultValues(defaultValuesInput);
const result = createDefaultValues(useFormDataCreateDefaultValuesMock);
expect(result).toEqual(defaultValuesMockResult);
});

View File

@ -1,26 +1,33 @@
import { useQuery } from 'react-query';
import { buildClientSchema } from 'graphql';
import {
DataSource,
exportMetadata,
Operator,
runIntrospectionQuery,
TableColumn,
} from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { createDefaultValues } from './createDefaultValues';
import { createFormData } from './createFormData';
import { Table } from '@/dataSources';
import { Source } from '@/features/hasura-metadata-types';
import { MetadataDataSource } from '@/metadata/types';
import { Feature } from '../../../../../DataSource/index';
export type Args = {
dataSourceName: string;
table: unknown;
roleName: string;
queryType: 'select' | 'insert' | 'update' | 'delete';
tableColumns: TableColumn[];
metadataSource: MetadataDataSource | undefined;
};
type ReturnValue = {
formData: ReturnType<typeof createFormData>;
defaultValues: ReturnType<typeof createDefaultValues>;
formData: ReturnType<typeof createFormData> | undefined;
defaultValues: ReturnType<typeof createDefaultValues> | undefined;
};
/**
@ -33,6 +40,8 @@ export const useFormData = ({
table,
roleName,
queryType,
tableColumns = [],
metadataSource,
}: Args) => {
const httpClient = useHttpClient();
return useQuery<ReturnValue, Error>({
@ -41,18 +50,13 @@ export const useFormData = ({
'permissionFormData',
JSON.stringify(table),
roleName,
tableColumns,
],
queryFn: async () => {
const introspectionResult = await runIntrospectionQuery({ httpClient });
const schema = buildClientSchema(introspectionResult.data);
if (tableColumns.length === 0)
return { formData: undefined, defaultValues: undefined };
const metadata = await exportMetadata({ httpClient });
// get table columns for metadata table from db introspection
const tableColumns = await DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
const defaultQueryRoot = await DataSource(httpClient).getDefaultQueryRoot(
{
dataSourceName,
@ -60,6 +64,12 @@ export const useFormData = ({
}
);
const supportedOperators = (await DataSource(
httpClient
).getSupportedOperators({
dataSourceName,
})) as Operator[];
const defaultValues = createDefaultValues({
queryType,
roleName,
@ -67,8 +77,9 @@ export const useFormData = ({
metadata,
table,
tableColumns,
schema,
defaultQueryRoot,
metadataSource,
supportedOperators: supportedOperators ?? [],
});
const formData = createFormData({
@ -76,10 +87,13 @@ export const useFormData = ({
table,
metadata,
tableColumns,
trackedTables: metadataSource?.tables,
metadataSource,
});
return { formData, defaultValues };
},
refetchOnWindowFocus: false,
});
};

View File

@ -101,9 +101,8 @@ export const useSubmitForm = (args: UseSubmitFormArgs) => {
accessType,
resourceVersion: resource_version,
formData,
existingPermissions,
existingPermissions: existingPermissions ?? [],
});
await mutate.mutateAsync(
{
query: body,
@ -115,6 +114,9 @@ export const useSubmitForm = (args: UseSubmitFormArgs) => {
title: 'Success!',
message: 'Permissions saved successfully!',
});
exportMetadata({
httpClient,
});
},
onError: err => {
fireNotification({

View File

@ -40,6 +40,7 @@ export const schema = z.discriminatedUnion('queryType', [
columns,
presets,
backendOnly: z.boolean().optional(),
supportedOperators: z.array(z.any()),
clonePermissions: z.array(permission).optional(),
}),
z.object({
@ -53,6 +54,7 @@ export const schema = z.discriminatedUnion('queryType', [
clonePermissions: z.array(permission).optional(),
query_root_fields: z.array(z.string()).nullable().optional(),
subscription_root_fields: z.array(z.string()).nullable().optional(),
supportedOperators: z.array(z.any()),
}),
z.object({
queryType: z.literal('update'),
@ -63,6 +65,7 @@ export const schema = z.discriminatedUnion('queryType', [
check: z.any(),
presets,
backendOnly: z.boolean().optional(),
supportedOperators: z.array(z.any()),
clonePermissions: z.array(permission).optional(),
}),
z.object({
@ -70,6 +73,7 @@ export const schema = z.discriminatedUnion('queryType', [
filterType: z.string(),
filter: z.any(),
backendOnly: z.boolean().optional(),
supportedOperators: z.array(z.any()),
clonePermissions: z.array(permission).optional(),
}),
]);

View File

@ -142,7 +142,6 @@ export const metadataHandlers: Partial<
},
};
}
console.log({ query_name, queries: existingCollection.definition.queries });
const existingQuery = existingCollection.definition.queries.find(
q => q.name === query_name
);

View File

@ -12,7 +12,7 @@ export const DEFAULT_STALE_TIME = 5 * 60000; // 5 minutes as default stale time
Default stale time is 5 minutes, but can be adjusted using the staleTime arg
*/
const METADATA_QUERY_KEY = 'export_metadata';
export const METADATA_QUERY_KEY = 'export_metadata';
export const useInvalidateMetadata = () => {
const queryClient = useQueryClient();

View File

@ -84,6 +84,7 @@
"lodash.isobject": "^3.0.2",
"lodash.merge": "4.6.2",
"lodash.pickby": "^4.6.0",
"lodash.set": "^4.3.2",
"lodash.uniqueid": "^4.0.1",
"lru-memoize": "1.0.0",
"luxon": "^1.17.2",
@ -225,6 +226,7 @@
"@types/lodash.isobject": "^3.0.7",
"@types/lodash.merge": "^4.6.6",
"@types/lodash.pickby": "^4.6.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.uniqueid": "^4.0.7",
"@types/mini-css-extract-plugin": "0.9.1",
"@types/node": "16.11.7",
@ -278,7 +280,7 @@
"crypto-browserify": "^3.12.0",
"css-loader": "3.5.3",
"cypress": "^10.2.0",
"cypress-plugin-snapshots": "^1.4.4",
"cypress-plugin-snapshots": "1.4.4",
"cypress-wait-until": "^1.7.2",
"dedent": "0.7.0",
"dotenv": "5.0.1",
@ -27469,6 +27471,15 @@
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.set": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.7.tgz",
"integrity": "sha512-bS5Wkg/nrT82YUfkNYPSccFrNZRL+irl7Yt4iM6OTSQ0VZJED2oUIVm15NkNtUAQ8SRhCe+axqERUV6MJgkeEg==",
"dev": true,
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.uniqueid": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@types/lodash.uniqueid/-/lodash.uniqueid-4.0.7.tgz",
@ -84627,6 +84638,15 @@
"@types/lodash": "*"
}
},
"@types/lodash.set": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.7.tgz",
"integrity": "sha512-bS5Wkg/nrT82YUfkNYPSccFrNZRL+irl7Yt4iM6OTSQ0VZJED2oUIVm15NkNtUAQ8SRhCe+axqERUV6MJgkeEg==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/lodash.uniqueid": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@types/lodash.uniqueid/-/lodash.uniqueid-4.0.7.tgz",

View File

@ -119,6 +119,7 @@
"lodash.isobject": "^3.0.2",
"lodash.merge": "4.6.2",
"lodash.pickby": "^4.6.0",
"lodash.set": "^4.3.2",
"lodash.uniqueid": "^4.0.1",
"lru-memoize": "1.0.0",
"luxon": "^1.17.2",
@ -260,6 +261,7 @@
"@types/lodash.isobject": "^3.0.7",
"@types/lodash.merge": "^4.6.6",
"@types/lodash.pickby": "^4.6.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.uniqueid": "^4.0.7",
"@types/mini-css-extract-plugin": "0.9.1",
"@types/node": "16.11.7",