forked from mirrors/thatmattlove-hyperglass
continue generic command work
This commit is contained in:
parent
029649e44f
commit
f40004b38f
14 changed files with 152 additions and 101 deletions
|
|
@ -26,20 +26,13 @@ from hyperglass.constants import (
|
|||
from hyperglass.exceptions import ConfigError, ConfigMissing
|
||||
from hyperglass.util.files import check_path
|
||||
|
||||
# from hyperglass.models.commands import Commands
|
||||
from hyperglass.models.commands.generic import Directive
|
||||
from hyperglass.models.config.params import Params
|
||||
from hyperglass.models.config.devices import Devices, Device
|
||||
from hyperglass.configuration.defaults import (
|
||||
CREDIT,
|
||||
DEFAULT_HELP,
|
||||
DEFAULT_TERMS,
|
||||
DEFAULT_DETAILS,
|
||||
)
|
||||
from hyperglass.models.config.devices import Devices
|
||||
|
||||
# Local
|
||||
from .markdown import get_markdown
|
||||
from .validation import validate_config, validate_nos_commands
|
||||
from .validation import validate_config
|
||||
|
||||
set_app_path(required=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# Third Party
|
||||
from pydantic import BaseModel, StrictStr, constr, validator
|
||||
|
|
@ -22,6 +23,7 @@ from .validators import (
|
|||
validate_community_select,
|
||||
)
|
||||
from ..config.vrf import Vrf
|
||||
from ..commands.generic import Directive
|
||||
|
||||
|
||||
def get_vrf_object(vrf_name: str) -> Vrf:
|
||||
|
|
@ -41,12 +43,23 @@ def get_vrf_object(vrf_name: str) -> Vrf:
|
|||
raise InputInvalid(params.messages.vrf_not_found, vrf_name=vrf_name)
|
||||
|
||||
|
||||
def get_directive(group: str) -> Optional[Directive]:
|
||||
for device in devices.objects:
|
||||
for command in device.commands:
|
||||
if group in command.groups:
|
||||
return command
|
||||
# TODO: Move this to a param
|
||||
# raise InputInvalid("Group {group} not found", group=group)
|
||||
return None
|
||||
|
||||
|
||||
class Query(BaseModel):
|
||||
"""Validation model for input query parameters."""
|
||||
|
||||
query_location: StrictStr
|
||||
query_type: SupportedQuery
|
||||
query_vrf: StrictStr
|
||||
# query_vrf: StrictStr
|
||||
query_group: StrictStr
|
||||
query_target: constr(strip_whitespace=True, min_length=1)
|
||||
|
||||
class Config:
|
||||
|
|
@ -64,7 +77,12 @@ class Query(BaseModel):
|
|||
"description": "Type of Query to Execute",
|
||||
"example": "bgp_route",
|
||||
},
|
||||
"query_vrf": {
|
||||
# "query_vrf": {
|
||||
# "title": params.web.text.query_vrf,
|
||||
# "description": "Routing Table/VRF",
|
||||
# "example": "default",
|
||||
# },
|
||||
"query_group": {
|
||||
"title": params.web.text.query_vrf,
|
||||
"description": "Routing Table/VRF",
|
||||
"example": "default",
|
||||
|
|
@ -88,7 +106,7 @@ class Query(BaseModel):
|
|||
"""Represent only the query fields."""
|
||||
return (
|
||||
f"Query(query_location={str(self.query_location)}, "
|
||||
f"query_type={str(self.query_type)}, query_vrf={str(self.query_vrf)}, "
|
||||
f"query_type={str(self.query_type)}, query_group={str(self.query_group)}, "
|
||||
f"query_target={str(self.query_target)})"
|
||||
)
|
||||
|
||||
|
|
@ -108,7 +126,7 @@ class Query(BaseModel):
|
|||
items = (
|
||||
f"query_location={self.query_location}",
|
||||
f"query_type={self.query_type}",
|
||||
f"query_vrf={self.query_vrf.name}",
|
||||
f"query_group={self.query_group}",
|
||||
f"query_target={str(self.query_target)}",
|
||||
)
|
||||
return f'Query({", ".join(items)})'
|
||||
|
|
@ -130,14 +148,14 @@ class Query(BaseModel):
|
|||
items = {
|
||||
"query_location": self.device.name,
|
||||
"query_type": self.query.display_name,
|
||||
"query_vrf": self.query_vrf.display_name,
|
||||
"query_group": self.query_group,
|
||||
"query_target": str(self.query_target),
|
||||
}
|
||||
else:
|
||||
items = {
|
||||
"query_location": self.query_location,
|
||||
"query_type": self.query_type,
|
||||
"query_vrf": self.query_vrf._id,
|
||||
"query_group": self.query_group,
|
||||
"query_target": str(self.query_target),
|
||||
}
|
||||
return items
|
||||
|
|
@ -177,26 +195,35 @@ class Query(BaseModel):
|
|||
)
|
||||
return value
|
||||
|
||||
@validator("query_vrf")
|
||||
def validate_query_vrf(cls, value, values):
|
||||
"""Ensure query_vrf is defined."""
|
||||
# @validator("query_vrf")
|
||||
# def validate_query_vrf(cls, value, values):
|
||||
# """Ensure query_vrf is defined."""
|
||||
|
||||
vrf_object = get_vrf_object(value)
|
||||
device = devices[values["query_location"]]
|
||||
device_vrf = None
|
||||
# vrf_object = get_vrf_object(value)
|
||||
# device = devices[values["query_location"]]
|
||||
# device_vrf = None
|
||||
|
||||
for vrf in device.vrfs:
|
||||
if vrf == vrf_object:
|
||||
device_vrf = vrf
|
||||
break
|
||||
# for vrf in device.vrfs:
|
||||
# if vrf == vrf_object:
|
||||
# device_vrf = vrf
|
||||
# break
|
||||
|
||||
if device_vrf is None:
|
||||
raise InputInvalid(
|
||||
params.messages.vrf_not_associated,
|
||||
vrf_name=vrf_object.display_name,
|
||||
device_name=device.name,
|
||||
)
|
||||
return device_vrf
|
||||
# if device_vrf is None:
|
||||
# raise InputInvalid(
|
||||
# params.messages.vrf_not_associated,
|
||||
# vrf_name=vrf_object.display_name,
|
||||
# device_name=device.name,
|
||||
# )
|
||||
# return device_vrf
|
||||
|
||||
# @validator("query_group")
|
||||
# def validate_query_group(cls, value, values):
|
||||
# """Ensure query_vrf is defined."""
|
||||
|
||||
# obj = get_directive(value)
|
||||
# if obj is not None:
|
||||
# ...
|
||||
# return device_vrf
|
||||
|
||||
@validator("query_target")
|
||||
def validate_query_target(cls, value, values):
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ class Directive(HyperglassModel):
|
|||
def frontend(self, params: Params) -> Dict:
|
||||
|
||||
value = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"field_type": self.field_type,
|
||||
"groups": self.groups,
|
||||
|
|
|
|||
|
|
@ -77,13 +77,16 @@ export const Footer: React.FC = () => {
|
|||
if (item.show_icon) {
|
||||
icon.rightIcon = <ExtIcon />;
|
||||
}
|
||||
return <FooterLink href={url} title={item.title} {...icon} />;
|
||||
return <FooterLink key={item.title} href={url} title={item.title} {...icon} />;
|
||||
} else if (isMenu(item)) {
|
||||
return <FooterButton side="right" content={item.content} title={item.title} />;
|
||||
return (
|
||||
<FooterButton key={item.title} side="right" content={item.content} title={item.title} />
|
||||
);
|
||||
}
|
||||
})}
|
||||
<If c={web.credit.enable}>
|
||||
<FooterButton
|
||||
key="credit"
|
||||
side="right"
|
||||
content={content.credit}
|
||||
title={<Icon as={CodeIcon} boxSize={size} />}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Flex, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { If } from '~/components';
|
||||
|
|
@ -13,15 +14,19 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
const errorColor = useColorValue('red.500', 'red.300');
|
||||
const opacity = useBooleanValue(hiddenLabels, 0, undefined);
|
||||
|
||||
const [error, setError] = useState<Nullable<FieldError>>(null);
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
const error = name in errors && (errors[name] as FieldError);
|
||||
|
||||
if (error !== false) {
|
||||
console.warn(`Error on field '${label}': ${error.message}`);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (name in errors) {
|
||||
console.dir(errors);
|
||||
setError(errors[name]);
|
||||
console.warn(`Error on field '${label}': ${error?.message}`);
|
||||
}
|
||||
}, [error, errors, setError]);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
|
|
@ -29,8 +34,8 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
w="100%"
|
||||
maxW="100%"
|
||||
flexDir="column"
|
||||
isInvalid={error !== null}
|
||||
my={{ base: 2, lg: 4 }}
|
||||
isInvalid={error !== false}
|
||||
{...rest}
|
||||
>
|
||||
<FormLabel
|
||||
|
|
@ -41,7 +46,7 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
opacity={opacity}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
color={error !== false ? errorColor : labelColor}
|
||||
color={error !== null ? errorColor : labelColor}
|
||||
>
|
||||
{label}
|
||||
<If c={typeof labelAddOn !== 'undefined'}>{labelAddOn}</If>
|
||||
|
|
@ -52,7 +57,7 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
{fieldAddOn}
|
||||
</Flex>
|
||||
</If>
|
||||
<FormErrorMessage opacity={opacity}>{error && error.message}</FormErrorMessage>
|
||||
<FormErrorMessage opacity={opacity}>{error?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@ import type { TQuerySelectField } from './types';
|
|||
// .map(q => ({ value: q.name, label: q.display_name }));
|
||||
// }
|
||||
|
||||
function* buildOptions(networks: TNetwork[]): Generator<TSelectOption> {
|
||||
for (const net of networks) {
|
||||
for (const loc of net.locations) {
|
||||
for (const directive of loc.directives) {
|
||||
const { name } = directive;
|
||||
yield { value: name, label: name };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// function* buildOptions(networks: TNetwork[]): Generator<TSelectOption> {
|
||||
// for (const net of networks) {
|
||||
// for (const loc of net.locations) {
|
||||
// for (const directive of loc.directives) {
|
||||
// const { name } = directive;
|
||||
// yield { value: name, label: name };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
|
||||
const { onChange, label } = props;
|
||||
|
|
@ -67,7 +67,8 @@ export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField)
|
|||
aria-label={label}
|
||||
onChange={handleChange}
|
||||
value={exportState(selections.queryType.value)}
|
||||
isError={typeof errors.query_type !== 'undefined'}
|
||||
// isError={typeof errors.query_type !== 'undefined'}
|
||||
isError={'query_type' in errors}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { Frame } from './frame';
|
|||
|
||||
export const Layout: React.FC = () => {
|
||||
const { formReady } = useLGMethods();
|
||||
const ready = formReady();
|
||||
console.log('ready', ready);
|
||||
return (
|
||||
<Frame>
|
||||
{formReady() ? (
|
||||
{ready ? (
|
||||
<Results />
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks';
|
||||
import { isQueryType, isQueryContent, isString, isQueryField } from '~/types';
|
||||
import { isQueryType, isQueryContent, isString, isQueryField, TDirective } from '~/types';
|
||||
|
||||
import type { TFormData, TDeviceVrf, OnChangeArgs } from '~/types';
|
||||
|
||||
|
|
@ -52,34 +52,6 @@ export const LookingGlass: React.FC = () => {
|
|||
const noQueryLoc = useStrf(messages.no_input, { field: web.text.query_location });
|
||||
const noQueryTarget = useStrf(messages.no_input, { field: web.text.query_target });
|
||||
|
||||
const formSchema = vest.create((data: TFormData = {} as TFormData) => {
|
||||
test('query_location', noQueryLoc, () => {
|
||||
enforce(data.query_location).isArrayOf(enforce.isString()).isNotEmpty();
|
||||
});
|
||||
test('query_target', noQueryTarget, () => {
|
||||
enforce(data.query_target).longerThan(1);
|
||||
});
|
||||
test('query_type', noQueryType, () => {
|
||||
enforce(data.query_type).anyOf(
|
||||
enforce.equals('bgp_route'),
|
||||
enforce.equals('bgp_community'),
|
||||
enforce.equals('bgp_aspath'),
|
||||
enforce.equals('ping'),
|
||||
enforce.equals('traceroute'),
|
||||
);
|
||||
});
|
||||
test('query_vrf', 'Query VRF is empty', () => {
|
||||
enforce(data.query_vrf).isString();
|
||||
});
|
||||
});
|
||||
|
||||
const formInstance = useForm<TFormData>({
|
||||
resolver: vestResolver(formSchema),
|
||||
defaultValues: { query_vrf: 'default', query_target: '', query_location: [], query_type: '' },
|
||||
});
|
||||
|
||||
const { handleSubmit, register, setValue, setError, clearErrors } = formInstance;
|
||||
|
||||
const {
|
||||
availableGroups,
|
||||
queryVrf,
|
||||
|
|
@ -95,7 +67,37 @@ export const LookingGlass: React.FC = () => {
|
|||
selections,
|
||||
} = useLGState();
|
||||
|
||||
const { resolvedOpen, resetForm } = useLGMethods();
|
||||
const queryTypes = useMemo(() => availableTypes.map(t => t.id.value), [availableTypes.length]);
|
||||
|
||||
const formSchema = vest.create((data: TFormData = {} as TFormData) => {
|
||||
test('query_location', noQueryLoc, () => {
|
||||
enforce(data.query_location).isArrayOf(enforce.isString()).isNotEmpty();
|
||||
});
|
||||
test('query_target', noQueryTarget, () => {
|
||||
enforce(data.query_target).longerThan(1);
|
||||
});
|
||||
test('query_type', noQueryType, () => {
|
||||
enforce(data.query_type).inside(queryTypes);
|
||||
});
|
||||
test('query_group', 'Query Group is empty', () => {
|
||||
enforce(data.query_group).isString();
|
||||
});
|
||||
});
|
||||
|
||||
const formInstance = useForm<TFormData>({
|
||||
resolver: vestResolver(formSchema),
|
||||
defaultValues: {
|
||||
// query_vrf: 'default',
|
||||
query_target: '',
|
||||
query_location: [],
|
||||
query_type: '',
|
||||
query_group: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, register, setValue, setError, clearErrors } = formInstance;
|
||||
|
||||
const { resolvedOpen, resetForm, getDirective } = useLGMethods();
|
||||
|
||||
const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value);
|
||||
|
||||
|
|
@ -115,6 +117,13 @@ export const LookingGlass: React.FC = () => {
|
|||
}, [queryType.value]);
|
||||
|
||||
function submitHandler() {
|
||||
console.table({
|
||||
'Query Location': queryLocation.value,
|
||||
'Query Type': queryType.value,
|
||||
'Query Group': queryGroup.value,
|
||||
'Query Target': queryTarget.value,
|
||||
'Selected Directive': selectedDirective?.name ?? null,
|
||||
});
|
||||
/**
|
||||
* Before submitting a query, make sure the greeting is acknowledged if required. This should
|
||||
* be handled before loading the app, but people be sneaky.
|
||||
|
|
@ -153,7 +162,7 @@ export const LookingGlass: React.FC = () => {
|
|||
const allVrfs = [] as TDeviceVrf[][];
|
||||
const locationNames = [] as string[];
|
||||
const allGroups = [] as string[][];
|
||||
const allTypes = [] as string[][];
|
||||
const allTypes = [] as TDirective[][];
|
||||
const allDevices = [];
|
||||
|
||||
queryLocation.set(locations);
|
||||
|
|
@ -187,7 +196,8 @@ export const LookingGlass: React.FC = () => {
|
|||
for (const directive of device.directives) {
|
||||
if (directive.groups.includes(group)) {
|
||||
// allTypes.add(directive.name);
|
||||
allTypes.push(device.directives.map(d => d.name));
|
||||
allTypes.push(device.directives);
|
||||
// allTypes.push(device.directives.map(d => d.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +225,7 @@ export const LookingGlass: React.FC = () => {
|
|||
message: `${locationNames.join(', ')} have no query types in common.`,
|
||||
});
|
||||
} else if (intersectingTypes.length === 1) {
|
||||
queryType.set(intersectingTypes[0]);
|
||||
queryType.set(intersectingTypes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,20 +273,13 @@ export const LookingGlass: React.FC = () => {
|
|||
// queryGroup.set(e.value);
|
||||
handleGroupChange(e.value);
|
||||
}
|
||||
console.table({
|
||||
'Query Location': queryLocation.value,
|
||||
'Query Type': queryType.value,
|
||||
'Query Group': queryGroup.value,
|
||||
'Query Target': queryTarget.value,
|
||||
'Selected Directive': selectedDirective?.name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
register('query_location', { required: true });
|
||||
register('query_target', { required: true });
|
||||
register('query_type', { required: true });
|
||||
register('query_vrf');
|
||||
register('query_group');
|
||||
}, [register]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ export const Tags: React.FC = () => {
|
|||
queryTypeLabel = queries[queryType.value].display_name;
|
||||
}
|
||||
|
||||
const getVrf = useVrf();
|
||||
const vrf = getVrf(queryVrf.value);
|
||||
// const getVrf = useVrf();
|
||||
// const vrf = getVrf(queryVrf.value);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -115,7 +115,8 @@ export const Tags: React.FC = () => {
|
|||
<Label
|
||||
bg={vrfBg}
|
||||
label={web.text.query_vrf}
|
||||
value={vrf.display_name}
|
||||
// value={vrf.display_name}
|
||||
value="fix me"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface TMethodsExtension {
|
|||
formReady(): boolean;
|
||||
resetForm(): void;
|
||||
stateExporter<O extends unknown>(o: O): O | null;
|
||||
getDirective(n: string): Nullable<State<TDirective>>;
|
||||
}
|
||||
|
||||
export type TLGState = {
|
||||
|
|
@ -65,7 +66,7 @@ export type TLGState = {
|
|||
queryLocation: string[];
|
||||
availVrfs: TDeviceVrf[];
|
||||
availableGroups: string[];
|
||||
availableTypes: string[];
|
||||
availableTypes: TDirective[];
|
||||
resolvedIsOpen: boolean;
|
||||
selections: TSelections;
|
||||
responses: { [d: string]: TQueryResponse };
|
||||
|
|
@ -79,6 +80,7 @@ export type TLGStateHandlers = {
|
|||
formReady(): boolean;
|
||||
resetForm(): void;
|
||||
stateExporter<O extends unknown>(o: O): O | null;
|
||||
getDirective(n: string): Nullable<State<TDirective>>;
|
||||
};
|
||||
|
||||
export type UseStrfArgs = { [k: string]: unknown } | string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { all } from '~/util';
|
|||
|
||||
import type { State, PluginStateControl, Plugin } from '@hookstate/core';
|
||||
import type { TLGState, TLGStateHandlers, TMethodsExtension } from './types';
|
||||
import { TDirective } from '~/types';
|
||||
|
||||
const MethodsId = Symbol('Methods');
|
||||
|
||||
|
|
@ -37,6 +38,14 @@ class MethodsInstance {
|
|||
}
|
||||
}
|
||||
|
||||
public getDirective(state: State<TLGState>, name: string): Nullable<State<TDirective>> {
|
||||
const [directive] = state.availableTypes.filter(t => t.name.value === name);
|
||||
if (typeof directive !== 'undefined') {
|
||||
return directive;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the form is ready for submission, e.g. all fields have values and isSubmitting
|
||||
* has been set to true. This ultimately controls the UI layout.
|
||||
|
|
@ -46,7 +55,7 @@ class MethodsInstance {
|
|||
state.isSubmitting.value &&
|
||||
all(
|
||||
...[
|
||||
state.queryVrf.value !== '',
|
||||
// state.queryVrf.value !== '',
|
||||
state.queryType.value !== '',
|
||||
state.queryGroup.value !== '',
|
||||
state.queryTarget.value !== '',
|
||||
|
|
@ -122,6 +131,7 @@ function Methods(inst?: State<TLGState>): Plugin | TMethodsExtension {
|
|||
resolvedClose: () => instance.resolvedClose(inst),
|
||||
getResponse: device => instance.getResponse(inst, device),
|
||||
stateExporter: obj => instance.stateExporter(obj),
|
||||
getDirective: name => instance.getDirective(inst, name),
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export interface TDeviceVrf extends TDeviceVrfBase {
|
|||
}
|
||||
|
||||
interface TDirectiveBase {
|
||||
id: string;
|
||||
name: string;
|
||||
field_type: 'text' | 'select' | null;
|
||||
description: string;
|
||||
|
|
|
|||
2
hyperglass/ui/types/globals.d.ts
vendored
2
hyperglass/ui/types/globals.d.ts
vendored
|
|
@ -4,6 +4,8 @@ declare global {
|
|||
type Dict<T = string> = Record<string, T>;
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
type TRPKIStates = 0 | 1 | 2 | 3;
|
||||
|
||||
type TResponseLevel = 'success' | 'warning' | 'error' | 'danger';
|
||||
|
|
|
|||
|
|
@ -62,5 +62,5 @@ export function isState<S>(a: any): a is State<NonNullable<S>> {
|
|||
* Determine if a form field name is a valid form key name.
|
||||
*/
|
||||
export function isQueryField(field: string): field is keyof TFormData {
|
||||
return ['query_location', 'query_type', 'query_vrf', 'query_target'].includes(field);
|
||||
return ['query_location', 'query_type', 'query_group', 'query_target'].includes(field);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue