Example: Contact Directory

A searchable contact directory. Demonstrates text search with contains, multi_select tag filtering, and email/url field types.

Schema

Entity: Contacts — key: contacts

Field Type Notes
name text required
email email required
phone text
company text
role text
website url
tags multi_select client / vendor / partner / internal
notes textarea

Source Code

1<!DOCTYPE html>2<html lang="en">3<head>4  <meta charset="UTF-8">5  <meta name="viewport" content="width=device-width, initial-scale=1.0">6  <title>Contact Directory</title>7  <style>8    * { box-sizing: border-box; margin: 0; padding: 0; }9    body { font-family: system-ui, sans-serif; max-width: 860px; margin: 0 auto; padding: 20px; color: #1a1a1a; }10    h1 { margin-bottom: 20px; }11    .toolbar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }12    .toolbar input, .toolbar select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }13    .toolbar input { flex: 1; min-width: 180px; }14    .toolbar button { padding: 8px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }15    .toolbar button:hover { background: #1d4ed8; }16    .contact { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }17    .contact-info { flex: 1; min-width: 0; }18    .contact-name { font-weight: 600; font-size: 1rem; }19    .contact-meta { font-size: 13px; color: #6b7280; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 10px; }20    .contact-meta a { color: #2563eb; text-decoration: none; }21    .contact-meta a:hover { text-decoration: underline; }22    .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }23    .tag { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; background: #ede9fe; color: #6d28d9; }24    .contact-actions { display: flex; gap: 4px; flex-shrink: 0; }25    .contact-actions button { padding: 4px 10px; font-size: 12px; border: 1px solid #d1d5db; border-radius: 4px; background: white; cursor: pointer; }26    .contact-actions button.delete { color: #dc2626; border-color: #fecaca; }27    .notes { font-size: 13px; color: #374151; margin-top: 6px; }28    .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; justify-content: center; align-items: flex-start; padding-top: 60px; }29    .modal-overlay.open { display: flex; }30    .modal { background: white; border-radius: 12px; padding: 24px; width: 100%; max-width: 520px; max-height: calc(100vh - 100px); overflow-y: auto; }31    .modal h2 { margin-bottom: 16px; }32    .field { margin-bottom: 12px; }33    .field label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }34    .field input, .field select, .field textarea { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }35    .field textarea { resize: vertical; min-height: 70px; }36    .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }37    .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }38    .checkbox-label { display: flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; }39    .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }40    .actions button { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; }41    .btn-cancel { background: white; border: 1px solid #d1d5db; }42    .btn-submit { background: #2563eb; color: white; border: none; }43    .error { color: #dc2626; font-size: 13px; margin-top: 4px; }44    .empty { text-align: center; padding: 40px; color: #9ca3af; }45    .loading { text-align: center; padding: 40px; color: #6b7280; }46    .count { font-size: 13px; color: #6b7280; margin-left: auto; }47  </style>48</head>49<body>50  <h1>Contacts</h1>5152  <div class="toolbar">53    <input type="search" id="search" placeholder="Search by name…">54    <select id="filter-tag">55      <option value="">All tags</option>56      <option value="client">Client</option>57      <option value="vendor">Vendor</option>58      <option value="partner">Partner</option>59      <option value="internal">Internal</option>60    </select>61    <span class="count" id="count"></span>62    <button id="btn-create" style="display:none;">+ New Contact</button>63  </div>6465  <div id="contact-list"><div class="loading">Loading contacts…</div></div>6667  <div class="modal-overlay" id="modal">68    <div class="modal">69      <h2 id="modal-title">New Contact</h2>70      <form id="contact-form">71        <div class="field-row">72          <div class="field">73            <label for="name">Name *</label>74            <input type="text" id="name" name="name" required>75            <div class="error" id="error-name"></div>76          </div>77          <div class="field">78            <label for="email">Email *</label>79            <input type="email" id="email" name="email" required>80            <div class="error" id="error-email"></div>81          </div>82        </div>83        <div class="field-row">84          <div class="field">85            <label for="phone">Phone</label>86            <input type="tel" id="phone" name="phone">87            <div class="error" id="error-phone"></div>88          </div>89          <div class="field">90            <label for="company">Company</label>91            <input type="text" id="company" name="company">92            <div class="error" id="error-company"></div>93          </div>94        </div>95        <div class="field-row">96          <div class="field">97            <label for="role">Role / Title</label>98            <input type="text" id="role" name="role">99            <div class="error" id="error-role"></div>100          </div>101          <div class="field">102            <label for="website">Website</label>103            <input type="url" id="website" name="website" placeholder="https://…">104            <div class="error" id="error-website"></div>105          </div>106        </div>107        <div class="field">108          <label>Tags</label>109          <div class="checkbox-group">110            <label class="checkbox-label"><input type="checkbox" name="tags" value="client"> Client</label>111            <label class="checkbox-label"><input type="checkbox" name="tags" value="vendor"> Vendor</label>112            <label class="checkbox-label"><input type="checkbox" name="tags" value="partner"> Partner</label>113            <label class="checkbox-label"><input type="checkbox" name="tags" value="internal"> Internal</label>114          </div>115          <div class="error" id="error-tags"></div>116        </div>117        <div class="field">118          <label for="notes">Notes</label>119          <textarea id="notes" name="notes"></textarea>120          <div class="error" id="error-notes"></div>121        </div>122        <div class="error" id="error-general"></div>123        <div class="actions">124          <button type="button" class="btn-cancel" id="btn-cancel">Cancel</button>125          <button type="submit" class="btn-submit">Save</button>126        </div>127      </form>128    </div>129  </div>130131  <script src="https://sdk.workapps.tech/v1.js"></script>132  <script>133    const sdk = new WorkAppsSDK();134    const { ErrorCode } = WorkAppsSDK;135136    let canEdit = false;137    let editingId = null;138    let searchTimer = null;139140    async function init() {141      const bootstrap = await sdk.getBootstrap();142      canEdit = ['editor', 'admin'].includes(bootstrap.role);143      if (canEdit) {144        document.getElementById('btn-create').style.display = 'block';145      }146      loadContacts();147    }148149    async function loadContacts() {150      const listEl = document.getElementById('contact-list');151      const query = document.getElementById('search').value.trim();152      const tag = document.getElementById('filter-tag').value;153154      try {155        const filter = {};156        if (query) filter.name = { contains: query };157        if (tag) filter.tags = { contains: tag };158159        const result = await sdk.listRecords('contacts', {160          filter: Object.keys(filter).length ? filter : undefined,161          sort: 'name:asc',162          pageSize: 50,163        });164165        document.getElementById('count').textContent =166          result.meta.total != null ? `${result.meta.total} contacts` : '';167168        if (result.data.length === 0) {169          listEl.innerHTML = '<div class="empty">No contacts found.</div>';170          return;171        }172173        listEl.innerHTML = result.data.map(c => `174          <div class="contact">175            <div class="contact-info">176              <div class="contact-name">${escapeHtml(c.name)}</div>177              <div class="contact-meta">178                <a href="mailto:${escapeHtml(c.email)}">${escapeHtml(c.email)}</a>179                ${c.phone ? `<span>${escapeHtml(c.phone)}</span>` : ''}180                ${c.company ? `<span>${escapeHtml(c.company)}${c.role ? ` · ${escapeHtml(c.role)}` : ''}</span>` : ''}181                ${c.website ? `<a href="${escapeHtml(c.website)}" target="_blank" rel="noopener">${escapeHtml(c.website)}</a>` : ''}182              </div>183              ${c.tags && c.tags.length ? `<div class="tags">${c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}184              ${c.notes ? `<div class="notes">${escapeHtml(c.notes)}</div>` : ''}185            </div>186            ${canEdit ? `187              <div class="contact-actions">188                <button onclick="openEdit('${c.id}')">Edit</button>189                <button class="delete" onclick="deleteContact('${c.id}')">Delete</button>190              </div>191            ` : ''}192          </div>193        `).join('');194      } catch (error) {195        listEl.innerHTML = `<div class="error">Failed to load contacts: ${escapeHtml(error.message)}</div>`;196      }197    }198199    // Debounced search200    document.getElementById('search').addEventListener('input', () => {201      clearTimeout(searchTimer);202      searchTimer = setTimeout(loadContacts, 300);203    });204205    document.getElementById('filter-tag').addEventListener('change', loadContacts);206207    document.getElementById('btn-create').addEventListener('click', () => {208      editingId = null;209      document.getElementById('modal-title').textContent = 'New Contact';210      document.getElementById('contact-form').reset();211      document.getElementById('modal').classList.add('open');212    });213214    document.getElementById('btn-cancel').addEventListener('click', closeModal);215216    async function openEdit(id) {217      const result = await sdk.getRecord('contacts', id);218      const c = result.data;219      editingId = id;220      document.getElementById('modal-title').textContent = 'Edit Contact';221      document.getElementById('name').value = c.name || '';222      document.getElementById('email').value = c.email || '';223      document.getElementById('phone').value = c.phone || '';224      document.getElementById('company').value = c.company || '';225      document.getElementById('role').value = c.role || '';226      document.getElementById('website').value = c.website || '';227      document.getElementById('notes').value = c.notes || '';228229      // Restore multi_select checkboxes230      const selectedTags = Array.isArray(c.tags) ? c.tags : [];231      document.querySelectorAll('input[name="tags"]').forEach(cb => {232        cb.checked = selectedTags.includes(cb.value);233      });234235      document.getElementById('modal').classList.add('open');236    }237238    document.getElementById('contact-form').addEventListener('submit', async (e) => {239      e.preventDefault();240      clearErrors();241242      const selectedTags = Array.from(document.querySelectorAll('input[name="tags"]:checked'))243        .map(cb => cb.value);244245      const data = {246        name: document.getElementById('name').value,247        email: document.getElementById('email').value,248        phone: document.getElementById('phone').value || null,249        company: document.getElementById('company').value || null,250        role: document.getElementById('role').value || null,251        website: document.getElementById('website').value || null,252        tags: selectedTags.length ? selectedTags : null,253        notes: document.getElementById('notes').value || null,254      };255256      try {257        if (editingId) {258          await sdk.updateRecord('contacts', editingId, data);259        } else {260          await sdk.createRecord('contacts', data);261        }262        closeModal();263        loadContacts();264      } catch (error) {265        if (error.code === ErrorCode.ValidationFailed) {266          const fieldErrors = error.getFieldErrors();267          Object.entries(fieldErrors).forEach(([field, message]) => {268            const el = document.getElementById('error-' + field);269            if (el) el.textContent = message;270          });271        } else {272          document.getElementById('error-general').textContent = error.message;273        }274      }275    });276277    async function deleteContact(id) {278      // In production, use a custom confirmation dialog — never window.confirm279      try {280        await sdk.deleteRecord('contacts', id);281        loadContacts();282      } catch (error) {283        console.error('Delete failed:', error.message);284      }285    }286287    function closeModal() {288      document.getElementById('modal').classList.remove('open');289    }290291    function escapeHtml(str) {292      if (str == null) return '';293      const div = document.createElement('div');294      div.textContent = String(str);295      return div.innerHTML;296    }297298    function clearErrors() {299      document.querySelectorAll('.error').forEach(el => el.textContent = '');300    }301302    init();303  </script>304</body>305</html>

Key Patterns

Text Search with Debounce

1let searchTimer;2document.getElementById('search').addEventListener('input', () => {3  clearTimeout(searchTimer);4  searchTimer = setTimeout(loadContacts, 300);5});

Debouncing prevents a request on every keystroke. 300 ms is a good default.

Filter by Tag

1const result = await sdk.listRecords('contacts', {2  filter: { tags: { contains: 'vendor' } },3  sort: 'name:asc',4});

contains on a multi_select field matches any record where the array includes the given value.

Combined Search + Tag Filter

1const filter = {};2if (query) filter.name = { contains: query };3if (tag) filter.tags = { contains: tag };45const result = await sdk.listRecords('contacts', {6  filter: Object.keys(filter).length ? filter : undefined,7  sort: 'name:asc',8});

Multiple filter conditions use AND logic — both must match.

Reading multi_select Values

1// c.tags is an array of strings, or null/undefined if empty2const selectedTags = Array.isArray(c.tags) ? c.tags : [];

Restoring Checkboxes on Edit

1document.querySelectorAll('input[name="tags"]').forEach(cb => {2  cb.checked = selectedTags.includes(cb.value);3});

Null-Safe HTML Escaping

Always guard escapeHtml against null/undefined values — optional fields may be absent on older records:

1function escapeHtml(str) {2  if (str == null) return '';3  const div = document.createElement('div');4  div.textContent = String(str);5  return div.innerHTML;6}