mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-14 02:35:56 +08:00
QueryBuilder filters for log pipelines (#3587)
This commit is contained in:
parent
30e0924bfb
commit
8bfb0b5088
@ -7,7 +7,7 @@ const config: Config.InitialOptions = {
|
|||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||||
modulePathIgnorePatterns: ['dist'],
|
modulePathIgnorePatterns: ['dist'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'\\.(css|less)$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
"delete_processor_description": "Logs are processed sequentially in processors. Deleting a processor may change content of data processed by other processors",
|
"delete_processor_description": "Logs are processed sequentially in processors. Deleting a processor may change content of data processed by other processors",
|
||||||
"search_pipeline_placeholder": "Filter Pipelines",
|
"search_pipeline_placeholder": "Filter Pipelines",
|
||||||
"pipeline_name_placeholder": "Name",
|
"pipeline_name_placeholder": "Name",
|
||||||
|
"pipeline_filter_placeholder": "Filter for selecting logs to be processed by this pipeline. Example: service_name = billing",
|
||||||
"pipeline_tags_placeholder": "Tags",
|
"pipeline_tags_placeholder": "Tags",
|
||||||
"pipeline_description_placeholder": "Enter description for your pipeline",
|
"pipeline_description_placeholder": "Enter description for your pipeline",
|
||||||
"processor_name_placeholder": "Name",
|
"processor_name_placeholder": "Name",
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
import { Form } from 'antd';
|
||||||
|
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||||
|
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { ProcessorFormField } from '../../AddNewProcessor/config';
|
||||||
|
import { formValidationRules } from '../../config';
|
||||||
|
import { FormLabelStyle } from '../styles';
|
||||||
|
|
||||||
|
function TagFilterInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: TagFilterInputProps): JSX.Element {
|
||||||
|
const query = { ...initialQueryBuilderFormValuesMap.logs };
|
||||||
|
if (value) {
|
||||||
|
query.filters = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onQueryChange = (newValue: TagFilter): void => {
|
||||||
|
// Avoid unnecessary onChange calls
|
||||||
|
if (!isEqual(newValue, query.filters)) {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryBuilderSearch
|
||||||
|
query={query}
|
||||||
|
onChange={onQueryChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagFilterInputProps {
|
||||||
|
onChange: (filter: TagFilter) => void;
|
||||||
|
value: TagFilter;
|
||||||
|
placeholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterInput({ fieldData }: FilterInputProps): JSX.Element {
|
||||||
|
const { t } = useTranslation('pipeline');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
required={false}
|
||||||
|
label={<FormLabelStyle>{fieldData.fieldName}</FormLabelStyle>}
|
||||||
|
key={fieldData.id}
|
||||||
|
rules={formValidationRules}
|
||||||
|
name={fieldData.name}
|
||||||
|
>
|
||||||
|
{/* Antd form will supply value and onChange to <TagFilterInput /> here.
|
||||||
|
// @ts-ignore */}
|
||||||
|
<TagFilterInput placeholder={t(fieldData.placeholder)} />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
interface FilterInputProps {
|
||||||
|
fieldData: ProcessorFormField;
|
||||||
|
}
|
||||||
|
export default FilterInput;
|
@ -1,31 +0,0 @@
|
|||||||
import { Form, Input } from 'antd';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { ProcessorFormField } from '../../AddNewProcessor/config';
|
|
||||||
import { formValidationRules } from '../../config';
|
|
||||||
import { FormLabelStyle } from '../styles';
|
|
||||||
|
|
||||||
function FilterSearch({ fieldData }: FilterSearchProps): JSX.Element {
|
|
||||||
const { t } = useTranslation('pipeline');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.Item
|
|
||||||
required={false}
|
|
||||||
label={<FormLabelStyle>{fieldData.fieldName}</FormLabelStyle>}
|
|
||||||
key={fieldData.id}
|
|
||||||
rules={formValidationRules}
|
|
||||||
name={fieldData.name}
|
|
||||||
>
|
|
||||||
<Input.Search
|
|
||||||
id={fieldData.id.toString()}
|
|
||||||
name={fieldData.name}
|
|
||||||
placeholder={t(fieldData.placeholder)}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
interface FilterSearchProps {
|
|
||||||
fieldData: ProcessorFormField;
|
|
||||||
}
|
|
||||||
export default FilterSearch;
|
|
@ -0,0 +1,24 @@
|
|||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
|
||||||
|
import { PipelineData } from 'types/api/pipeline/def';
|
||||||
|
|
||||||
|
function PipelineFilterPreview({
|
||||||
|
filter,
|
||||||
|
}: PipelineFilterPreviewProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="pipeline-filter-preview-container">
|
||||||
|
{queryFilterTags(filter).map((tag) => (
|
||||||
|
<div className="pipeline-filter-preview-condition" key={tag}>
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineFilterPreviewProps {
|
||||||
|
filter: PipelineData['filter'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PipelineFilterPreview;
|
@ -0,0 +1,10 @@
|
|||||||
|
.pipeline-filter-preview-condition {
|
||||||
|
padding: 0 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-filter-preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { PipelineData, ProcessorData } from 'types/api/pipeline/def';
|
|||||||
|
|
||||||
import { PipelineIndexIcon } from '../AddNewProcessor/styles';
|
import { PipelineIndexIcon } from '../AddNewProcessor/styles';
|
||||||
import { ColumnDataStyle, ListDataStyle, ProcessorIndexIcon } from '../styles';
|
import { ColumnDataStyle, ListDataStyle, ProcessorIndexIcon } from '../styles';
|
||||||
|
import PipelineFilterPreview from './PipelineFilterPreview';
|
||||||
|
|
||||||
const componentMap: ComponentMap = {
|
const componentMap: ComponentMap = {
|
||||||
orderId: ({ record }) => <PipelineIndexIcon>{record}</PipelineIndexIcon>,
|
orderId: ({ record }) => <PipelineIndexIcon>{record}</PipelineIndexIcon>,
|
||||||
@ -14,6 +15,7 @@ const componentMap: ComponentMap = {
|
|||||||
),
|
),
|
||||||
id: ({ record }) => <ProcessorIndexIcon>{record}</ProcessorIndexIcon>,
|
id: ({ record }) => <ProcessorIndexIcon>{record}</ProcessorIndexIcon>,
|
||||||
name: ({ record }) => <ListDataStyle>{record}</ListDataStyle>,
|
name: ({ record }) => <ListDataStyle>{record}</ListDataStyle>,
|
||||||
|
filter: ({ record }) => <PipelineFilterPreview filter={record} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function TableComponents({
|
function TableComponents({
|
||||||
@ -31,7 +33,9 @@ type ComponentMap = {
|
|||||||
[key: string]: React.FC<{ record: Record }>;
|
[key: string]: React.FC<{ record: Record }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Record = PipelineData['orderId'] & ProcessorData;
|
export type Record = PipelineData['orderId'] &
|
||||||
|
PipelineData['filter'] &
|
||||||
|
ProcessorData;
|
||||||
|
|
||||||
interface TableComponentsProps {
|
interface TableComponentsProps {
|
||||||
columnKey: string;
|
columnKey: string;
|
||||||
|
@ -8,15 +8,16 @@ import {
|
|||||||
import DeploymentStage from '../Layouts/ChangeHistory/DeploymentStage';
|
import DeploymentStage from '../Layouts/ChangeHistory/DeploymentStage';
|
||||||
import DeploymentTime from '../Layouts/ChangeHistory/DeploymentTime';
|
import DeploymentTime from '../Layouts/ChangeHistory/DeploymentTime';
|
||||||
import DescriptionTextArea from './AddNewPipeline/FormFields/DescriptionTextArea';
|
import DescriptionTextArea from './AddNewPipeline/FormFields/DescriptionTextArea';
|
||||||
|
import FilterInput from './AddNewPipeline/FormFields/FilterInput';
|
||||||
import NameInput from './AddNewPipeline/FormFields/NameInput';
|
import NameInput from './AddNewPipeline/FormFields/NameInput';
|
||||||
|
|
||||||
export const pipelineFields = [
|
export const pipelineFields = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
fieldName: 'Filter',
|
fieldName: 'Filter',
|
||||||
placeholder: 'search_pipeline_placeholder',
|
placeholder: 'pipeline_filter_placeholder',
|
||||||
name: 'filter',
|
name: 'filter',
|
||||||
component: NameInput,
|
component: FilterInput,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
@ -1,7 +1,33 @@
|
|||||||
import { Pipeline, PipelineData } from 'types/api/pipeline/def';
|
import { Pipeline, PipelineData } from 'types/api/pipeline/def';
|
||||||
|
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
export const configurationVersion = '1.0';
|
export const configurationVersion = '1.0';
|
||||||
|
|
||||||
|
export function mockPipelineFilter(
|
||||||
|
key: string,
|
||||||
|
op: string,
|
||||||
|
value: string,
|
||||||
|
): TagFilter {
|
||||||
|
return {
|
||||||
|
op: 'AND',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `${key}-${value}`,
|
||||||
|
key: {
|
||||||
|
key,
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
isColumn: false,
|
||||||
|
isJSON: false,
|
||||||
|
},
|
||||||
|
op,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const pipelineMockData: Array<PipelineData> = [
|
export const pipelineMockData: Array<PipelineData> = [
|
||||||
{
|
{
|
||||||
id: '4453c8b0-c0fd-42bf-bf09-7cc1b04ccdc9',
|
id: '4453c8b0-c0fd-42bf-bf09-7cc1b04ccdc9',
|
||||||
@ -10,7 +36,7 @@ export const pipelineMockData: Array<PipelineData> = [
|
|||||||
alias: 'apachecommonparser',
|
alias: 'apachecommonparser',
|
||||||
description: 'This is a desc',
|
description: 'This is a desc',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
filter: 'attributes.source == nginx',
|
filter: mockPipelineFilter('source', '=', 'nginx'),
|
||||||
config: [
|
config: [
|
||||||
{
|
{
|
||||||
orderId: 1,
|
orderId: 1,
|
||||||
@ -43,7 +69,7 @@ export const pipelineMockData: Array<PipelineData> = [
|
|||||||
alias: 'movingpipelinenew',
|
alias: 'movingpipelinenew',
|
||||||
description: 'This is a desc of move',
|
description: 'This is a desc of move',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
filter: 'attributes.method == POST',
|
filter: mockPipelineFilter('method', '=', 'POST'),
|
||||||
config: [
|
config: [
|
||||||
{
|
{
|
||||||
orderId: 1,
|
orderId: 1,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import i18n from 'ReactI18';
|
import i18n from 'ReactI18';
|
||||||
@ -27,25 +28,36 @@ beforeAll(() => {
|
|||||||
matchMedia();
|
matchMedia();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('PipelinePage container test', () => {
|
describe('PipelinePage container test', () => {
|
||||||
it('should render AddNewPipeline section', () => {
|
it('should render AddNewPipeline section', () => {
|
||||||
const setActionType = jest.fn();
|
const setActionType = jest.fn();
|
||||||
const selectedPipelineData = pipelineMockData[0];
|
const selectedPipelineData = pipelineMockData[0];
|
||||||
const isActionType = 'add-pipeline';
|
const isActionType = 'add-pipeline';
|
||||||
|
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Provider store={store}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<I18nextProvider i18n={i18n}>
|
<Provider store={store}>
|
||||||
<AddNewPipeline
|
<I18nextProvider i18n={i18n}>
|
||||||
isActionType={isActionType}
|
<AddNewPipeline
|
||||||
setActionType={setActionType}
|
isActionType={isActionType}
|
||||||
selectedPipelineData={selectedPipelineData}
|
setActionType={setActionType}
|
||||||
setShowSaveButton={jest.fn()}
|
selectedPipelineData={selectedPipelineData}
|
||||||
setCurrPipelineData={jest.fn()}
|
setShowSaveButton={jest.fn()}
|
||||||
currPipelineData={pipelineMockData}
|
setCurrPipelineData={jest.fn()}
|
||||||
/>
|
currPipelineData={pipelineMockData}
|
||||||
</I18nextProvider>
|
/>
|
||||||
</Provider>
|
</I18nextProvider>
|
||||||
|
</Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { pipelineMockData } from '../mocks/pipeline';
|
import { mockPipelineFilter, pipelineMockData } from '../mocks/pipeline';
|
||||||
import {
|
import {
|
||||||
processorFields,
|
processorFields,
|
||||||
processorTypes,
|
processorTypes,
|
||||||
@ -68,7 +68,7 @@ describe('Utils testing of Pipeline Page', () => {
|
|||||||
...pipelineMockData[findRecordIndex],
|
...pipelineMockData[findRecordIndex],
|
||||||
name: 'updated name',
|
name: 'updated name',
|
||||||
description: 'changed description',
|
description: 'changed description',
|
||||||
filter: 'value == test',
|
filter: mockPipelineFilter('value', '=', 'test'),
|
||||||
tags: ['test'],
|
tags: ['test'],
|
||||||
};
|
};
|
||||||
const editedData = getEditedDataSource(
|
const editedData = getEditedDataSource(
|
||||||
|
@ -41,6 +41,7 @@ function QueryBuilderSearch({
|
|||||||
onChange,
|
onChange,
|
||||||
whereClauseConfig,
|
whereClauseConfig,
|
||||||
className,
|
className,
|
||||||
|
placeholder,
|
||||||
}: QueryBuilderSearchProps): JSX.Element {
|
}: QueryBuilderSearchProps): JSX.Element {
|
||||||
const {
|
const {
|
||||||
updateTag,
|
updateTag,
|
||||||
@ -190,7 +191,7 @@ function QueryBuilderSearch({
|
|||||||
filterOption={false}
|
filterOption={false}
|
||||||
autoClearSearchValue={false}
|
autoClearSearchValue={false}
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
placeholder={PLACEHOLDER}
|
placeholder={placeholder}
|
||||||
value={queryTags}
|
value={queryTags}
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
className={className}
|
className={className}
|
||||||
@ -218,11 +219,13 @@ interface QueryBuilderSearchProps {
|
|||||||
onChange: (value: TagFilter) => void;
|
onChange: (value: TagFilter) => void;
|
||||||
whereClauseConfig?: WhereClauseConfig;
|
whereClauseConfig?: WhereClauseConfig;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilderSearch.defaultProps = {
|
QueryBuilderSearch.defaultProps = {
|
||||||
whereClauseConfig: undefined,
|
whereClauseConfig: undefined,
|
||||||
className: '',
|
className: '',
|
||||||
|
placeholder: PLACEHOLDER,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CustomTagProps {
|
export interface CustomTagProps {
|
||||||
|
@ -7,10 +7,32 @@ import {
|
|||||||
import { unparse } from 'papaparse';
|
import { unparse } from 'papaparse';
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
TagFilter,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
import { WhereClauseConfig } from './useAutoComplete';
|
import { WhereClauseConfig } from './useAutoComplete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for formatting a TagFilter object into filter item strings
|
||||||
|
* @param {TagFilter} filters - query filter object to be converted
|
||||||
|
* @returns {string[]} An array of formatted conditions. Eg: `["service = web", "severity_text = INFO"]`)
|
||||||
|
*/
|
||||||
|
export function queryFilterTags(filter: TagFilter): string[] {
|
||||||
|
return (filter?.items || []).map((ele) => {
|
||||||
|
if (isInNInOperator(getOperatorFromValue(ele.op))) {
|
||||||
|
try {
|
||||||
|
const csvString = unparse([ele.value]);
|
||||||
|
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${csvString}`;
|
||||||
|
} catch {
|
||||||
|
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${ele.value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${ele.value}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type IUseTag = {
|
type IUseTag = {
|
||||||
handleAddTag: (value: string) => void;
|
handleAddTag: (value: string) => void;
|
||||||
handleClearTag: (value: string) => void;
|
handleClearTag: (value: string) => void;
|
||||||
@ -33,21 +55,9 @@ export const useTag = (
|
|||||||
setSearchKey: (value: string) => void,
|
setSearchKey: (value: string) => void,
|
||||||
whereClauseConfig?: WhereClauseConfig,
|
whereClauseConfig?: WhereClauseConfig,
|
||||||
): IUseTag => {
|
): IUseTag => {
|
||||||
const initTagsData = useMemo(
|
const initTagsData = useMemo(() => queryFilterTags(query?.filters), [
|
||||||
() =>
|
query?.filters,
|
||||||
(query?.filters?.items || []).map((ele) => {
|
]);
|
||||||
if (isInNInOperator(getOperatorFromValue(ele.op))) {
|
|
||||||
try {
|
|
||||||
const csvString = unparse([ele.value]);
|
|
||||||
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${csvString}`;
|
|
||||||
} catch {
|
|
||||||
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${ele.value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${ele.key?.key} ${getOperatorFromValue(ele.op)} ${ele.value}`;
|
|
||||||
}),
|
|
||||||
[query.filters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [tags, setTags] = useState<string[]>(initTagsData);
|
const [tags, setTags] = useState<string[]>(initTagsData);
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
export interface ProcessorData {
|
export interface ProcessorData {
|
||||||
type: string;
|
type: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -23,7 +25,7 @@ export interface PipelineData {
|
|||||||
description?: string;
|
description?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
filter: string;
|
filter: TagFilter;
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
orderId: number;
|
orderId: number;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user