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 |
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}