Selectors and Descriptors
EMPS ships two built-in Vue components for working with references to other
database rows (foreign keys): <selector> — a search-as-you-type picker
used in forms — and <descriptor> — a read-only label that resolves an
id into a human-readable name. Together they save you from writing a custom
autocomplete and a custom "look up the name for this id" query every time a
table references another one.
What <selector> Actually Does
<selector> is fundamentally one thing: a search-as-you-type picker that
calls into modules/pick/ng (covered below) for its results. There isn't a
separate built-in "enum mode" — type always resolves to a pick/ng case.
When type refers to a database table name and :search="true" is set,
<selector> queries that table live as the user types:
<selector type="remed"
v-model="row.remedy_id"
title="Remedy"
:search="true"
:has-extra="false"
placeholder="Remedy">
</selector>
type— the table name (unprefixed, withoutc_/temp_).v-model— bound to the foreign key column (an id,0meaning "none selected").:search="true"— switches the component into live-search-against-a-table mode.title/placeholder— labels shown in the picker UI.:has-extra="false"— disables an optional secondary field some pickers support; leave itfalseunless you know you need it.
<selector> Is Not for Enums
For a status/type field backed by enum.nn.txt, use a plain <select> instead:
<select v-model="row.status">
<option v-for="item in enums['order_status']" :key="item.code" :value="item.code">
{{ item.value }}
</option>
</select>
See Enum Values for loading enums into a component and the
enum_val() display helper. Reach for <selector> only when you're picking
an actual database row by id.
<descriptor> is the read-only counterpart — given just an id, it resolves
and displays the row's label:
<descriptor type="remed" :value="row.remedy_id" :plain="true"></descriptor>
Both components need to be included once per page, inside the vue_out
capture buffer described in Vue.js in EMPS:
{{capture append="vue_out"}}
...
{{include file="db:_comp/selector"}}
{{include file="db:_comp/descriptor"}}
{{/capture}}
How Entity Search Mode Works: modules/pick/ng
The type="table_name" :search="true" combination is backed by a small
project module conventionally located at modules/pick/ng/:
modules/pick/ng/
list/
project.php ← backs <selector> — search + paginated results
describe/
project.php ← backs <descriptor> — resolve a single id to a label
Both files define a local subclass of an EMPS core class:
list/project.phpdefinesEMPS_Local_PickList extends EMPS_NG_PickListdescribe/project.phpdefinesEMPS_Local_PickDescribe extends EMPS_NG_PickDescribe
By default, the parent classes (EMPS_NG_PickList / EMPS_NG_PickDescribe)
search and display a column literally called name. If the table you're
pointing type at has a name column, everything works with zero extra
code — that's the whole point of the convention described on
Table Column Naming Conventions.
When the Default Doesn't Work
If the table's main text column is called something else — title, short
fullname, or anything other thanname— the default query silently returns nothing (or an empty label for<descriptor>). You'll notice this as "I can't find X in the picker" even though the rows clearly exist.
The fix is to add a per-table case to both files, following the same pattern
already used for tables that don't have a name column. Here's a real
example — a books table whose title lives in a title column, added
alongside an existing remed case (which uses short/fullname):
modules/pick/ng/list/project.php — search + row display:
class EMPS_Local_PickList extends EMPS_NG_PickList {
public function handle_row($ra)
{
global $crm, $emps, $bk, $dom;
if ($this->table_name == "remed") {
$ra['display_name'] = $crm->captialize($ra['short']) . " / " . $crm->captialize($ra['fullname']);
} else if ($this->table_name == "books") {
$ra['display_name'] = $ra['title'];
} else {
return parent::handle_row($ra);
}
return $ra;
}
public function handle_request() {
global $emps, $start, $perpage, $bk, $key;
$this->parse_request();
if (!$emps->auth->credentials("admin")) {
return;
}
if ($this->table_name == "books") {
$text = $emps->db->sql_escape($emps->utf8_urldecode($_GET['text']));
$and = $this->make_and($this->filter);
$perpage = $this->perpage;
$start = intval($start);
$r = $emps->db->query("select SQL_CALC_FOUND_ROWS t.* from " .
TP . $this->table_name . " as t
where t.title like '%{$text}%' " . $this->where . " " . $and . "
group by t.id
order by t.title asc
limit $start, $perpage");
$pages = $emps->count_pages($emps->db->found_rows());
$lst = array();
while ($ra = $emps->db->fetch_named($r)) {
$ra = $emps->db->row_types($this->table_name, $ra);
$ra = $this->handle_row($ra);
$lst[] = $ra;
}
} else {
parent::handle_request(); exit;
}
$response = ['code' => 'OK', 'list' => $lst];
if ($pages) { $response['pages'] = $pages; }
echo json_encode($response);
}
}
modules/pick/ng/describe/project.php — single-id lookup for <descriptor>:
class EMPS_Local_PickDescribe extends EMPS_NG_PickDescribe {
public function handle_request()
{
global $emps, $crm, $dom;
$this->parse_request();
if ($this->table_name == "books") {
$row = $emps->db->get_row("books", "id = {$this->id}");
$row = $emps->db->row_types("books", $row);
$row['display_name'] = $row['title'];
} else {
parent::handle_request();
exit;
}
echo json_encode(['code' => 'OK', 'display' => $row['display_name']]);
}
}
Note both files already exist per project (they're where a project's earlier
custom cases, like remed, live) — you're adding an else if branch, not
creating the module from scratch.
See Also
- Table Column Naming Conventions — name your columns so you rarely need the custom-case workaround above.
- Admin Module Pattern — where foreign key fields typically show up in a form.
- Enum Values — use a plain
<select>for these instead of<selector>.