/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import React, { useState, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import HelpIcon from '@mui/icons-material/HelpRounded';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import pgAdmin from 'sources/pgadmin';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import Loader from 'sources/components/Loader';
import getApiInstance, { parseApiError } from '../../../../static/js/api_instance';
import { PrimaryButton, PgIconButton } from '../../../../static/js/components/Buttons';
import { ModalContent } from '../../../../static/js/components/ModalContent';
import { FormFooterMessage, InputSelect, InputText, MESSAGE_TYPE } from '../../../../static/js/components/FormComponents';
import PgReactDataGrid from '../../../../static/js/components/PgReactDataGrid';
const StyledBox = styled(Box)(({theme}) => ({
'& .SearchObjects-toolbar': {
padding: '4px',
display: 'flex',
...theme.mixins.panelBorder?.bottom,
'& .SearchObjects-inputSearch': {
lineHeight: 1,
},
'& .SearchObjects-Btnmargin': {
marginLeft: '0.25rem',
},
},
'& .SearchObjects-footer1': {
justifyContent: 'space-between',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
borderTop: `1px solid ${theme.otherVars.inputBorderColor}`,
},
'&.SearchObjects-footer': {
borderTop: `1px solid ${theme.otherVars.inputBorderColor} !important`,
padding: '0.5rem',
display: 'flex',
width: '100%',
background: theme.otherVars.headerBg,
},
'& .SearchObjects-grid': {
fontSize: '13px !important',
'& .rdg-header-row': {
'& .rdg-cell': {
padding: '0px 4px !important',
}
},
'& .rdg-cell': {
padding: '0px 4px',
'&[aria-colindex="1"]': {
padding: '0px 4px !important',
'&.rdg-editor-container': {
padding: '0px',
},
},
'& .SearchObjects-textWrap': {
textOverflow: 'ellipsis',
overflow: 'hidden'
},
'& .SearchObjects-cellMuted': {
color: `${theme.otherVars.textMuted} !important`,
cursor: 'default !important',
},
'& .SearchObjects-gridCell': {
display: 'inline-block',
height: '1.3rem',
width: '1.3rem',
'& .SearchObjects-funcArgs': {
cursor: 'pointer',
},
},
}
},
}));
const pgBrowser = pgAdmin.Browser;
function ObjectNameFormatter({row}) {
return (
{row.name}
{row.other_info != null && row.other_info != '' && (
{row.showArgs = true;}} onKeyDown={()=>{/* no need */}}> {row?.showArgs ? `(${row.other_info})` : '(...)'}
)}
);
}
ObjectNameFormatter.propTypes = {
row: PropTypes.object,
};
function TypePathFormatter({row, column}) {
let val = '';
if(column.key == 'type') {
val = row.type_label;
} else if(column.key == 'path') {
val = row.path;
}
return (
{val}
);
}
TypePathFormatter.propTypes = {
row: PropTypes.object,
column: PropTypes.object,
};
const columns = [
{
key: 'name',
name: gettext('Object name'),
width: 250,
formatter: ObjectNameFormatter,
},{
key: 'type',
name: gettext('Type'),
width: 30,
formatter: TypePathFormatter,
},{
key: 'path',
name: gettext('Object path'),
enableSorting: false,
formatter: TypePathFormatter,
}
];
/* This function is used to get the final data with the proper icon
* based on the type and translated path.
*/
const finaliseData = (nodeData, datum)=> {
datum.icon = 'icon-' + datum.type;
/* finalise path */
[datum.path, datum.id_path] = translateSearchObjectsPath(nodeData, datum.path, datum.catalog_level);
/* id is required by dataview */
datum.id = datum.id_path ? datum.id_path.join('.') : _.uniqueId(datum.name);
datum.other_info = datum.other_info ? _.escape(datum.other_info) : datum.other_info;
return datum;
};
const getCollNode = (node_type)=> {
if('coll-'+node_type in pgBrowser.Nodes) {
return pgBrowser.Nodes['coll-'+node_type];
} else if(node_type in pgBrowser.Nodes &&
typeof(pgBrowser.Nodes[node_type].collection_type) === 'string') {
return pgBrowser.Nodes[pgBrowser.Nodes[node_type].collection_type];
}
return null;
};
/* This function will translate the path given by search objects API into two parts
* 1. The display path on the UI
* 2. The tree search path to locate the object on the tree.
*
* Sample path returned by search objects API
* :schema.11:/pg_catalog/:table.2604:/pg_attrdef
*
* Sample path required by tree locator
* Normal object - server_group/1.server/3.coll-database/3.database/13258.coll-schema/13258.schema/2200.coll-table/2200.table/41773
* pg_catalog schema - server_group/1.server/3.coll-database/3.database/13258.coll-catalog/13258.catalog/11.coll-table/11.table/2600
* Information Schema, sys:
* server_group/1.server/3.coll-database/3.database/13258.coll-catalog/13258.catalog/12967.coll-catalog_object/12967.catalog_object/13204
* server_group/1.server/11.coll-database/11.database/13258.coll-catalog/13258.catalog/12967.coll-catalog_object/12967.catalog_object/12997.coll-catalog_object_column/12997.catalog_object_column/13
*
* Column catalog_level has values as
* N - Not a catalog schema
* D - Catalog schema with DB support - pg_catalog
* O - Catalog schema with object support only - info schema, sys
*/
const translateSearchObjectsPath = (nodeData, path, catalog_level)=> {
if (path === null) {
return [null, null];
}
catalog_level = catalog_level || 'N';
/* path required by tree locator */
/* the path received from the backend is after the DB node, initial path setup */
let id_path = [
nodeData?.server_group?.id,
nodeData?.server?.id,
getCollNode('database').type + '_' + nodeData?.server?._id,
nodeData?.database?.id,
];
let prev_node_id = nodeData?.database?._id;
/* add the slash to match regex, remove it from display path later */
path = '/' + path;
/* the below regex will match all /:schema.2200:/ */
let new_path = path.replace(/\/:[a-zA-Z_]+\.\d+:\//g, (token)=>{
let orig_token = token;
/* remove the slash and colon */
token = token.slice(2, -2);
let [node_type, node_oid, others] = token.split('.');
if(typeof(others) !== 'undefined') {
return token;
}
/* schema type is "catalog" for catalog schemas */
node_type = (['D', 'O'].indexOf(catalog_level) != -1 && node_type == 'schema') ? 'catalog' : node_type;
/* catalog like info schema will only have views and tables AKA catalog_object except for pg_catalog */
node_type = (catalog_level === 'O' && ['view', 'table'].indexOf(node_type) != -1) ? 'catalog_object' : node_type;
/* catalog_object will have column node as catalog_object_column */
node_type = (catalog_level === 'O' && node_type == 'column') ? 'catalog_object_column' : node_type;
/* If collection node present then add it */
let coll_node = getCollNode(node_type);
if(coll_node) {
/* Add coll node to the path */
if(prev_node_id != null) id_path.push(`${coll_node.type}_${prev_node_id}`);
/* Add the node to the path */
id_path.push(`${node_type}_${node_oid}`);
/* This will be needed for coll node */
prev_node_id = node_oid;
/* This will be displayed in the grid */
return `/${coll_node.label}/`;
} else if(node_type in pgBrowser.Nodes) {
/* Add the node to the path */
id_path.push(`${node_type}_${node_oid}`);
/* This will be need for coll node id path */
prev_node_id = node_oid;
/* Remove the token and replace with slash. This will be displayed in the grid */
return '/';
}
prev_node_id = null;
return orig_token;
});
/* Remove the slash we had added */
new_path = new_path.substring(1);
return [new_path, id_path];
};
// This function is used to sort the column.
function getComparator(sortColumn) {
const key = sortColumn?.columnKey;
const dir = sortColumn?.direction == 'ASC' ? 1 : -1;
if (!key) return ()=>0;
return (a, b) => {
return dir*(a[key].localeCompare(b[key]));
};
}
export default function SearchObjects({nodeData}) {
const [type, setType] = React.useState('all');
const [loaderText, setLoaderText] = useState('');
const [search, setSearch] = useState('');
const [footerText, setFooterText] = useState('0 matches found.');
const [searchData, setSearchData] = useState([]);
const [sortColumns, setSortColumns] = useState([]);
const [errorMsg, setErrorMsg] = useState('');
const api = getApiInstance();
const onDialogHelp = ()=> {
window.open(url_for('help.static', { 'filename': 'search_objects.html' }), 'pgadmin_help');
};
const sortedItems = useMemo(()=>(
[...searchData].sort(getComparator(sortColumns[0]))
), [searchData, sortColumns]);
const onItemEnter = useCallback((rowData)=>{
let tree = pgBrowser.tree;
setErrorMsg('');
if(!rowData.show_node) {
setErrorMsg(
gettext('%s objects are disabled in the browser. You can enable them in the preferences dialog.', rowData.type_label));
setTimeout(()=> {
document.getElementById('prefdlgid').addEventListener('click', ()=>{
if(pgAdmin.Preferences) {
pgAdmin.Preferences.show();
}
});
}, 100);
return false;
}
setLoaderText(gettext('Locating...'));
tree.findNodeWithToggle(rowData.id_path)
.then((treeItem)=>{
setTimeout(() => {
tree.select(treeItem, true, 'center');
}, 100);
setLoaderText(null);
})
.catch(()=>{
setLoaderText(null);
setErrorMsg(gettext('Unable to locate this object in the browser.'));
});
}, []);
const onSearch = ()=> {
// If user press the Enter key and the search characters are
// less than 3 characters then return from the function.
if (search.length < 3)
return;
setLoaderText(gettext('Searching....'));
setErrorMsg('');
let searchType = type;
if(type === 'constraints') {
searchType = ['constraints', 'check_constraint', 'foreign_key', 'primary_key', 'unique_constraint', 'exclusion_constraint'];
}
api.get(url_for('search_objects.search',{
sid: nodeData?.server?._id,
did: nodeData?.database?._id,
}), { params: {
text: search,
type: searchType,
}})
.then(res=>{
setLoaderText(null);
let finalData = [];
// Get the finalise list of data.
res?.data?.data.forEach((element) => {
finalData.push(finaliseData(nodeData, element));
});
setSearchData(finalData);
setFooterText(res?.data?.data?.length + ' matches found');
})
.catch((err)=>{
setLoaderText(null);
pgAdmin.Browser.notifier.error(parseApiError(err));
});
};
const onEnterPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
onSearch();
}
};
const typeOptions = ()=> {
return new Promise((resolve, reject)=>{
try {
api.get(url_for('search_objects.types', {
sid: nodeData?.server?._id,
did: nodeData?.database?._id,
}))
.then(res=>{
let typeOpt = [{label:gettext('All types'), value:'all'}];
let typesRes = Object.entries(res.data.data).sort((a,b)=>a?.localeCompare?.(b));
typesRes.forEach((element) => {
typeOpt.push({label:gettext(element[1]), value:element[0]});
});
resolve(typeOpt);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
reject(new Error(err));
});
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
reject(new Error(error));
}
});
};
return (
setType(v)}/>
}
onClick={onSearch} disabled={search.length < 3}>{gettext('Search')}
{footerText}
setErrorMsg('')} />
} title={gettext('Help for this dialog.')} />
);
}
SearchObjects.propTypes = {
nodeData: PropTypes.object,
};