fluentui-extended 2026.8.36 β†’ 2026.8.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,873 +1,1624 @@
1
- # FluentUI-Extended
2
-
3
- Extended components for Fluent UI v9, designed to match Dynamics 365 patterns.
4
-
5
- [![npm version](https://badge.fury.io/js/fluentui-extended.svg)](https://www.npmjs.com/package/fluentui-extended)
6
- [![CI](https://github.com/garethcheyne/npm-fluentui-extended/actions/workflows/ci.yml/badge.svg)](https://github.com/garethcheyne/npm-fluentui-extended/actions)
7
-
8
- ## Why This Library?
9
-
10
- We started with the **Lookup** component because it's one of the most requested components in the Dynamics 365 and Power Platform community. Fluent UI v9 doesn't include a Lookup control out of the box, so we built one that matches the native Dynamics 365 experience.
11
-
12
- **Have a component request?** Open an issue on [GitHub](https://github.com/garethcheyne/npm-fluentui-extended/issues) and we'll consider adding it!
13
-
14
- This project is **open source** and **free to use**. It is provided as-is, without warranty. Community contributions are welcomeβ€”feel free to submit pull requests or suggest improvements!
15
-
16
- ## Installation
17
-
18
- ```bash
19
- npm install fluentui-extended @fluentui/react-components @fluentui/react-icons
20
- ```
21
-
22
- ## Components
23
-
24
- ### Lookup
25
-
26
- A searchable dropdown component styled after Dynamics 365 lookup fields. Supports async search, expandable option details, and customizable header/footer.
27
-
28
- ![Lookup Component](assets/screenshot-lookup.png)
29
-
30
- ### QueryBuilder
31
-
32
- > **🚧 Beta** - This component is in beta. Please report any issues on [GitHub](https://github.com/garethcheyne/npm-fluentui-extended/issues).
33
-
34
- An Advanced Find-style query builder for Dynamics 365. Build complex filter conditions with AND/OR logic, serialize to FetchXML or OData, and validate queries against the Dynamics 365 API.
35
-
36
- ![QueryBuilder Component](assets/screenshot-querybuilder.png)
37
-
38
- ## Quick Start
39
-
40
- ```tsx
41
- import { useState } from 'react';
42
- import { Lookup, LookupOption } from 'fluentui-extended';
43
- import { FluentProvider, webLightTheme } from '@fluentui/react-components';
44
-
45
- const options: LookupOption[] = [
46
- { key: '1', text: 'Contoso Ltd', secondaryText: 'CON001' },
47
- { key: '2', text: 'Fabrikam Inc', secondaryText: 'FAB001' },
48
- { key: '3', text: 'Adventure Works', secondaryText: 'ADV001' },
49
- ];
50
-
51
- function App() {
52
- const [selected, setSelected] = useState<LookupOption | null>(null);
53
-
54
- return (
55
- <FluentProvider theme={webLightTheme}>
56
- <Lookup
57
- options={options}
58
- selectedOption={selected}
59
- onOptionSelect={setSelected}
60
- placeholder="Search accounts..."
61
- />
62
- </FluentProvider>
63
- );
64
- }
65
- ```
66
-
67
- ## Features
68
-
69
- ### Basic Selection (with key)
70
-
71
- ```tsx
72
- const [selectedKey, setSelectedKey] = useState<string | null>(null);
73
-
74
- <Lookup
75
- options={options}
76
- selectedKey={selectedKey}
77
- onOptionSelect={(opt) => setSelectedKey(opt?.key ?? null)}
78
- placeholder="Search..."
79
- />
80
- ```
81
-
82
- ### Async Search (API Integration)
83
-
84
- For async scenarios, use `selectedOption` to persist the display value when options change:
85
-
86
- ```tsx
87
- function AsyncLookup() {
88
- const [options, setOptions] = useState<LookupOption[]>([]);
89
- const [selected, setSelected] = useState<LookupOption | null>(null);
90
- const [loading, setLoading] = useState(false);
91
-
92
- const handleSearch = async (searchText: string) => {
93
- setLoading(true);
94
- const response = await fetch(`/api/accounts?search=${searchText}`);
95
- setOptions(await response.json());
96
- setLoading(false);
97
- };
98
-
99
- return (
100
- <Lookup
101
- options={options}
102
- selectedOption={selected}
103
- onOptionSelect={setSelected}
104
- onSearchChange={handleSearch}
105
- loading={loading}
106
- searchDebounceMs={300}
107
- placeholder="Type to search..."
108
- />
109
- );
110
- }
111
- ```
112
-
113
- ### With Icons and Expandable Details
114
-
115
- Options can include icons and expandable detail rows (click chevron to expand):
116
-
117
- ```tsx
118
- import { BuildingRegular } from '@fluentui/react-icons';
119
-
120
- const options: LookupOption[] = [
121
- {
122
- key: '1',
123
- text: 'Contoso Ltd',
124
- secondaryText: 'CON001',
125
- icon: <BuildingRegular />,
126
- details: [
127
- { label: 'Phone', value: '555-0100' },
128
- { label: 'Industry', value: 'Technology' },
129
- { value: 'Active Customer' },
130
- ],
131
- data: { id: 'acc-001', revenue: 5000000 }, // Custom data accessible in onOptionSelect
132
- },
133
- ];
134
- ```
135
-
136
- ### Dynamics 365 Style (Header & Footer)
137
-
138
- ```tsx
139
- import { Text, Button, Link } from '@fluentui/react-components';
140
- import { AddRegular, PersonSearchRegular } from '@fluentui/react-icons';
141
-
142
- <Lookup
143
- options={options}
144
- selectedOption={selected}
145
- onOptionSelect={setSelected}
146
- header={
147
- <>
148
- <Text size={200}>Accounts</Text>
149
- <Button appearance="outline" size="small">Recent records</Button>
150
- </>
151
- }
152
- footer={
153
- <>
154
- <Link style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
155
- <AddRegular /> New
156
- </Link>
157
- <Link style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
158
- <PersonSearchRegular /> Advanced
159
- </Link>
160
- </>
161
- }
162
- />
163
- ```
164
-
165
- ### Multi-Entity Lookup with Filter Buttons
166
-
167
- Replicate the native Dynamics 365 lookup pattern where users can filter between entity types:
168
-
169
- ```tsx
170
- import { Text, Button, Link, ToggleButton } from '@fluentui/react-components';
171
- import { BuildingRegular, PersonRegular, ArrowLeftRegular } from '@fluentui/react-icons';
172
-
173
- function MultiEntityLookup() {
174
- const [showAccounts, setShowAccounts] = useState(true);
175
- const [showContacts, setShowContacts] = useState(true);
176
-
177
- const options: LookupOption[] = [
178
- { key: 'acc-1', text: 'Contoso Ltd', icon: <BuildingRegular />, details: [...] },
179
- { key: 'con-1', text: 'John Smith', icon: <PersonRegular />, details: [...] },
180
- ];
181
-
182
- // Filter based on key prefix
183
- const filteredOptions = useMemo(() =>
184
- options.filter(opt => {
185
- if (opt.key.startsWith('acc-')) return showAccounts;
186
- if (opt.key.startsWith('con-')) return showContacts;
187
- return true;
188
- }), [showAccounts, showContacts]
189
- );
190
-
191
- return (
192
- <Lookup
193
- options={filteredOptions}
194
- header={
195
- // Drill-down view when single entity selected
196
- showAccounts !== showContacts ? (
197
- <>
198
- <Link onClick={() => { setShowAccounts(true); setShowContacts(true); }}>
199
- <ArrowLeftRegular /> All
200
- </Link>
201
- <Text weight="semibold">{showAccounts ? 'Accounts' : 'Contacts'}</Text>
202
- </>
203
- ) : (
204
- // Filter toggles when showing all
205
- <>
206
- <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
207
- <Text size={200}>Results from:</Text>
208
- <ToggleButton size="small" checked={showAccounts}
209
- onClick={() => { setShowAccounts(true); setShowContacts(false); }}>
210
- Accounts
211
- </ToggleButton>
212
- <ToggleButton size="small" checked={showContacts}
213
- onClick={() => { setShowAccounts(false); setShowContacts(true); }}>
214
- Contacts
215
- </ToggleButton>
216
- </div>
217
- <Button size="small">Recent records</Button>
218
- </>
219
- )
220
- }
221
- />
222
- );
223
- }
224
- ```
225
-
226
- ### Rich Secondary Text with React Elements
227
-
228
- Both `secondaryText` and `details` support React elements, not just strings:
229
-
230
- ```tsx
231
- import { Badge } from '@fluentui/react-components';
232
- import { CheckmarkCircleRegular } from '@fluentui/react-icons';
233
-
234
- const options: LookupOption[] = [
235
- {
236
- key: '1',
237
- text: 'John Smith',
238
- secondaryText: (
239
- <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
240
- <Badge appearance="tint" color="success" size="small" icon={<CheckmarkCircleRegular />}>
241
- Active
242
- </Badge>
243
- <span>john.smith@contoso.com</span>
244
- </span>
245
- ),
246
- icon: <PersonRegular />,
247
- details: [
248
- { label: 'Status', value: <Badge appearance="filled" color="success" size="small">Verified</Badge> },
249
- { label: 'Role', value: <Badge appearance="tint" color="brand" size="small">Decision Maker</Badge> },
250
- { value: <span style={{ color: '#0078d4' }}>View full profile β†’</span> },
251
- ],
252
- },
253
- ];
254
- ```
255
-
256
- ---
257
-
258
- ## Test Harness Examples
259
-
260
- Run the test harness with `npm run harness` to see all examples in action.
261
-
262
- | Example | Description |
263
- |---------|-------------|
264
- | **Basic Lookup** | Simple lookup with expandable details, no header/footer |
265
- | **Header & Footer** | D365-style with "Accounts" header and "New" / "Advanced" footer links |
266
- | **Details Only (No Secondary Text)** | Options with icons + details but no secondary text; demonstrates icon centering on single-line text |
267
- | **Multi-Entity Filter** | Toggle between Accounts/Contacts with drill-down header pattern (← All) |
268
- | **Dynamic Search (Async API)** | Simulated 800ms API delay with loading spinner; auto-loads top 5 on open |
269
- | **Live Dynamics Lookup** | Connects to real D365 environment via Xrm.WebApi (when connected) |
270
- | **QueryBuilder** | Full Advanced Find-style query builder with FetchXML serialization |
271
-
272
- ## API Reference
273
-
274
- ### Lookup Props
275
-
276
- | Prop | Type | Default | Description |
277
- |------|------|---------|-------------|
278
- | `id` | `string` | auto-generated | Unique identifier for the lookup |
279
- | `options` | `LookupOption[]` | `[]` | Options to display in the dropdown |
280
- | `selectedKey` | `string \| null` | - | Selected option key (controlled) |
281
- | `selectedOption` | `LookupOption \| null` | - | Selected option object (recommended for async) |
282
- | `onOptionSelect` | `(option: LookupOption \| null) => void` | - | Selection change callback |
283
- | `onSearchChange` | `(searchText: string) => void` | - | Search text change callback |
284
- | `placeholder` | `string` | `'Search...'` | Input placeholder |
285
- | `loading` | `boolean` | `false` | Show loading spinner |
286
- | `noResultsMessage` | `string` | `'No results found'` | Empty state message |
287
- | `clearable` | `boolean` | `true` | Show clear button |
288
- | `minSearchLength` | `number` | `0` | Min chars before search fires |
289
- | `searchDebounceMs` | `number` | `300` | Search debounce delay (ms) |
290
- | `matchInputWidth` | `boolean` | `true` | Match dropdown width to input width |
291
- | `header` | `ReactNode` | - | Header content |
292
- | `footer` | `ReactNode` | - | Footer content |
293
- | `disabled` | `boolean` | `false` | Disable the lookup |
294
- | `open` | `boolean` | - | Controlled open state for the dropdown |
295
- | `onOpenChange` | `(open: boolean) => void` | - | Callback when dropdown open state changes |
296
- | `disableClientFilter` | `boolean` | `false` | Disable client-side filtering of options. Use this when filtering is performed server-side via `onSearchChange` |
297
- | `searchFields` | `string` | - | Hidden searchable text (never rendered). Use this to include additional searchable content (codes, IDs) while displaying JSX in `secondaryText` |
298
-
299
- ### Client-Side Filtering
300
-
301
- By default, the Lookup component filters options client-side as the user types. The filtering logic works as follows:
302
-
303
- 1. **Primary field (`text`)** β€” Always searched, regardless of other props
304
- 2. **Search fields (`searchFields`)** β€” If provided, this hidden text is searched (useful when `secondaryText` is JSX)
305
- 3. **Secondary text (`secondaryText`)** β€” Only searched if it's a string (JSX elements are skipped)
306
-
307
- This allows you to use rich JSX (badges, icons) in `secondaryText` while still providing searchable text via `searchFields`:
308
-
309
- ```tsx
310
- const options: LookupOption[] = [
311
- {
312
- key: 'PROD-001',
313
- text: 'Acme Widget', // Always searchable
314
- searchFields: 'PROD-001 SKU-12345 acme-widget', // Hidden searchable text
315
- secondaryText: ( // Rich display (not searchable)
316
- <span style={{ display: 'flex', gap: 4 }}>
317
- <Badge size="small">PROD-001</Badge>
318
- <Badge size="small" color="brand">SKU-12345</Badge>
319
- </span>
320
- ),
321
- },
322
- ];
323
- ```
324
-
325
- **Server-Side Filtering:** When using `onSearchChange` to fetch results from an API, set `disableClientFilter={true}` to prevent the client from re-filtering server results:
326
-
327
- ```tsx
328
- <Lookup
329
- options={apiResults}
330
- onSearchChange={(searchText) => fetchFromApi(searchText)}
331
- disableClientFilter={true} // API already filtered the results
332
- loading={isLoading}
333
- />
334
- ```
335
-
336
- ### Cross-Document Support (Dynamics 365 Iframes)
337
-
338
- The Lookup component automatically detects when it's rendered inside a cross-document context (e.g., a React tree mounted into a parent window's document from an iframe). It uses `ownerDocument` and `ownerDocument.defaultView` instead of the global `document` and `window` to ensure:
339
-
340
- - **Dismiss on click outside** works correctly (mousedown listener on the correct document)
341
- - **Scroll/resize tracking** responds to the correct window's events
342
- - **Dropdown positioning** uses the correct scroll offsets
343
-
344
- ### Inherited Input Props
345
-
346
- The Lookup component extends Fluent UI's `Input` and supports these standard props:
347
-
348
- | Prop | Type | Default | Description |
349
- |------|------|---------|-------------|
350
- | `appearance` | `'outline' \| 'underline' \| 'filled-darker' \| 'filled-lighter'` | `'outline'` | Visual style of the input |
351
- | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size of the input |
352
- | `contentBefore` | `ReactNode` | - | Content before the input text |
353
- | `className` | `string` | - | Additional CSS class |
354
- | `style` | `CSSProperties` | - | Inline styles |
355
-
356
- ```tsx
357
- // Examples
358
- <Lookup appearance="filled-darker" size="large" ... />
359
- <Lookup appearance="underline" size="small" ... />
360
- ```
361
-
362
- ### LookupOption
363
-
364
- ```ts
365
- interface LookupOption {
366
- key: string; // Unique identifier (required)
367
- text: string; // Display text (required)
368
- secondaryText?: ReactNode; // Secondary line - string, Badge, or JSX
369
- searchFields?: string; // Hidden searchable text (never rendered)
370
- icon?: ReactNode; // Icon component (e.g., <BuildingRegular />)
371
- details?: LookupOptionDetail[]; // Expandable details (chevron appears)
372
- data?: unknown; // Custom data payload for your app
373
- disabled?: boolean; // Disable this option
374
- }
375
-
376
- interface LookupOptionDetail {
377
- label?: ReactNode; // Optional label (e.g., "Phone:" or a Badge)
378
- value: ReactNode; // Detail value - string or JSX element
379
- }
380
- ```
381
-
382
- > **Note:** Both `secondaryText` and `details` support React elements, not just strings. See [Rich Secondary Text](#rich-secondary-text-with-react-elements) for examples. When using JSX in `secondaryText`, use `searchFields` to provide hidden searchable text (see [Client-Side Filtering](#client-side-filtering)).
383
-
384
- ## Keyboard Navigation
385
-
386
- | Key | Action |
387
- |-----|--------|
388
- | `↓` | Open dropdown / Move to next option |
389
- | `↑` | Move to previous option |
390
- | `Enter` | Select highlighted option |
391
- | `Escape` | Close dropdown |
392
- | `Tab` | Close dropdown and move focus |
393
-
394
- ---
395
-
396
- ## QueryBuilder
397
-
398
- The QueryBuilder component provides an Advanced Find-style interface for building complex queries against Dynamics 365 entities.
399
-
400
- ### Basic Usage (Dynamics 365)
401
-
402
- In Dynamics 365, fields are automatically loaded from entity metadata - no need to pass them manually:
403
-
404
- ```tsx
405
- import { QueryBuilder, QueryBuilderApplyResult } from 'fluentui-extended';
406
- import { FluentProvider, webLightTheme } from '@fluentui/react-components';
407
-
408
- function App() {
409
- const [fetchXml, setFetchXml] = React.useState<string>('');
410
-
411
- const handleChange = (result: QueryBuilderApplyResult) => {
412
- setFetchXml(result.fetchXml);
413
- // Also available: result.odataFilter, result.fetchXmlFilter, result.state
414
- };
415
-
416
- return (
417
- <FluentProvider theme={webLightTheme}>
418
- <QueryBuilder
419
- entityName="account"
420
- entityDisplayName="Accounts"
421
- onSerializedChange={handleChange}
422
- />
423
- </FluentProvider>
424
- );
425
- }
426
- ```
427
-
428
- ### Loading Existing FetchXML
429
-
430
- Pass existing FetchXML to pre-populate the query builder:
431
-
432
- ```tsx
433
- const existingFetchXml = `
434
- <fetch version="1.0">
435
- <entity name="account">
436
- <filter type="and">
437
- <condition attribute="name" operator="like" value="%Contoso%" />
438
- <condition attribute="statecode" operator="eq" value="0" />
439
- </filter>
440
- </entity>
441
- </fetch>
442
- `;
443
-
444
- <QueryBuilder
445
- entityName="account"
446
- initialFetchXml={existingFetchXml}
447
- onSerializedChange={handleChange}
448
- />
449
- ```
450
-
451
- ### Getting Values Back
452
-
453
- Use `onSerializedChange` to get the query whenever it changes:
454
-
455
- ```tsx
456
- const handleChange = (result: QueryBuilderApplyResult) => {
457
- // FetchXML for SDK queries
458
- console.log(result.fetchXml);
459
- // <fetch version="1.0"><entity name="account"><filter type="and">...</filter></entity></fetch>
460
-
461
- // OData for Web API
462
- console.log(result.odataFilter);
463
- // name eq 'Contoso' and revenue gt 1000000
464
-
465
- // Just the filter element
466
- console.log(result.fetchXmlFilter);
467
- // <filter type="and">...</filter>
468
-
469
- // Current state object (for saving/restoring)
470
- console.log(result.state);
471
- };
472
- ```
473
-
474
- ### Features
475
-
476
- #### Import/Edit/Export FetchXML
477
-
478
- Download the current query as FetchXML, import FetchXML from elsewhere, or open the current
479
- query as editable FetchXML:
480
-
481
- ```tsx
482
- <QueryBuilder
483
- entityName="account"
484
- showDownloadFetchXmlButton={true} // Default: true
485
- showUploadFetchXmlButton={true} // Default: true
486
- showEditFetchXmlButton={true} // Default: true
487
- />
488
- ```
489
-
490
- **Import FetchXML** opens an empty dialog for pasting in a query from elsewhere.
491
-
492
- **Edit FetchXML** opens the same dialog prefilled with the current query's FetchXML, so you can
493
- tweak it in place or select-all and paste a different query over it. Applying rebuilds the
494
- builder from whatever is in the box. If the XML doesn't parse, your text is kept and the error
495
- is shown inline.
496
-
497
- #### Live Preview
498
-
499
- Show real-time preview of the generated queries. The previews can also be toggled from the
500
- toolbar, so these props set the *initial* visibility rather than hiding the previews outright:
501
-
502
- ```tsx
503
- <QueryBuilder
504
- entityName="account"
505
- fields={fields}
506
- showODataPreview={true}
507
- showFetchXmlPreview={true}
508
- showPreviewToggleButtons={true} // Default: true
509
- />
510
- ```
511
-
512
- #### Queries That OData Cannot Express
513
-
514
- FetchXML has operators the OData `$filter` syntax has no equivalent for β€” relative dates
515
- (`last-x-days`, `this-month`), fiscal periods, user context (`eq-userid`) and hierarchy
516
- operators (`under`, `above`). These are evaluated by the FetchXML engine itself.
517
-
518
- When a query uses one, it is **omitted from the OData filter** and reported on the result:
519
-
520
- ```tsx
521
- <QueryBuilder
522
- entityName="account"
523
- fields={fields}
524
- onSerializedChange={(result) => {
525
- if (result.odataUnsupported.length > 0) {
526
- // The OData filter is NOT equivalent to the FetchXML - use result.fetchXml instead
527
- console.warn('Not expressible in OData:', result.odataUnsupported);
528
- }
529
- }}
530
- />
531
- ```
532
-
533
- Each entry gives the field and operator that could not be translated:
534
-
535
- ```ts
536
- { fieldId: 'createdon', fieldLabel: 'Created On', operator: 'last-x-days', operatorLabel: 'Last X Days' }
537
- ```
538
-
539
- The OData preview shows the same information as a warning. Use `isOperatorConvertibleToOData`
540
- to check a single operator yourself.
541
-
542
- #### Validation with Dynamics 365 API
543
-
544
- The Validate button checks query structure and optionally tests against the Dynamics 365 API:
545
-
546
- ```tsx
547
- <QueryBuilder
548
- entityName="account"
549
- fields={fields}
550
- showValidateButton={true} // Default: true
551
- />
552
- ```
553
-
554
- When running inside Dynamics 365:
555
- - Uses native fetch to `/api/data/v9.2/` endpoints
556
- - Executes a test query with `$top=1&$count=true`
557
- - Shows record count or API error message
558
-
559
- When running outside Dynamics 365:
560
- - Shows "API validation unavailable β€” not running in Dynamics 365 environment"
561
-
562
- #### Lookup Fields with Async Search
563
-
564
- For lookup-type fields, provide an async search callback:
565
-
566
- ```tsx
567
- const handleLookupSearch = async (fieldId: string, searchText: string) => {
568
- const response = await fetch(`/api/${fieldId}?search=${searchText}`);
569
- const data = await response.json();
570
- return data.map(item => ({
571
- key: item.id,
572
- text: item.name,
573
- secondaryText: item.code,
574
- }));
575
- };
576
-
577
- <QueryBuilder
578
- entityName="account"
579
- fields={fields}
580
- onLookupSearch={handleLookupSearch}
581
- />
582
- ```
583
-
584
- #### Debug Tracing
585
-
586
- Enable debug tracing to see what's happening inside the component:
587
-
588
- ```tsx
589
- <QueryBuilder
590
- entityName="account"
591
- fields={fields}
592
- onTrace={(message, data) => {
593
- console.debug(
594
- '%c FluentUI-Extended ',
595
- 'background: #845EF7; color: white; padding: 2px 4px; border-radius: 2px; font-weight: bold;',
596
- message,
597
- data || ''
598
- );
599
- }}
600
- />
601
- ```
602
-
603
- This is useful for:
604
- - Debugging related entity field loading
605
- - Tracking optionset metadata fetching
606
- - Understanding when API calls are made
607
- - Troubleshooting field resolution issues
608
-
609
- ### Standalone Usage (Outside Dynamics 365)
610
-
611
- When not running in Dynamics 365, provide fields manually:
612
-
613
- ```tsx
614
- const fields: QueryBuilderField[] = [
615
- { id: 'name', label: 'Account Name', dataType: 'string' },
616
- { id: 'revenue', label: 'Annual Revenue', dataType: 'number' },
617
- { id: 'statecode', label: 'Status', dataType: 'optionset', options: [
618
- { label: 'Active', value: 0 },
619
- { label: 'Inactive', value: 1 },
620
- ]},
621
- ];
622
-
623
- <QueryBuilder
624
- entityName="account"
625
- fields={fields}
626
- onSerializedChange={handleChange}
627
- />
628
- ```
629
-
630
- ### QueryBuilder Props
631
-
632
- | Prop | Type | Default | Description |
633
- |------|------|---------|-------------|
634
- | `entityName` | `string` | - | Logical name of the entity (required) |
635
- | `entityDisplayName` | `string` | - | Display name shown in header |
636
- | `fields` | `QueryBuilderField[]` | - | Fields for filtering (auto-loaded via Web API if omitted) |
637
- | `initialFetchXml` | `string` | - | FetchXML to pre-populate the query builder |
638
- | `initialState` | `QueryBuilderState` | - | Initial query state object |
639
- | `onSerializedChange` | `(result: QueryBuilderApplyResult) => void` | - | Called when query changes |
640
- | `onLookupSearch` | `(fieldId: string, searchText: string) => Promise<LookupOption[]>` | - | Lookup field search handler |
641
- | `showODataPreview` | `boolean` | `false` | Initial visibility of the OData filter preview |
642
- | `showFetchXmlPreview` | `boolean` | `false` | Initial visibility of the FetchXML preview |
643
- | `showPreviewToggleButtons` | `boolean` | `true` | Show toolbar buttons that toggle the previews |
644
- | `showResetToDefaultButton` | `boolean` | `true` | Show Reset button |
645
- | `showDownloadFetchXmlButton` | `boolean` | `true` | Show Download FetchXML button |
646
- | `showUploadFetchXmlButton` | `boolean` | `true` | Show Import FetchXML button |
647
- | `showEditFetchXmlButton` | `boolean` | `true` | Show Edit FetchXML button |
648
- | `showValidateButton` | `boolean` | `true` | Show Validate button |
649
- | `showDeleteAllFiltersButton` | `boolean` | `true` | Show Delete All button |
650
- | `onTrace` | `(message: string, data?: any) => void` | - | Debug/trace callback for component behavior |
651
-
652
- ### QueryBuilderField
653
-
654
- ```ts
655
- interface QueryBuilderField {
656
- id: string; // Logical attribute name
657
- label: string; // Display label
658
- dataType: 'string' | 'number' | 'datetime' | 'boolean' | 'optionset' | 'lookup';
659
- options?: Array<{ label: string; value: string | number }>; // Optionset and boolean fields
660
- }
661
- ```
662
-
663
- Options are loaded automatically from entity metadata when `fields` is omitted. Boolean fields
664
- pick up their Dynamics labels (for example "Allowed" / "Not Allowed" rather than Yes / No), with
665
- values `'1'` and `'0'` to match the FetchXML representation.
666
-
667
- ### QueryBuilderApplyResult
668
-
669
- ```ts
670
- interface QueryBuilderApplyResult {
671
- state: QueryBuilderState; // Current query state
672
- fetchXmlFilter: string; // Just the <filter> element
673
- fetchXml: string; // Complete FetchXML document
674
- odataFilter: string; // OData $filter value
675
- odataQuery?: string; // Full OData query URL (requires entitySetName)
676
- odataUnsupported: QueryBuilderODataUnsupported[]; // Conditions OData cannot express
677
- }
678
-
679
- interface QueryBuilderODataUnsupported {
680
- fieldId: string; // e.g. "createdon"
681
- fieldLabel: string; // e.g. "Created On"
682
- operator: string; // e.g. "last-x-days"
683
- operatorLabel: string; // e.g. "Last X Days"
684
- }
685
- ```
686
-
687
- When `odataUnsupported` is non-empty, `odataFilter` is **not** equivalent to `fetchXml` β€” the
688
- untranslatable conditions have been left out. Use `fetchXml` to run the query.
689
-
690
- ### Programmatic API
691
-
692
- #### Serialize State
693
-
694
- ```ts
695
- import { serializeQueryBuilderState } from 'fluentui-extended';
696
-
697
- const result = serializeQueryBuilderState(state, fields, 'account');
698
- console.log(result.fetchXml);
699
- console.log(result.odataFilter);
700
- ```
701
-
702
- #### Parse FetchXML
703
-
704
- ```ts
705
- import { parseFetchXmlToState } from 'fluentui-extended';
706
-
707
- const result = parseFetchXmlToState(fetchXmlString, fields);
708
- if (result.state) {
709
- // Use result.state to populate QueryBuilder
710
- } else {
711
- console.error(result.error);
712
- }
713
- ```
714
-
715
- #### Validate State
716
-
717
- ```ts
718
- import { validateQueryBuilderState } from 'fluentui-extended';
719
-
720
- const result = validateQueryBuilderState(state, fields);
721
- if (!result.isValid) {
722
- result.errors.forEach(err => {
723
- console.log(`${err.fieldLabel}: ${err.message}`);
724
- });
725
- }
726
- ```
727
-
728
- ### Supported Operators
729
-
730
- | Data Type | Operators |
731
- |-----------|-----------|
732
- | `string` | Contains, Does Not Contain, Begins With, Does Not Begin With, Ends With, Does Not End With, Like, Not Like, Equals, Not Equals, Is Empty, Has Value |
733
- | `number` | Greater Than, Greater Than Or Equal, Less Than, Less Than Or Equal, Between, Not Between, Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value |
734
- | `datetime` | All number comparisons, plus On / On Or Before / On Or After, relative dates (Today, This Month, Last X Days, Older Than X Months, ...) and fiscal period operators |
735
- | `optionset` | Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value |
736
- | `lookup` | Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value, plus user-context (Equals Current User, ...) and hierarchy (Under, Above, ...) operators |
737
- | `boolean` | Equals, Not Equals, Is Empty, Has Value |
738
-
739
- Operators map to the [FetchXML condition operators][fetchxml-operators]. Note that FetchXML has
740
- no `contains` operator β€” "Contains" and "Does Not Contain" are serialized as `like` / `not-like`
741
- with `%` wildcards around the value.
742
-
743
- Relative date, fiscal period, user-context and hierarchy operators are FetchXML-only and cannot
744
- be expressed in OData β€” see [Queries That OData Cannot Express](#queries-that-odata-cannot-express).
745
-
746
- [fetchxml-operators]: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/fetchxml/reference/operators
747
-
748
- ---
749
-
750
- ## Acknowledgments
751
-
752
- This library extends [Microsoft's Fluent UI React v9](https://react.fluentui.dev/) components. Thank you to Microsoft and the Fluent UI team for creating and maintaining such an excellent design system.
753
-
754
- - [Fluent UI React](https://react.fluentui.dev/)
755
- - [Fluent UI GitHub](https://github.com/microsoft/fluentui)
756
- - [Fluent 2 Design System](https://fluent2.microsoft.design/)
757
-
758
- ---
759
-
760
- ## Changelog
761
-
762
- > Version format: `YYYY.M.DD` (e.g., `2026.8.30` = August 30, 2026)
763
-
764
- ### 2026.8.36
765
-
766
- QueryBuilder field-type and FetchXML correctness pass.
767
-
768
- - πŸ› **Every field resolved as `string`.** `dataTypeFromAttribute` compared `AttributeTypeName.Value`
769
- (which is suffixed β€” `MoneyType`, `PicklistType`, `BooleanType`) against unsuffixed names, so only
770
- lookups were typed correctly. Money fields offered "Contains", optionsets rendered a text box, and
771
- booleans never reached their branch.
772
- - πŸ› **Optionset and boolean options were never loaded** for main-entity fields. The component's field
773
- loader fetched attributes and lookup targets but no option metadata.
774
- - πŸ› **Global option sets returned no options** β€” only `OptionSet` was expanded, never `GlobalOptionSet`.
775
- - πŸ› **Boolean fields** now use their Dynamics labels ("Allowed" / "Not Allowed") instead of hardcoded
776
- Yes/No, and match on truthiness so a saved `value="1"` no longer displays as "No".
777
- - πŸ› **"Does Not Contain" produced invalid FetchXML** (`operator="not-contain"`, which does not exist).
778
- Now serialized as `not-like` with `%` wildcards.
779
- - πŸ› **Date picker shifted the day** in UTC+ timezones β€” `toISOString()` converted local midnight to
780
- the previous UTC day.
781
- - πŸ› **`IsValidForAdvancedFind` was never requested**, so the filter meant to hide non-filterable
782
- attributes did nothing.
783
- - πŸ› **"Has Value" left the value box enabled**; no-value operators now disable it correctly.
784
- - πŸ› **"Last X Days" rendered a date picker** instead of a number input.
785
- - πŸ› **`not-between` and fiscal period-and-year operators had no second value input.**
786
- - πŸ› **`link-entity` guessed `from="<entity>id"`**, which is wrong for activity entities
787
- (`email`, `task`, `appointment` all use `activityid`). Now uses `PrimaryIdAttribute`.
788
- - πŸ› **Invalid OData output.** Untranslatable operators were emitted as a `/* comment */` in the filter
789
- string; nested related-entity conditions were silently coerced to `eq`. Both are now omitted.
790
- - ✨ **Edit FetchXML** toolbar button β€” opens the current query as editable FetchXML to tweak or paste
791
- over (`showEditFetchXmlButton`).
792
- - ✨ **Show/Hide OData and FetchXML** toolbar toggles (`showPreviewToggleButtons`).
793
- - ✨ `QueryBuilderApplyResult.odataUnsupported` reports conditions OData cannot express, surfaced as a
794
- warning in the OData preview. New `isOperatorConvertibleToOData` helper.
795
- - ✨ Option metadata is fetched once per attribute type rather than once per field.
796
- - πŸ’„ Softer, more rounded containers matching other Dynamics surfaces.
797
- - ⚠️ **Breaking:** `odataUnsupported` is a required field on `QueryBuilderApplyResult`. Consumers only
798
- reading the result are unaffected; anyone constructing the type will need to add it.
799
-
800
- ### 2026.8.30
801
-
802
- - ✨ Added multi-entity filter pattern with drill-down header ("← All" back button) in test harness
803
- - ✨ Added "Details Only (No Secondary Text)" example to test harness
804
- - ✨ Support for React elements in `secondaryText` and `details[].value` (Badges, icons, styled text)
805
- - ♻️ Improved `aria-selected`/`aria-disabled` attribute handling
806
- - πŸ“ Documentation overhaul with complete API reference and examples
807
-
808
- ### 2026.6.12
809
-
810
- - ✨ Added React 19 support to peerDependencies
811
- - πŸ“ Updated README with cross-document support documentation
812
-
813
- ### 2026.2.19
814
-
815
- - πŸ› Fixed cross-document dismiss in Dynamics 365 iframes using `ownerDocument`
816
-
817
- ### 2026.2.17
818
-
819
- - πŸ› Removed `requestAnimationFrame` β€” handler registers immediately
820
- - πŸ› Switched to capture phase for dismiss handler to prevent D365 DOM interference
821
-
822
- ### 2026.2.15
823
-
824
- - ✨ Added controlled `open` and `onOpenChange` props for programmatic dropdown control
825
- - πŸ› Removed `requestAnimationFrame` from dismiss handler
826
-
827
- ### 2026.2.13
828
-
829
- - ♻️ Rebuilt popup from scratch using custom element (Fluent UI Popover had unexpected behavior)
830
-
831
- ### 2026.2.11
832
-
833
- - πŸ› Added `onOpenChange` callback to sync internal state with Popover dismiss events (outside click, Escape, focus loss)
834
-
835
- ### 2026.2.10
836
-
837
- - πŸ› Fixed Lookup not allowing space character in search input
838
-
839
- ### 2026.2.8
840
-
841
- - ✨ **QueryBuilder**: Native API integration for field metadata
842
- - ✨ **QueryBuilder**: Lookup field support with related entity validation
843
-
844
- ### 2026.2.7
845
-
846
- - ♻️ Removed `Xrm` global dependency β€” now uses native `/api/data/v9.2/` fetch calls
847
-
848
- ### 2026.2.6
849
-
850
- - ♻️ **QueryBuilder**: Refactored to reuse shared components and styles
851
-
852
- ### 2026.2.5
853
-
854
- - ✨ **QueryBuilder**: Initial release (beta) β€” Advanced Find-style query builder
855
- - ♻️ **Lookup**: Changed from options-only to popup-based rendering
856
-
857
- ### 2026.2.3
858
-
859
- - ✨ Added `id` prop β€” auto-generated if not provided
860
-
861
- ### 2026.2.2
862
-
863
- - πŸ› Fixed classic JSX transform for React 16 compatibility
864
- - ✨ Added React 16.8+ support
865
-
866
- ### 2026.2.1
867
-
868
- - πŸŽ‰ Initial release
869
- - ✨ Lookup component with async search, expandable details, header/footer
870
-
871
- ## License
872
-
873
- MIT
1
+ # FluentUI-Extended
2
+
3
+ Extended components for Fluent UI v9, designed to match Dynamics 365 patterns.
4
+
5
+ [![npm version](https://badge.fury.io/js/fluentui-extended.svg)](https://www.npmjs.com/package/fluentui-extended)
6
+ [![CI](https://github.com/garethcheyne/npm-fluentui-extended/actions/workflows/ci.yml/badge.svg)](https://github.com/garethcheyne/npm-fluentui-extended/actions)
7
+
8
+ ## Why This Library?
9
+
10
+ We started with the **Lookup** component because it's one of the most requested components in the Dynamics 365 and Power Platform community. Fluent UI v9 doesn't include a Lookup control out of the box, so we built one that matches the native Dynamics 365 experience.
11
+
12
+ **Have a component request?** Open an issue on [GitHub](https://github.com/garethcheyne/npm-fluentui-extended/issues) and we'll consider adding it!
13
+
14
+ This project is **open source** and **free to use**. It is provided as-is, without warranty. Community contributions are welcomeβ€”feel free to submit pull requests or suggest improvements!
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install fluentui-extended \
20
+ @fluentui/react-components \
21
+ @fluentui/react-icons \
22
+ @fluentui/react-datepicker-compat \
23
+ @fluentui/react-calendar-compat
24
+ ```
25
+
26
+ ## Appearance
27
+
28
+ Every field-like component in this library takes an `appearance` prop and **defaults it to
29
+ `filled-darker`**, which is how Dynamics 365 renders fields natively. Fluent's own default is
30
+ `outline`, which reads as foreign on a model-driven form β€” so the default is deliberately different
31
+ from upstream Fluent.
32
+
33
+ ```tsx
34
+ <Lookup options={options} /> // filled-darker
35
+ <Lookup options={options} appearance="outline" /> // opt back out
36
+ ```
37
+
38
+ Applies to `Lookup`, `QueryBuilder` (every field inside it), `DateTimeField` and `OptionSetField`.
39
+ `CommandBar`, `EntityGrid` and `RecordHoverCard` are not field controls and take no `appearance`.
40
+
41
+ | Value | Notes |
42
+ |-------|-------|
43
+ | `outline` | Fluent's default |
44
+ | `underline` | Not supported by `Textarea`; falls back to `outline` there |
45
+ | `filled-darker` | **This library's default** β€” native Dynamics 365 |
46
+ | `filled-lighter` | |
47
+ | `filled-darker-shadow` | Deprecated upstream; narrowed to `filled-darker` on dropdowns |
48
+ | `filled-lighter-shadow` | Deprecated upstream; narrowed to `filled-lighter` on dropdowns |
49
+
50
+ The two shadow variants are deprecated in Fluent and will be removed there. They are accepted so
51
+ existing callers keep working, but `Combobox` and `Dropdown` never supported them, so a component
52
+ containing those narrows to the closest non-shadow fill rather than dropping the value.
53
+
54
+ ## Components
55
+
56
+ ### Lookup
57
+
58
+ A searchable dropdown component styled after Dynamics 365 lookup fields. Supports async search, expandable option details, and customizable header/footer.
59
+
60
+ ![Lookup resolved](assets/screenshot-lookup-rest.png)
61
+
62
+ ![Lookup open](assets/screenshot-lookup-open.png)
63
+
64
+ ### QueryBuilder
65
+
66
+ An Advanced Find-style query builder for Dynamics 365. Build complex filter conditions with AND/OR logic, serialize to FetchXML or OData, and validate queries against the Dynamics 365 API.
67
+
68
+ ![QueryBuilder](assets/screenshot-querybuilder.png)
69
+
70
+ ### CommandBar
71
+
72
+ A Dynamics-style command bar. Commands that no longer fit collapse into a "More commands" menu
73
+ rather than wrapping to a second row or being clipped.
74
+
75
+ ![CommandBar](assets/screenshot-commandbar.png)
76
+
77
+ ### EntityGrid
78
+
79
+ > **🚧 Beta** β€” this is the only component in the library still marked as not yet stable.
80
+
81
+ A subgrid backed by the Web API: columns named from entity metadata, server-side paging and
82
+ sorting, and lookups rendered as names rather than GUIDs.
83
+
84
+ ![EntityGrid](assets/screenshot-entitygrid.png)
85
+
86
+ ### DateTimeField
87
+
88
+ A date/time field that respects the attribute's Dynamics `DateTimeBehavior`, so `DateOnly` values
89
+ cannot drift a day across timezones.
90
+
91
+ ![DateTimeField](assets/screenshot-datetimefield.png)
92
+
93
+ ### OptionSetField
94
+
95
+ An optionset / multi-select picklist field that loads its options from metadata, including global
96
+ option sets, and round-trips multi-selects in the comma-separated form Dynamics stores.
97
+
98
+ ![OptionSetField](assets/screenshot-optionset-closed.png)
99
+
100
+ ![OptionSetField open](assets/screenshot-optionset-open.png)
101
+
102
+ ### RecordHoverCard
103
+
104
+ A hover card for a record reference. The record is fetched lazily once the pointer settles, and the
105
+ result is held so re-opening costs nothing.
106
+
107
+ ![RecordHoverCard](assets/screenshot-hovercard.png)
108
+
109
+ ### SystemUserPersona
110
+
111
+ A Dynamics systemuser persona with the contact card a persona shows on a model-driven form.
112
+
113
+ ![SystemUserPersona](assets/screenshot-persona.png)
114
+
115
+ ![SystemUserPersona contact card](assets/screenshot-persona-card.png)
116
+
117
+ ### OwnerLookup
118
+
119
+ A preconfigured `Lookup` for `ownerid`. An owner is a user *or* a team, so both are searched;
120
+ selections render as the usual Lookup badges and multi-select comes for free.
121
+
122
+ ![OwnerLookup resolved](assets/screenshot-ownerlookup-rest.png)
123
+
124
+ ![OwnerLookup users and teams](assets/screenshot-ownerlookup-open.png)
125
+
126
+ ## Quick Start
127
+
128
+ ```tsx
129
+ import { useState } from 'react';
130
+ import { Lookup, LookupOption } from 'fluentui-extended';
131
+ import { FluentProvider, webLightTheme } from '@fluentui/react-components';
132
+
133
+ const options: LookupOption[] = [
134
+ { key: '1', text: 'Contoso Ltd', secondaryText: 'CON001' },
135
+ { key: '2', text: 'Fabrikam Inc', secondaryText: 'FAB001' },
136
+ { key: '3', text: 'Adventure Works', secondaryText: 'ADV001' },
137
+ ];
138
+
139
+ function App() {
140
+ const [selected, setSelected] = useState<LookupOption | null>(null);
141
+
142
+ return (
143
+ <FluentProvider theme={webLightTheme}>
144
+ <Lookup
145
+ options={options}
146
+ selectedOption={selected}
147
+ onOptionSelect={setSelected}
148
+ placeholder="Search accounts..."
149
+ />
150
+ </FluentProvider>
151
+ );
152
+ }
153
+ ```
154
+
155
+ ## Features
156
+
157
+ ### Basic Selection (with key)
158
+
159
+ ```tsx
160
+ const [selectedKey, setSelectedKey] = useState<string | null>(null);
161
+
162
+ <Lookup
163
+ options={options}
164
+ selectedKey={selectedKey}
165
+ onOptionSelect={(opt) => setSelectedKey(opt?.key ?? null)}
166
+ placeholder="Search..."
167
+ />
168
+ ```
169
+
170
+ ### Async Search (API Integration)
171
+
172
+ For async scenarios, use `selectedOption` to persist the display value when options change:
173
+
174
+ ```tsx
175
+ function AsyncLookup() {
176
+ const [options, setOptions] = useState<LookupOption[]>([]);
177
+ const [selected, setSelected] = useState<LookupOption | null>(null);
178
+ const [loading, setLoading] = useState(false);
179
+
180
+ const handleSearch = async (searchText: string) => {
181
+ setLoading(true);
182
+ const response = await fetch(`/api/accounts?search=${searchText}`);
183
+ setOptions(await response.json());
184
+ setLoading(false);
185
+ };
186
+
187
+ return (
188
+ <Lookup
189
+ options={options}
190
+ selectedOption={selected}
191
+ onOptionSelect={setSelected}
192
+ onSearchChange={handleSearch}
193
+ loading={loading}
194
+ searchDebounceMs={300}
195
+ placeholder="Type to search..."
196
+ />
197
+ );
198
+ }
199
+ ```
200
+
201
+ ### With Icons and Expandable Details
202
+
203
+ Options can include icons and expandable detail rows (click chevron to expand):
204
+
205
+ ```tsx
206
+ import { BuildingRegular } from '@fluentui/react-icons';
207
+
208
+ const options: LookupOption[] = [
209
+ {
210
+ key: '1',
211
+ text: 'Contoso Ltd',
212
+ secondaryText: 'CON001',
213
+ icon: <BuildingRegular />,
214
+ details: [
215
+ { label: 'Phone', value: '555-0100' },
216
+ { label: 'Industry', value: 'Technology' },
217
+ { value: 'Active Customer' },
218
+ ],
219
+ data: { id: 'acc-001', revenue: 5000000 }, // Custom data accessible in onOptionSelect
220
+ },
221
+ ];
222
+ ```
223
+
224
+ ### Dynamics 365 Style (Header & Footer)
225
+
226
+ ```tsx
227
+ import { Text, Button, Link } from '@fluentui/react-components';
228
+ import { AddRegular, PersonSearchRegular } from '@fluentui/react-icons';
229
+
230
+ <Lookup
231
+ options={options}
232
+ selectedOption={selected}
233
+ onOptionSelect={setSelected}
234
+ header={
235
+ <>
236
+ <Text size={200}>Accounts</Text>
237
+ <Button appearance="outline" size="small">Recent records</Button>
238
+ </>
239
+ }
240
+ footer={
241
+ <>
242
+ <Link style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
243
+ <AddRegular /> New
244
+ </Link>
245
+ <Link style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
246
+ <PersonSearchRegular /> Advanced
247
+ </Link>
248
+ </>
249
+ }
250
+ />
251
+ ```
252
+
253
+ ### Multi-Entity Lookup with Filter Buttons
254
+
255
+ Replicate the native Dynamics 365 lookup pattern where users can filter between entity types:
256
+
257
+ ```tsx
258
+ import { Text, Button, Link, ToggleButton } from '@fluentui/react-components';
259
+ import { BuildingRegular, PersonRegular, ArrowLeftRegular } from '@fluentui/react-icons';
260
+
261
+ function MultiEntityLookup() {
262
+ const [showAccounts, setShowAccounts] = useState(true);
263
+ const [showContacts, setShowContacts] = useState(true);
264
+
265
+ const options: LookupOption[] = [
266
+ { key: 'acc-1', text: 'Contoso Ltd', icon: <BuildingRegular />, details: [...] },
267
+ { key: 'con-1', text: 'John Smith', icon: <PersonRegular />, details: [...] },
268
+ ];
269
+
270
+ // Filter based on key prefix
271
+ const filteredOptions = useMemo(() =>
272
+ options.filter(opt => {
273
+ if (opt.key.startsWith('acc-')) return showAccounts;
274
+ if (opt.key.startsWith('con-')) return showContacts;
275
+ return true;
276
+ }), [showAccounts, showContacts]
277
+ );
278
+
279
+ return (
280
+ <Lookup
281
+ options={filteredOptions}
282
+ header={
283
+ // Drill-down view when single entity selected
284
+ showAccounts !== showContacts ? (
285
+ <>
286
+ <Link onClick={() => { setShowAccounts(true); setShowContacts(true); }}>
287
+ <ArrowLeftRegular /> All
288
+ </Link>
289
+ <Text weight="semibold">{showAccounts ? 'Accounts' : 'Contacts'}</Text>
290
+ </>
291
+ ) : (
292
+ // Filter toggles when showing all
293
+ <>
294
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
295
+ <Text size={200}>Results from:</Text>
296
+ <ToggleButton size="small" checked={showAccounts}
297
+ onClick={() => { setShowAccounts(true); setShowContacts(false); }}>
298
+ Accounts
299
+ </ToggleButton>
300
+ <ToggleButton size="small" checked={showContacts}
301
+ onClick={() => { setShowAccounts(false); setShowContacts(true); }}>
302
+ Contacts
303
+ </ToggleButton>
304
+ </div>
305
+ <Button size="small">Recent records</Button>
306
+ </>
307
+ )
308
+ }
309
+ />
310
+ );
311
+ }
312
+ ```
313
+
314
+ ### Rich Secondary Text with React Elements
315
+
316
+ Both `secondaryText` and `details` support React elements, not just strings:
317
+
318
+ ```tsx
319
+ import { Badge } from '@fluentui/react-components';
320
+ import { CheckmarkCircleRegular } from '@fluentui/react-icons';
321
+
322
+ const options: LookupOption[] = [
323
+ {
324
+ key: '1',
325
+ text: 'John Smith',
326
+ secondaryText: (
327
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
328
+ <Badge appearance="tint" color="success" size="small" icon={<CheckmarkCircleRegular />}>
329
+ Active
330
+ </Badge>
331
+ <span>john.smith@contoso.com</span>
332
+ </span>
333
+ ),
334
+ icon: <PersonRegular />,
335
+ details: [
336
+ { label: 'Status', value: <Badge appearance="filled" color="success" size="small">Verified</Badge> },
337
+ { label: 'Role', value: <Badge appearance="tint" color="brand" size="small">Decision Maker</Badge> },
338
+ { value: <span style={{ color: '#0078d4' }}>View full profile β†’</span> },
339
+ ],
340
+ },
341
+ ];
342
+ ```
343
+
344
+ ---
345
+
346
+ ## Test Harness Examples
347
+
348
+ Run the test harness with `npm run harness` to see all examples in action. The harness is split into
349
+ one tab per component, so each can be viewed and screenshotted on its own.
350
+
351
+ | Example | Tab | Description |
352
+ |---------|-----|-------------|
353
+ | **Basic Lookup** | Lookup | Simple lookup with expandable details, no header/footer |
354
+ | **Header & Footer** | Lookup | D365-style with "Accounts" header and "New" / "Advanced" footer links |
355
+ | **Details Only (No Secondary Text)** | Lookup | Options with icons + details but no secondary text; demonstrates icon centering on single-line text |
356
+ | **Multi-Entity Filter** | Lookup | Toggle between Accounts/Contacts with drill-down header pattern (← All) |
357
+ | **Dynamic Search (Async API)** | Lookup | Simulated 800ms API delay with loading spinner; auto-loads top 5 on open |
358
+ | **Live Dynamics Lookup** | Lookup | Connects to real D365 environment via Xrm.WebApi (when connected) |
359
+ | **QueryBuilder** | Query Builder | Full Advanced Find-style query builder with FetchXML serialization |
360
+ | **Unknown / Invalid Fields** | Query Builder | FetchXML referencing attributes that match no known field, each flagged inline |
361
+ | **Command Bar** | Command Bar | Nine commands collapsing into an overflow menu; narrow the window to watch them move |
362
+ | **Pinned / no overflow** | Command Bar | A pinned command that never collapses, and horizontal scrolling with overflow disabled |
363
+ | **Entity Grid** | Entity Grid | Server-paged accounts with sorting, selection and formatted lookup values (needs a connection) |
364
+ | **DateTimeBehavior** | Fields | One picked date serialized three ways, showing which behaviours pass through UTC |
365
+ | **OptionSetField** | Fields | Single-select with metadata colours, and a multi-select round-tripping as "1,2" |
366
+ | **Record Hover Card** | Hover Card | Static and live record cards with lazy loading on hover intent |
367
+
368
+ ## API Reference
369
+
370
+ ### Resolved Lookup (rest state)
371
+
372
+ Once a record is selected and the dropdown is closed, the field renders the way a resolved lookup
373
+ does on a Dynamics form: the table's icon (or its entity image), the record name as a link, a clear
374
+ button, and a magnifier rather than a chevron.
375
+
376
+ ```tsx
377
+ <Lookup
378
+ options={options}
379
+ selectedOption={selected}
380
+ onOptionSelect={setSelected}
381
+ entityIcon={<BuildingRegular />} // falls back to the option's own icon
382
+ entityImage={account.entityimage_url} // wins over the icon when the table has one
383
+ onRecordClick={(option) => openRecord(option.key)} // clicking the name opens the record
384
+ />
385
+ ```
386
+
387
+ If the selected option carries Dynamics record metadata (`entityName` plus `recordId` or `key`), the
388
+ Lookup now behaves like a native Dynamics field by default:
389
+
390
+ - Clicking the resolved value opens the record
391
+ - `Xrm.Navigation.openForm(...)` is used when available
392
+ - Outside a form, it falls back to a Dynamics `main.aspx` entity-record URL
393
+
394
+ Pass `onRecordClick` to override that default navigation. Set `recordLinkAppearance={false}` to render
395
+ the value as plain input text instead.
396
+
397
+ ### Lookup Setup Modes
398
+
399
+ The same component supports three distinct setups:
400
+
401
+ #### 1. Plain custom values
402
+
403
+ Use this when the lookup is just a searchable picker and does not represent a Dynamics table row.
404
+
405
+ ```tsx
406
+ const options: LookupOption[] = [
407
+ { key: 'draft', text: 'Draft' },
408
+ { key: 'submitted', text: 'Submitted' },
409
+ { key: 'approved', text: 'Approved' },
410
+ ];
411
+
412
+ <Lookup
413
+ options={options}
414
+ selectedOption={selected}
415
+ onOptionSelect={setSelected}
416
+ />
417
+ ```
418
+
419
+ Only `key` and `text` are required. In this mode there is no record navigation and no Web API-backed
420
+ hover card unless you provide your own custom card body.
421
+
422
+ #### 2. Dynamics-backed records
423
+
424
+ Use this when each option represents a real Dataverse / Dynamics record.
425
+
426
+ ```tsx
427
+ const options: LookupOption[] = [
428
+ {
429
+ key: '00000000-0000-0000-0000-000000000001',
430
+ text: 'Contoso Ltd',
431
+ entityName: 'account',
432
+ recordId: '00000000-0000-0000-0000-000000000001',
433
+ },
434
+ ];
435
+
436
+ <Lookup
437
+ options={options}
438
+ selectedOption={selected}
439
+ onOptionSelect={setSelected}
440
+ />
441
+ ```
442
+
443
+ When `entityName` and `recordId` are present, the selected value can behave like a native Dynamics
444
+ lookup link and the built-in record hover card can fetch the record lazily.
445
+
446
+ #### 3. Dynamics-backed records with hover cards
447
+
448
+ Turn on `showHoverCard` when you want the lookup to reveal record details on hover.
449
+
450
+ ```tsx
451
+ <Lookup
452
+ options={options}
453
+ selectedOption={selected}
454
+ onOptionSelect={setSelected}
455
+ showHoverCard
456
+ hoverCardColumns={['accountnumber', 'telephone1', 'primarycontactid']}
457
+ hoverCardTarget="both"
458
+ />
459
+ ```
460
+
461
+ Two hover-card modes are supported:
462
+
463
+ - Built-in record card: provide `entityName` and `recordId` on each option, plus `hoverCardColumns`
464
+ - Custom card body: provide `renderHoverCard={(option) => ...}`
465
+
466
+ If a rest-state hover card is active, the component does not also show the compacted-value tooltip on
467
+ that same surface.
468
+
469
+ ### Lookup Props
470
+
471
+ | Prop | Type | Default | Description |
472
+ |------|------|---------|-------------|
473
+ | `id` | `string` | auto-generated | Unique identifier for the lookup |
474
+ | `appearance` | `FieldAppearance` | `'filled-darker'` | See [Appearance](#appearance) |
475
+ | `entityIcon` | `React.ReactNode` | option's `icon` | Table icon shown at rest |
476
+ | `entityImage` | `string` | - | Entity image URL, shown in place of the icon |
477
+ | `recordLinkAppearance` | `boolean` | `true` | Render the resolved value as a link |
478
+ | `onRecordClick` | `(option: LookupOption) => void` | - | Overrides the default Dynamics record navigation when the resolved value is clicked |
479
+ | `options` | `LookupOption[]` | `[]` | Options to display in the dropdown |
480
+ | `selectedKey` | `string \| null` | - | Selected option key (controlled) |
481
+ | `selectedOption` | `LookupOption \| null` | - | Selected option object (recommended for async) |
482
+ | `onOptionSelect` | `(option: LookupOption \| null) => void` | - | Selection change callback |
483
+ | `onSearchChange` | `(searchText: string) => void` | - | Search text change callback |
484
+ | `placeholder` | `string` | `'Search...'` | Input placeholder |
485
+ | `loading` | `boolean` | `false` | Show loading spinner |
486
+ | `noResultsMessage` | `string` | `'No results found'` | Empty state message |
487
+ | `clearable` | `boolean` | `true` | Show clear button |
488
+ | `minSearchLength` | `number` | `0` | Min chars before search fires |
489
+ | `searchDebounceMs` | `number` | `300` | Search debounce delay (ms) |
490
+ | `matchInputWidth` | `boolean` | `true` | Match dropdown width to input width |
491
+ | `header` | `ReactNode` | - | Header content |
492
+ | `footer` | `ReactNode` | - | Footer content |
493
+ | `disabled` | `boolean` | `false` | Disable the lookup |
494
+ | `open` | `boolean` | - | Controlled open state for the dropdown |
495
+ | `onOpenChange` | `(open: boolean) => void` | - | Callback when dropdown open state changes |
496
+ | `disableClientFilter` | `boolean` | `false` | Disable client-side filtering of options. Use this when filtering is performed server-side via `onSearchChange` |
497
+ | `showHoverCard` | `boolean` | `false` | Reveal a hover card on lookup rows and/or the resolved value |
498
+ | `hoverCardColumns` | `string[]` | - | Columns fetched and listed by the built-in record card |
499
+ | `renderHoverCard` | `(option: LookupOption) => ReactNode` | - | Build the hover card body yourself |
500
+ | `hoverCardTarget` | `'list' \| 'rest' \| 'both'` | `'both'` | Which lookup surfaces offer the hover card |
501
+ | `hoverCardDelayMs` | `number` | `400` | Hover-intent delay before opening the card |
502
+ | `hoverCardActions` | `ReactNode` | - | Footer actions rendered on the card |
503
+ | `searchFields` | `string` | - | Hidden searchable text (never rendered). Use this to include additional searchable content (codes, IDs) while displaying JSX in `secondaryText` |
504
+
505
+ ### Client-Side Filtering
506
+
507
+ By default, the Lookup component filters options client-side as the user types. The filtering logic works as follows:
508
+
509
+ 1. **Primary field (`text`)** β€” Always searched, regardless of other props
510
+ 2. **Search fields (`searchFields`)** β€” If provided, this hidden text is searched (useful when `secondaryText` is JSX)
511
+ 3. **Secondary text (`secondaryText`)** β€” Only searched if it's a string (JSX elements are skipped)
512
+
513
+ This allows you to use rich JSX (badges, icons) in `secondaryText` while still providing searchable text via `searchFields`:
514
+
515
+ ```tsx
516
+ const options: LookupOption[] = [
517
+ {
518
+ key: 'PROD-001',
519
+ text: 'Acme Widget', // Always searchable
520
+ searchFields: 'PROD-001 SKU-12345 acme-widget', // Hidden searchable text
521
+ secondaryText: ( // Rich display (not searchable)
522
+ <span style={{ display: 'flex', gap: 4 }}>
523
+ <Badge size="small">PROD-001</Badge>
524
+ <Badge size="small" color="brand">SKU-12345</Badge>
525
+ </span>
526
+ ),
527
+ },
528
+ ];
529
+ ```
530
+
531
+ **Server-Side Filtering:** When using `onSearchChange` to fetch results from an API, set `disableClientFilter={true}` to prevent the client from re-filtering server results:
532
+
533
+ ```tsx
534
+ <Lookup
535
+ options={apiResults}
536
+ onSearchChange={(searchText) => fetchFromApi(searchText)}
537
+ disableClientFilter={true} // API already filtered the results
538
+ loading={isLoading}
539
+ />
540
+ ```
541
+
542
+ ### Cross-Document Support (Dynamics 365 Iframes)
543
+
544
+ The Lookup component automatically detects when it's rendered inside a cross-document context (e.g., a React tree mounted into a parent window's document from an iframe). It uses `ownerDocument` and `ownerDocument.defaultView` instead of the global `document` and `window` to ensure:
545
+
546
+ - **Dismiss on click outside** works correctly (mousedown listener on the correct document)
547
+ - **Scroll/resize tracking** responds to the correct window's events
548
+ - **Dropdown positioning** uses the correct scroll offsets
549
+
550
+ ### Inherited Input Props
551
+
552
+ The Lookup component extends Fluent UI's `Input` and supports these standard props:
553
+
554
+ | Prop | Type | Default | Description |
555
+ |------|------|---------|-------------|
556
+ | `appearance` | `'outline' \| 'underline' \| 'filled-darker' \| 'filled-lighter'` | `'outline'` | Visual style of the input |
557
+ | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size of the input |
558
+ | `contentBefore` | `ReactNode` | - | Content before the input text |
559
+ | `className` | `string` | - | Additional CSS class |
560
+ | `style` | `CSSProperties` | - | Inline styles |
561
+
562
+ ```tsx
563
+ // Examples
564
+ <Lookup appearance="filled-darker" size="large" ... />
565
+ <Lookup appearance="underline" size="small" ... />
566
+ ```
567
+
568
+ ### LookupOption
569
+
570
+ ```ts
571
+ interface LookupOption {
572
+ key: string; // Unique identifier (required)
573
+ text: string; // Display text (required)
574
+ secondaryText?: ReactNode; // Secondary line - string, Badge, or JSX
575
+ searchFields?: string; // Hidden searchable text (never rendered)
576
+ icon?: ReactNode; // Icon component (e.g., <BuildingRegular />)
577
+ details?: LookupOptionDetail[]; // Expandable details (chevron appears)
578
+ data?: unknown; // Custom data payload for your app
579
+ disabled?: boolean; // Disable this option
580
+ entityName?: string; // Dynamics table logical name, e.g. 'account'
581
+ recordId?: string; // Dynamics record GUID; falls back to key when omitted
582
+ }
583
+
584
+ interface LookupOptionDetail {
585
+ label?: ReactNode; // Optional label (e.g., "Phone:" or a Badge)
586
+ value: ReactNode; // Detail value - string or JSX element
587
+ }
588
+ ```
589
+
590
+ > **Note:** Both `secondaryText` and `details` support React elements, not just strings. See [Rich Secondary Text](#rich-secondary-text-with-react-elements) for examples. When using JSX in `secondaryText`, use `searchFields` to provide hidden searchable text (see [Client-Side Filtering](#client-side-filtering)).
591
+
592
+ `entityName` plus `recordId` are what switch a generic option into a Dynamics-backed record option.
593
+ That metadata is used by the default selected-value navigation path and by the built-in hover-card
594
+ fetch path.
595
+
596
+ ## Keyboard Navigation
597
+
598
+ | Key | Action |
599
+ |-----|--------|
600
+ | `↓` | Open dropdown / Move to next option |
601
+ | `↑` | Move to previous option |
602
+ | `Enter` | Select highlighted option |
603
+ | `Escape` | Close dropdown |
604
+ | `Tab` | Close dropdown and move focus |
605
+
606
+ ---
607
+
608
+ ## QueryBuilder
609
+
610
+ The QueryBuilder component provides an Advanced Find-style interface for building complex queries against Dynamics 365 entities.
611
+
612
+ ### Basic Usage (Dynamics 365)
613
+
614
+ In Dynamics 365, fields are automatically loaded from entity metadata - no need to pass them manually:
615
+
616
+ ```tsx
617
+ import { QueryBuilder, QueryBuilderApplyResult } from 'fluentui-extended';
618
+ import { FluentProvider, webLightTheme } from '@fluentui/react-components';
619
+
620
+ function App() {
621
+ const [fetchXml, setFetchXml] = React.useState<string>('');
622
+
623
+ const handleChange = (result: QueryBuilderApplyResult) => {
624
+ setFetchXml(result.fetchXml);
625
+ // Also available: result.odataFilter, result.fetchXmlFilter, result.state
626
+ };
627
+
628
+ return (
629
+ <FluentProvider theme={webLightTheme}>
630
+ <QueryBuilder
631
+ entityName="account"
632
+ entityDisplayName="Accounts"
633
+ onSerializedChange={handleChange}
634
+ />
635
+ </FluentProvider>
636
+ );
637
+ }
638
+ ```
639
+
640
+ ### Loading Existing FetchXML
641
+
642
+ Pass existing FetchXML to pre-populate the query builder:
643
+
644
+ ```tsx
645
+ const existingFetchXml = `
646
+ <fetch version="1.0">
647
+ <entity name="account">
648
+ <filter type="and">
649
+ <condition attribute="name" operator="like" value="%Contoso%" />
650
+ <condition attribute="statecode" operator="eq" value="0" />
651
+ </filter>
652
+ </entity>
653
+ </fetch>
654
+ `;
655
+
656
+ <QueryBuilder
657
+ entityName="account"
658
+ initialFetchXml={existingFetchXml}
659
+ onSerializedChange={handleChange}
660
+ />
661
+ ```
662
+
663
+ ### Getting Values Back
664
+
665
+ Use `onSerializedChange` to get the query whenever it changes:
666
+
667
+ ```tsx
668
+ const handleChange = (result: QueryBuilderApplyResult) => {
669
+ // FetchXML for SDK queries
670
+ console.log(result.fetchXml);
671
+ // <fetch version="1.0"><entity name="account"><filter type="and">...</filter></entity></fetch>
672
+
673
+ // OData for Web API
674
+ console.log(result.odataFilter);
675
+ // name eq 'Contoso' and revenue gt 1000000
676
+
677
+ // Just the filter element
678
+ console.log(result.fetchXmlFilter);
679
+ // <filter type="and">...</filter>
680
+
681
+ // Current state object (for saving/restoring)
682
+ console.log(result.state);
683
+ };
684
+ ```
685
+
686
+ ### Features
687
+
688
+ #### Import/Edit/Export FetchXML
689
+
690
+ Download the current query as FetchXML, import FetchXML from elsewhere, or open the current
691
+ query as editable FetchXML:
692
+
693
+ ```tsx
694
+ <QueryBuilder
695
+ entityName="account"
696
+ showDownloadFetchXmlButton={true} // Default: true
697
+ showUploadFetchXmlButton={true} // Default: true
698
+ showEditFetchXmlButton={true} // Default: true
699
+ />
700
+ ```
701
+
702
+ **Import FetchXML** opens an empty dialog for pasting in a query from elsewhere.
703
+
704
+ **Edit FetchXML** opens the same dialog prefilled with the current query's FetchXML, so you can
705
+ tweak it in place or select-all and paste a different query over it. Applying rebuilds the
706
+ builder from whatever is in the box. If the XML doesn't parse, your text is kept and the error
707
+ is shown inline.
708
+
709
+ #### Live Preview
710
+
711
+ Show real-time preview of the generated queries. The previews can also be toggled from the
712
+ toolbar, so these props set the *initial* visibility rather than hiding the previews outright:
713
+
714
+ ```tsx
715
+ <QueryBuilder
716
+ entityName="account"
717
+ fields={fields}
718
+ showODataPreview={true}
719
+ showFetchXmlPreview={true}
720
+ showPreviewToggleButtons={true} // Default: true
721
+ />
722
+ ```
723
+
724
+ #### Queries That OData Cannot Express
725
+
726
+ FetchXML has operators the OData `$filter` syntax has no equivalent for β€” relative dates
727
+ (`last-x-days`, `this-month`), fiscal periods, user context (`eq-userid`) and hierarchy
728
+ operators (`under`, `above`). These are evaluated by the FetchXML engine itself.
729
+
730
+ When a query uses one, it is **omitted from the OData filter** and reported on the result:
731
+
732
+ ```tsx
733
+ <QueryBuilder
734
+ entityName="account"
735
+ fields={fields}
736
+ onSerializedChange={(result) => {
737
+ if (result.odataUnsupported.length > 0) {
738
+ // The OData filter is NOT equivalent to the FetchXML - use result.fetchXml instead
739
+ console.warn('Not expressible in OData:', result.odataUnsupported);
740
+ }
741
+ }}
742
+ />
743
+ ```
744
+
745
+ Each entry gives the field and operator that could not be translated:
746
+
747
+ ```ts
748
+ { fieldId: 'createdon', fieldLabel: 'Created On', operator: 'last-x-days', operatorLabel: 'Last X Days' }
749
+ ```
750
+
751
+ The OData preview shows the same information as a warning. Use `isOperatorConvertibleToOData`
752
+ to check a single operator yourself.
753
+
754
+ #### Validation with Dynamics 365 API
755
+
756
+ The Validate button checks query structure and optionally tests against the Dynamics 365 API:
757
+
758
+ ```tsx
759
+ <QueryBuilder
760
+ entityName="account"
761
+ fields={fields}
762
+ showValidateButton={true} // Default: true
763
+ />
764
+ ```
765
+
766
+ When running inside Dynamics 365:
767
+ - Uses native fetch to `/api/data/v9.2/` endpoints
768
+ - Executes a test query with `$top=1&$count=true`
769
+ - Shows record count or API error message
770
+
771
+ When running outside Dynamics 365:
772
+ - Shows "API validation unavailable β€” not running in Dynamics 365 environment"
773
+
774
+ #### Lookup Fields with Async Search
775
+
776
+ For lookup-type fields, provide an async search callback:
777
+
778
+ ```tsx
779
+ const handleLookupSearch = async (fieldId: string, searchText: string) => {
780
+ const response = await fetch(`/api/${fieldId}?search=${searchText}`);
781
+ const data = await response.json();
782
+ return data.map(item => ({
783
+ key: item.id,
784
+ text: item.name,
785
+ secondaryText: item.code,
786
+ }));
787
+ };
788
+
789
+ <QueryBuilder
790
+ entityName="account"
791
+ fields={fields}
792
+ onLookupSearch={handleLookupSearch}
793
+ />
794
+ ```
795
+
796
+ #### Debug Tracing
797
+
798
+ Enable debug tracing to see what's happening inside the component:
799
+
800
+ ```tsx
801
+ <QueryBuilder
802
+ entityName="account"
803
+ fields={fields}
804
+ onTrace={(message, data) => {
805
+ console.debug(
806
+ '%c FluentUI-Extended ',
807
+ 'background: #845EF7; color: white; padding: 2px 4px; border-radius: 2px; font-weight: bold;',
808
+ message,
809
+ data || ''
810
+ );
811
+ }}
812
+ />
813
+ ```
814
+
815
+ This is useful for:
816
+ - Debugging related entity field loading
817
+ - Tracking optionset metadata fetching
818
+ - Understanding when API calls are made
819
+ - Troubleshooting field resolution issues
820
+
821
+ ### Standalone Usage (Outside Dynamics 365)
822
+
823
+ When not running in Dynamics 365, provide fields manually:
824
+
825
+ ```tsx
826
+ const fields: QueryBuilderField[] = [
827
+ { id: 'name', label: 'Account Name', dataType: 'string' },
828
+ { id: 'revenue', label: 'Annual Revenue', dataType: 'number' },
829
+ { id: 'statecode', label: 'Status', dataType: 'optionset', options: [
830
+ { label: 'Active', value: 0 },
831
+ { label: 'Inactive', value: 1 },
832
+ ]},
833
+ ];
834
+
835
+ <QueryBuilder
836
+ entityName="account"
837
+ fields={fields}
838
+ onSerializedChange={handleChange}
839
+ />
840
+ ```
841
+
842
+ ### Layout and Scrolling
843
+
844
+ The header and toolbar stay pinned while the filter groups and previews scroll together as one
845
+ region. That scroll only engages when the parent constrains the height β€” give the wrapper a fixed
846
+ `height` (or `maxHeight`) and the component fills it:
847
+
848
+ ```tsx
849
+ <div style={{ height: 500, display: 'flex', flexDirection: 'column' }}>
850
+ <QueryBuilder entityName="account" entityDisplayName="Accounts" />
851
+ </div>
852
+ ```
853
+
854
+ In an unconstrained parent the component simply grows to fit its content and the page scrolls instead.
855
+
856
+ ### Query Options
857
+
858
+ The root `<fetch>` element carries the same attributes the Dynamics advanced-find editor emits:
859
+
860
+ ```xml
861
+ <fetch version="1.0" mapping="logical" no-lock="false" distinct="true">
862
+ ```
863
+
864
+ `distinct` defaults to `true`, which matters once related-entity filters are in play β€” a single
865
+ record can otherwise match several linked rows and appear more than once. Override per instance:
866
+
867
+ ```tsx
868
+ <QueryBuilder entityName="account" distinct={false} noLock top={50} />
869
+ ```
870
+
871
+ These props take precedence over whatever an imported query carried. When no prop is set, options
872
+ parsed from `initialFetchXml` are preserved rather than dropped on the next serialize.
873
+
874
+ ### QueryBuilder Props
875
+
876
+ | Prop | Type | Default | Description |
877
+ |------|------|---------|-------------|
878
+ | `entityName` | `string` | - | Logical name of the entity (required) |
879
+ | `entityDisplayName` | `string` | - | Display name shown in header |
880
+ | `fields` | `QueryBuilderField[]` | - | Fields for filtering (auto-loaded via Web API if omitted) |
881
+ | `initialFetchXml` | `string` | - | FetchXML to pre-populate the query builder |
882
+ | `initialState` | `QueryBuilderState` | - | Initial query state object |
883
+ | `distinct` | `boolean` | `true` | Emit `distinct="…"` on the root `<fetch>` |
884
+ | `noLock` | `boolean` | `false` | Emit `no-lock="…"` on the root `<fetch>` |
885
+ | `top` | `number` | - | Emit `top="N"` to cap the row count; omitted when unset |
886
+ | `onSerializedChange` | `(result: QueryBuilderApplyResult) => void` | - | Called when query changes |
887
+ | `onLookupSearch` | `(fieldId: string, searchText: string) => Promise<LookupOption[]>` | - | Lookup field search handler |
888
+ | `showODataPreview` | `boolean` | `false` | Initial visibility of the OData filter preview |
889
+ | `showFetchXmlPreview` | `boolean` | `false` | Initial visibility of the FetchXML preview |
890
+ | `showPreviewToggleButtons` | `boolean` | `true` | Show toolbar buttons that toggle the previews |
891
+ | `showResetToDefaultButton` | `boolean` | `true` | Show Reset button |
892
+ | `showDownloadFetchXmlButton` | `boolean` | `true` | Show Download FetchXML button |
893
+ | `showUploadFetchXmlButton` | `boolean` | `true` | Show Import FetchXML button |
894
+ | `showEditFetchXmlButton` | `boolean` | `true` | Show Edit FetchXML button |
895
+ | `showValidateButton` | `boolean` | `true` | Show Validate button |
896
+ | `showDeleteAllFiltersButton` | `boolean` | `true` | Show Delete All button |
897
+ | `onTrace` | `(message: string, data?: any) => void` | - | Debug/trace callback for component behavior |
898
+
899
+ ### QueryBuilderField
900
+
901
+ ```ts
902
+ interface QueryBuilderField {
903
+ id: string; // Logical attribute name
904
+ label: string; // Display label
905
+ dataType: 'string' | 'number' | 'datetime' | 'boolean' | 'optionset' | 'lookup';
906
+ options?: Array<{ label: string; value: string | number }>; // Optionset and boolean fields
907
+ }
908
+ ```
909
+
910
+ Options are loaded automatically from entity metadata when `fields` is omitted. Boolean fields
911
+ pick up their Dynamics labels (for example "Allowed" / "Not Allowed" rather than Yes / No), with
912
+ values `'1'` and `'0'` to match the FetchXML representation.
913
+
914
+ ### QueryBuilderApplyResult
915
+
916
+ ```ts
917
+ interface QueryBuilderApplyResult {
918
+ state: QueryBuilderState; // Current query state
919
+ fetchXmlFilter: string; // Just the <filter> element
920
+ fetchXml: string; // Complete FetchXML document
921
+ odataFilter: string; // OData $filter value
922
+ odataQuery?: string; // Full OData query URL (requires entitySetName)
923
+ odataUnsupported: QueryBuilderODataUnsupported[]; // Conditions OData cannot express
924
+ }
925
+
926
+ interface QueryBuilderODataUnsupported {
927
+ fieldId: string; // e.g. "createdon"
928
+ fieldLabel: string; // e.g. "Created On"
929
+ operator: string; // e.g. "last-x-days"
930
+ operatorLabel: string; // e.g. "Last X Days"
931
+ }
932
+ ```
933
+
934
+ When `odataUnsupported` is non-empty, `odataFilter` is **not** equivalent to `fetchXml` β€” the
935
+ untranslatable conditions have been left out. Use `fetchXml` to run the query.
936
+
937
+ ### Programmatic API
938
+
939
+ #### Serialize State
940
+
941
+ ```ts
942
+ import { serializeQueryBuilderState } from 'fluentui-extended';
943
+
944
+ const result = serializeQueryBuilderState(state, fields, 'account');
945
+ console.log(result.fetchXml);
946
+ console.log(result.odataFilter);
947
+ ```
948
+
949
+ #### Parse FetchXML
950
+
951
+ ```ts
952
+ import { parseFetchXmlToState } from 'fluentui-extended';
953
+
954
+ const result = parseFetchXmlToState(fetchXmlString, fields);
955
+ if (result.state) {
956
+ // Use result.state to populate QueryBuilder
957
+ } else {
958
+ console.error(result.error);
959
+ }
960
+ ```
961
+
962
+ #### Validate State
963
+
964
+ ```ts
965
+ import { validateQueryBuilderState } from 'fluentui-extended';
966
+
967
+ const result = validateQueryBuilderState(state, fields);
968
+ if (!result.isValid) {
969
+ result.errors.forEach(err => {
970
+ console.log(`${err.fieldLabel}: ${err.message}`);
971
+ });
972
+ }
973
+ ```
974
+
975
+ ### Supported Operators
976
+
977
+ | Data Type | Operators |
978
+ |-----------|-----------|
979
+ | `string` | Contains, Does Not Contain, Begins With, Does Not Begin With, Ends With, Does Not End With, Like, Not Like, Equals, Not Equals, Is Empty, Has Value |
980
+ | `number` | Greater Than, Greater Than Or Equal, Less Than, Less Than Or Equal, Between, Not Between, Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value |
981
+ | `datetime` | All number comparisons, plus On / On Or Before / On Or After, relative dates (Today, This Month, Last X Days, Older Than X Months, ...) and fiscal period operators |
982
+ | `optionset` | Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value |
983
+ | `lookup` | Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value, plus user-context (Equals Current User, ...) and hierarchy (Under, Above, ...) operators |
984
+ | `boolean` | Equals, Not Equals, Is Empty, Has Value |
985
+
986
+ Operators map to the [FetchXML condition operators][fetchxml-operators]. Note that FetchXML has
987
+ no `contains` operator β€” "Contains" and "Does Not Contain" are serialized as `like` / `not-like`
988
+ with `%` wildcards around the value.
989
+
990
+ Relative date, fiscal period, user-context and hierarchy operators are FetchXML-only and cannot
991
+ be expressed in OData β€” see [Queries That OData Cannot Express](#queries-that-odata-cannot-express).
992
+
993
+ [fetchxml-operators]: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/fetchxml/reference/operators
994
+
995
+ ---
996
+
997
+ ## CommandBar
998
+
999
+ Fluent ships `Toolbar` and `Overflow` as separate primitives. `CommandBar` composes them into the
1000
+ behaviour a command bar needs: commands that no longer fit move into a "More commands" menu instead
1001
+ of wrapping or being clipped. Widths are measured from the live DOM, so label length and icons are
1002
+ accounted for rather than estimated.
1003
+
1004
+ ```tsx
1005
+ import { CommandBar } from 'fluentui-extended';
1006
+ import { AddRegular, EditRegular, DeleteRegular } from '@fluentui/react-icons';
1007
+
1008
+ <CommandBar
1009
+ items={[
1010
+ { key: 'new', text: 'New', icon: <AddRegular />, appearance: 'primary', onClick: handleNew },
1011
+ { key: 'edit', text: 'Edit', icon: <EditRegular />, onClick: handleEdit },
1012
+ { key: 'delete', text: 'Delete', icon: <DeleteRegular />, dividerBefore: true, onClick: handleDelete },
1013
+ {
1014
+ key: 'export',
1015
+ text: 'Export',
1016
+ subItems: [{ key: 'excel', text: 'Export to Excel', onClick: handleExport }],
1017
+ },
1018
+ ]}
1019
+ />
1020
+ ```
1021
+
1022
+ ### CommandBar Props
1023
+
1024
+ | Prop | Type | Default | Description |
1025
+ |------|------|---------|-------------|
1026
+ | `items` | `CommandBarItem[]` | - | Commands rendered from the left (required) |
1027
+ | `farItems` | `CommandBarItem[]` | - | Right-aligned commands; never collapse |
1028
+ | `size` | `'small' \| 'medium' \| 'large'` | `'small'` | Button size |
1029
+ | `disableOverflow` | `boolean` | `false` | Scroll horizontally instead of collapsing |
1030
+ | `overflowAriaLabel` | `string` | `'More commands'` | Label for the overflow trigger |
1031
+
1032
+ `CommandBarItem` carries `key`, `text`, `icon`, `onClick`, `disabled`, `appearance`, `checked` (renders
1033
+ a toggle), `subItems` (renders a menu button, preserved as a submenu when overflowed), `dividerBefore`,
1034
+ and `pinned`. A pinned command never collapses β€” use it sparingly, because one that does not fit is
1035
+ clipped rather than moved.
1036
+
1037
+ ---
1038
+
1039
+ ## EntityGrid
1040
+
1041
+ > **🚧 Beta.** EntityGrid is the only component still marked as not yet stable.
1042
+
1043
+ A subgrid backed by the Web API. `DataGrid` renders rows you already have; `EntityGrid` fetches them.
1044
+
1045
+ Paging uses `Prefer: odata.maxpagesize` and follows `@odata.nextLink`, rather than `$top`/`$skip` β€”
1046
+ Dynamics does not support `$skip` for arbitrary offsets, and `$top` suppresses the paging cookie
1047
+ entirely. Because `nextLink` only moves forward, the URL of each visited page is kept so Previous can
1048
+ replay it.
1049
+
1050
+ ```tsx
1051
+ import { EntityGrid } from 'fluentui-extended';
1052
+
1053
+ <EntityGrid
1054
+ entityName="account"
1055
+ title="Accounts"
1056
+ height={420}
1057
+ pageSize={25}
1058
+ selectable
1059
+ columns={[
1060
+ { name: 'name', width: 260 },
1061
+ { name: 'accountnumber' },
1062
+ { name: 'primarycontactid', label: 'Primary Contact' },
1063
+ ]}
1064
+ onRecordOpen={(id) => Xrm.Navigation.openForm({ entityName: 'account', entityId: id })}
1065
+ />
1066
+ ```
1067
+
1068
+ Cells prefer the `@OData.Community.Display.V1.FormattedValue` annotation Dynamics attaches, which is
1069
+ what renders a lookup as a name and an optionset as its label rather than a GUID or an integer. The
1070
+ grid requests those annotations for you.
1071
+
1072
+ ### EntityGrid Props
1073
+
1074
+ | Prop | Type | Default | Description |
1075
+ |------|------|---------|-------------|
1076
+ | `entityName` | `string` | - | Entity logical name (required) |
1077
+ | `columns` | `EntityGridColumn[]` | primary name attribute | Columns to render |
1078
+ | `filter` | `string` | - | OData filter applied to every page |
1079
+ | `defaultSort` | `EntityGridSort` | primary name ascending | Initial sort |
1080
+ | `pageSize` | `number` | `25` | Rows per page |
1081
+ | `selectable` | `boolean` | `false` | Show selection checkboxes |
1082
+ | `onRecordOpen` | `(id, record) => void` | - | Row activation handler |
1083
+ | `onSelectionChange` | `(ids: string[]) => void` | - | Selection handler |
1084
+ | `height` | `number \| string` | - | Fixed height for the scrolling body |
1085
+
1086
+ `EntityGridColumn` carries `name`, `label` (defaults to the metadata display name), `width`,
1087
+ `sortable`, and `render(formatted, record)` for custom cells.
1088
+
1089
+ Pair it with QueryBuilder by passing that component's `odataFilter` output as `filter`.
1090
+
1091
+ ---
1092
+
1093
+ ## DateTimeField
1094
+
1095
+ Dynamics has three `DateTimeBehavior` values and they do not agree on what a stored string means, so
1096
+ a single `new Date(value)` is wrong for two of the three:
1097
+
1098
+ | Behavior | Stored as | Conversion |
1099
+ |----------|-----------|------------|
1100
+ | `UserLocal` | UTC | Converted to the user's timezone |
1101
+ | `DateOnly` | Calendar date, no time or zone | None β€” must never shift |
1102
+ | `TimeZoneIndependent` | Wall-clock, no zone | None β€” shown exactly as entered |
1103
+
1104
+ The trap is that `new Date('2026-08-06')` parses as UTC midnight, which renders as the 5th anywhere
1105
+ west of Greenwich, while `toISOString()` on a local date shifts the day for any user east of it.
1106
+ `DateTimeField` handles both explicitly.
1107
+
1108
+ ```tsx
1109
+ import { useState } from 'react';
1110
+ import { DateTimeField, DateTimeRangeField } from 'fluentui-extended';
1111
+
1112
+ function Example() {
1113
+ const [value, setValue] = useState<string | null>(null);
1114
+ const [range, setRange] = useState({
1115
+ startValue: null as string | null,
1116
+ endValue: null as string | null,
1117
+ });
1118
+
1119
+ return (
1120
+ <>
1121
+ <DateTimeField
1122
+ label="Estimated Close Date"
1123
+ behavior="DateOnly"
1124
+ value={value}
1125
+ onChange={(stored) => setValue(stored)} // "2026-08-06", never an ISO timestamp
1126
+ />
1127
+
1128
+ <DateTimeRangeField
1129
+ label="Booking window"
1130
+ showTime
1131
+ value={{ start: range.startValue, end: range.endValue }}
1132
+ onChange={({ startValue, endValue, startDate, endDate }) => {
1133
+ setRange({ startValue, endValue });
1134
+ console.log(startDate, endDate);
1135
+ }}
1136
+ />
1137
+ </>
1138
+ );
1139
+ }
1140
+ ```
1141
+
1142
+ Pass `entityName` and `attributeName` to read the behavior from metadata instead of declaring it.
1143
+ The conversion helpers are exported for use outside the component:
1144
+
1145
+ ```ts
1146
+ import { parseStoredValue, formatStoredValue } from 'fluentui-extended';
1147
+
1148
+ const date = parseStoredValue('2026-08-06', 'DateOnly'); // local midnight on the 6th
1149
+ const stored = formatStoredValue(date, 'DateOnly'); // "2026-08-06"
1150
+ ```
1151
+
1152
+ `DateTimeRangeField` is a composed helper for "between" inputs. It renders a start and end
1153
+ `DateTimeField` side by side and emits both the serialized values (`startValue`, `endValue`) and the
1154
+ resolved `Date` objects (`startDate`, `endDate`) in one callback.
1155
+
1156
+ ### DateTimeField Props
1157
+
1158
+ | Prop | Type | Default | Description |
1159
+ |------|------|---------|-------------|
1160
+ | `value` | `string \| Date \| null` | - | Stored value, interpreted per `behavior` |
1161
+ | `onChange` | `(value: string \| null, date: Date \| null) => void` | - | Serialized value plus the Date |
1162
+ | `behavior` | `DateTimeBehavior` | `'UserLocal'` | How the attribute is stored |
1163
+ | `showTime` | `boolean` | `false` | Show a time picker; ignored for `DateOnly` |
1164
+ | `timeIntervalMinutes` | `number` | `30` | Spacing of the time dropdown entries |
1165
+ | `entityName` / `attributeName` | `string` | - | Read `behavior` from metadata |
1166
+ | `clearable` | `boolean` | `true` | Show a clear button |
1167
+
1168
+ ---
1169
+
1170
+ ## OptionSetField
1171
+
1172
+ ```tsx
1173
+ import { OptionSetField } from 'fluentui-extended';
1174
+
1175
+ // Options loaded from metadata
1176
+ <OptionSetField entityName="account" attributeName="industrycode" value={value} onChange={setValue} />
1177
+
1178
+ // Multi-select picklist
1179
+ <OptionSetField
1180
+ options={options}
1181
+ multiselect
1182
+ value={values} // accepts [1, 2] or the stored "1,2"
1183
+ onChange={(next) => setValues(next as number[])}
1184
+ />
1185
+ ```
1186
+
1187
+ Two Dynamics details this handles that a plain `Dropdown` does not. A **global option set** leaves
1188
+ `OptionSet` empty and puts its values on `GlobalOptionSet` instead β€” reading only the former is why a
1189
+ dropdown that should be populated comes back empty. And a **multi-select picklist** stores its value
1190
+ as a comma-separated string, so `"1,2"` and `[1, 2]` have to mean the same thing; `parseSelectedValues`
1191
+ and `formatMultiSelectValue` are exported for that conversion.
1192
+
1193
+ When an option set has many values, the popup list stays constrained and scrolls inside the listbox
1194
+ instead of growing indefinitely. The control also supports typing to filter options by label, which
1195
+ is especially useful for long status-reason, industry, or category lists.
1196
+
1197
+ ### OptionSetField Props
1198
+
1199
+ | Prop | Type | Default | Description |
1200
+ |------|------|---------|-------------|
1201
+ | `options` | `OptionSetOption[]` | - | Options; omit to auto-load from metadata |
1202
+ | `entityName` / `attributeName` | `string` | - | Required for metadata auto-load |
1203
+ | `multiselect` | `boolean` | `false` | Multi-select picklist behaviour |
1204
+ | `value` | `number \| number[] \| string \| null` | - | Accepts every stored form |
1205
+ | `onChange` | `(value: number \| number[] \| null) => void` | - | Selection handler |
1206
+ | `showColors` | `boolean` | `false` | Render metadata colours as swatches |
1207
+ | `clearable` | `boolean` | `true` | Allow clearing the selection |
1208
+
1209
+ ### OptionSetField Behaviour
1210
+
1211
+ - Type in the field to filter the available options by label
1212
+ - Long option lists are capped and scroll inside the popup
1213
+ - `multiselect` keeps the Dynamics-style multi-value semantics while still allowing filter-by-typing
1214
+ - `value` still accepts Dynamics' stored comma-separated string form for multi-select picklists
1215
+
1216
+ ---
1217
+
1218
+ ## RecordHoverCard
1219
+
1220
+ ```tsx
1221
+ import { RecordHoverCard } from 'fluentui-extended';
1222
+
1223
+ <RecordHoverCard
1224
+ entityName="account"
1225
+ recordId={record.accountid}
1226
+ columns={['accountnumber', 'telephone1', 'primarycontactid']}
1227
+ actions={<Link onClick={open}>Open record</Link>}
1228
+ >
1229
+ <Link>{record.name}</Link>
1230
+ </RecordHoverCard>
1231
+ ```
1232
+
1233
+ The record is fetched only after the pointer has settled on the anchor for `hoverDelayMs` (400 by
1234
+ default) β€” without that delay, dragging a pointer across a grid column fires a request per row. The
1235
+ result is held for the life of the anchor, so re-opening the same card costs nothing, while a failure
1236
+ is not cached so the next hover retries.
1237
+
1238
+ Pass `record` directly to skip loading entirely when the calling grid already has the data.
1239
+
1240
+ ### RecordHoverCard Props
1241
+
1242
+ | Prop | Type | Default | Description |
1243
+ |------|------|---------|-------------|
1244
+ | `children` | `React.ReactElement` | - | Anchor element (required) |
1245
+ | `entityName` / `recordId` | `string` | - | Required to load via the Web API |
1246
+ | `columns` | `string[]` | primary name only | Columns to request and show |
1247
+ | `record` | `RecordHoverCardRecord` | - | Supply the record and skip loading |
1248
+ | `mapRecord` | `(raw) => RecordHoverCardRecord` | - | Map a raw record onto the card |
1249
+ | `hoverDelayMs` | `number` | `400` | Delay before a hover triggers a fetch |
1250
+ | `actions` | `React.ReactNode` | - | Footer commands |
1251
+
1252
+ ---
1253
+
1254
+ ## SystemUserPersona
1255
+
1256
+ A Dynamics `systemuser` persona: avatar, name, job title, and the contact card a persona shows on a
1257
+ model-driven form. The record loads lazily β€” only once the pointer settles on the persona β€” so a grid
1258
+ column of them costs one request per card actually looked at, not one per row.
1259
+
1260
+ ```tsx
1261
+ import { SystemUserPersona } from 'fluentui-extended';
1262
+
1263
+ <SystemUserPersona
1264
+ userId={record._ownerid_value}
1265
+ presence="available" // Teams presence: supply it, Dynamics does not expose it
1266
+ cardActions={<Link onClick={open}>Open record</Link>}
1267
+ />
1268
+ ```
1269
+
1270
+ Pass `user` instead of `userId` to skip loading when the caller already has the record. The photo is
1271
+ addressed at `systemusers(id)/entityimage/$value` rather than selected as a column β€” `entityimage` is
1272
+ binary, and selecting it inline bloats every search response. Pass `imageUrl={null}` to force initials.
1273
+
1274
+ ### SystemUserPersona Props
1275
+
1276
+ | Prop | Type | Default | Description |
1277
+ |------|------|---------|-------------|
1278
+ | `userId` | `string` | - | systemuser GUID to load |
1279
+ | `user` | `SystemUserRecord` | - | Supply the record and skip loading |
1280
+ | `presence` | `PresenceBadgeStatus` | - | Teams presence badge |
1281
+ | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Avatar and text scale |
1282
+ | `avatarOnly` | `boolean` | `false` | Hide the name; it moves to a tooltip |
1283
+ | `showHoverCard` | `boolean` | `true` | Reveal the contact card on hover |
1284
+ | `additionalContact` | `SystemUserContactItem[]` | - | Extra rows in the Contact section |
1285
+ | `cardActions` | `React.ReactNode` | - | Footer content on the card |
1286
+ | `onClick` | `(user) => void` | - | Called when the name is clicked |
1287
+
1288
+ `SystemUserCard` is exported separately for rendering the card body outside a popover.
1289
+
1290
+ ---
1291
+
1292
+ ## Hover Cards
1293
+
1294
+ Any `Lookup` can reveal a record card when the pointer settles on an option β€” in the dropdown, on the
1295
+ resolved badge, or both. It is off by default and adds no wrapper to the DOM until enabled.
1296
+
1297
+ ```tsx
1298
+ <Lookup
1299
+ options={accounts.map((a) => ({ ...a, entityName: 'account' }))}
1300
+ showHoverCard
1301
+ hoverCardColumns={['accountnumber', 'telephone1', 'primarycontactid']}
1302
+ hoverCardActions={<Link onClick={open}>Open record</Link>}
1303
+ />
1304
+ ```
1305
+
1306
+ ![Lookup with a hover card](assets/screenshot-lookup-hovercard.png)
1307
+
1308
+ Each option carries its own reference β€” `entityName`, plus `recordId` when the key is not the GUID β€”
1309
+ and that is what the card fetches from. Loading is **lazy and gated on hover intent**: nothing is
1310
+ requested until a pointer has rested on a row for `hoverCardDelayMs` (400 by default), so a list of
1311
+ fifty results costs no extra requests until one is actually hovered, and dragging across the list
1312
+ fires nothing at all. Results are cached per anchor; failures are not, so the next hover retries.
1313
+
1314
+ | Prop | Type | Default | Description |
1315
+ |------|------|---------|-------------|
1316
+ | `showHoverCard` | `boolean` | `false` | Enable the card |
1317
+ | `hoverCardColumns` | `string[]` | - | Columns fetched and listed on the card |
1318
+ | `renderHoverCard` | `(option) => ReactNode` | - | Build the body yourself; return `null` to suppress |
1319
+ | `hoverCardTarget` | `'list' \| 'rest' \| 'both'` | `'both'` | Which surfaces offer the card |
1320
+ | `hoverCardDelayMs` | `number` | `400` | Hover-intent delay before opening and fetching |
1321
+ | `hoverCardActions` | `React.ReactNode` | - | Footer content on the card |
1322
+
1323
+ ---
1324
+
1325
+ ## OwnerLookup
1326
+
1327
+ `ownerid` is polymorphic: an owner is a **systemuser or a team**. `OwnerLookup` is a preconfigured
1328
+ `Lookup` that knows this β€” it owns the querying and how owners present, and hands everything else to
1329
+ Lookup, so the resolved value is the same badge any lookup uses and multi-select needs no extra work.
1330
+
1331
+ ```tsx
1332
+ import { OwnerLookup } from 'fluentui-extended';
1333
+
1334
+ <OwnerLookup
1335
+ label="Owner"
1336
+ selectedOwner={owner}
1337
+ onOwnerSelect={setOwner}
1338
+ onOwnerClick={(o) => openRecord(o.type, o.id)}
1339
+ />
1340
+
1341
+ // Multi-select
1342
+ <OwnerLookup multiSelect selectedOwners={owners} onOwnersSelect={setOwners} />
1343
+ ```
1344
+
1345
+ ![OwnerLookup multi-select](assets/screenshot-ownerlookup-multi.png)
1346
+
1347
+ `types` defaults to `['systemuser']`. Pass both and the lookup **grows a header automatically**,
1348
+ letting the user narrow to Users or Teams the way a polymorphic Dynamics lookup does β€” no extra
1349
+ wiring:
1350
+
1351
+ ```tsx
1352
+ <OwnerLookup types={['systemuser', 'team']} selectedOwner={owner} onOwnerSelect={setOwner} />
1353
+ ```
1354
+
1355
+ ![OwnerLookup users and teams](assets/screenshot-ownerlookup-open.png)
1356
+
1357
+ Users and teams are queried in parallel and merged with users first, matching how the Dynamics owner
1358
+ lookup groups results. One type failing does not lose the other β€” a caller with no read access to
1359
+ teams still gets users. Users are filtered to enabled interactive accounts, and teams to
1360
+ `teamtype eq 0`: access teams and AAD-managed teams cannot own records, so offering them would
1361
+ produce an unassignable selection.
1362
+
1363
+ Hovering a user shows the full persona contact card; a team shows its description, business unit and
1364
+ administrator. Pass `types={['systemuser']}` for a people-only picker.
1365
+
1366
+ ### OwnerLookup Props
1367
+
1368
+ | Prop | Type | Default | Description |
1369
+ |------|------|---------|-------------|
1370
+ | `selectedOwner` | `OwnerRecord \| null` | - | Selection (controlled) |
1371
+ | `onOwnerSelect` | `(owner \| null) => void` | - | Selection handler |
1372
+ | `multiSelect` | `boolean` | `false` | Render selections as badges |
1373
+ | `selectedOwners` / `onOwnersSelect` | | - | Multi-select selection |
1374
+ | `types` | `OwnerType[]` | `['systemuser']` | Pass both to get the Users/Teams header |
1375
+ | `owners` | `OwnerRecord[]` | - | Supply a roster instead of querying |
1376
+ | `onSearch` | `(text) => Promise<OwnerRecord[]>` | - | Custom search |
1377
+ | `includeDisabled` | `boolean` | `false` | Include disabled user accounts |
1378
+ | `presence` | `Record<string, PresenceBadgeStatus>` | - | Presence keyed by owner id |
1379
+ | `showHoverCard` | `boolean` | `true` | Contact card on results and badges |
1380
+ | `onOwnerClick` | `(owner) => void` | - | Called when a resolved name is clicked |
1381
+
1382
+ ---
1383
+
1384
+ ## Documentation Screenshots
1385
+
1386
+ Component screenshots are generated, not taken by hand:
1387
+
1388
+ ```bash
1389
+ npm run shots # capture everything into assets/
1390
+ npm run shots lookup-open # or just one
1391
+ ```
1392
+
1393
+ `?shot=<id>` on the harness renders a **single populated component** with no surrounding chrome β€”
1394
+ no header, no tabs, no sibling examples β€” inside a fixed-width `#shot-frame`. The capture script
1395
+ visits each one and screenshots that element, so the output is already a tight crop at a stable size,
1396
+ with no manual cropping. `?shot=index` lists what is available.
1397
+
1398
+ Components that open a surface get one shot per state (`lookup-rest` / `lookup-open`,
1399
+ `optionset-closed` / `optionset-open` / `optionset-multi`), because a capture script cannot reliably
1400
+ drive a pointer, and those states are what the docs need to show.
1401
+
1402
+ Data-backed components are populated from fixtures rather than a live org: shot mode swaps the
1403
+ library transport via `setWebApiFetch`, so captures never depend on what happens to be in someone's
1404
+ environment and no real customer data reaches the docs. Add or edit shots in
1405
+ [`testHarness/shots/registry.tsx`](testHarness/shots/registry.tsx).
1406
+
1407
+ ---
1408
+
1409
+ ## Web API Client
1410
+
1411
+ The metadata-aware components share one Web API client with a process-wide metadata cache, so two
1412
+ components mounting in the same tick share a single round trip. Metadata is immutable for the life of
1413
+ a page, and failures are not cached.
1414
+
1415
+ ```ts
1416
+ import { setWebApiBaseUrl, setWebApiFetch, getEntityDefinition, clearMetadataCache } from 'fluentui-extended';
1417
+
1418
+ // Standalone / SPA usage - defaults to a relative path, which works inside Dynamics
1419
+ setWebApiBaseUrl('https://contoso.crm.dynamics.com/api/data/v9.2');
1420
+
1421
+ // Supply your own authenticated transport
1422
+ setWebApiFetch((url, init) => authenticatedFetch(url, init));
1423
+
1424
+ const definition = await getEntityDefinition('account'); // EntitySetName, PrimaryIdAttribute, ...
1425
+ ```
1426
+
1427
+ Exports: `webApiGet`, `setWebApiFetch`, `setWebApiBaseUrl`, `getWebApiBaseUrl`, `WebApiError`,
1428
+ `getEntityDefinition`, `getEntityAttributes`, `getEntityOptionSets`, `getAttributeOptions`,
1429
+ `clearMetadataCache`.
1430
+
1431
+ > **Note:** Lookup and QueryBuilder still use their own internal fetch logic and do not yet share
1432
+ > this client.
1433
+
1434
+ ---
1435
+
1436
+ ## Acknowledgments
1437
+
1438
+ This library extends [Microsoft's Fluent UI React v9](https://react.fluentui.dev/) components. Thank you to Microsoft and the Fluent UI team for creating and maintaining such an excellent design system.
1439
+
1440
+ - [Fluent UI React](https://react.fluentui.dev/)
1441
+ - [Fluent UI GitHub](https://github.com/microsoft/fluentui)
1442
+ - [Fluent 2 Design System](https://fluent2.microsoft.design/)
1443
+
1444
+ ---
1445
+
1446
+ ## Changelog
1447
+
1448
+ > Version format: `YYYY.M.DD` (e.g., `2026.8.30` = August 30, 2026)
1449
+
1450
+ ### Unreleased
1451
+
1452
+ Seven new Dynamics 365 components, plus the shared Web API client they sit on.
1453
+
1454
+ - ✨ **[SystemUserPersona](#systemuserpersona)** β€” a `systemuser` persona with the contact card a
1455
+ persona shows on a model-driven form. Loads lazily on hover intent; the record photo is addressed
1456
+ by URL rather than selected inline, since `entityimage` is binary and bloats every response.
1457
+ - ✨ **[OwnerLookup](#ownerlookup)** β€” a preconfigured `Lookup` for `ownerid`, which is polymorphic:
1458
+ an owner is a systemuser *or* a team. Both are searched in parallel, results render as personas or
1459
+ team glyphs, selections show as the usual Lookup badges, and multi-select comes from Lookup.
1460
+ - ✨ **[Lookup hover cards](#hover-cards)** β€” `showHoverCard` adds a lazy record card to the dropdown
1461
+ rows, the resolved badge, or both. Supply `hoverCardColumns` to have it fetched from the Web API,
1462
+ or `renderHoverCard` to build the body yourself. Nothing loads until a pointer settles.
1463
+ - πŸ”§ **[Generated documentation screenshots](#documentation-screenshots).** `npm run shots` captures
1464
+ each component in isolation via `?shot=<id>`, populated from fixtures rather than a live org.
1465
+ - ✨ `open` on `OptionSetField` and `RecordHoverCard` for rendering an expanded state without
1466
+ driving a pointer β€” used by the captures, and useful in tests.
1467
+
1468
+ - πŸ’„ **`filled-darker` is now the default appearance** across every field component β€” Lookup,
1469
+ QueryBuilder, DateTimeField and OptionSetField β€” matching native Dynamics 365. Fluent's default is
1470
+ `outline`. **Breaking for anyone relying on the previous outline look**; pass
1471
+ `appearance="outline"` to restore it. See [Appearance](#appearance).
1472
+ - πŸ’„ **Resolved lookups now render like Dynamics at rest**: table icon or entity image, the record
1473
+ name as a link, and a magnifier in place of the chevron. New `entityIcon`, `entityImage`,
1474
+ `recordLinkAppearance` and `onRecordClick` props.
1475
+ - πŸ› **Attribute metadata requests failed against live environments.** `Format` was included in the
1476
+ `$select` against the base `Attributes` collection, but it is declared on derived types β€” Dynamics
1477
+ rejects the whole request with *"Could not find a property named 'Format' on type
1478
+ 'Microsoft.Dynamics.CRM.AttributeMetadata'"*. `Format` and `DateTimeBehavior` are now fetched
1479
+ through cast segments and merged in, which also means `DateTimeField`'s metadata auto-load works
1480
+ (it could never have resolved a behavior before).
1481
+
1482
+ - ✨ **[CommandBar](#commandbar)** β€” commands that no longer fit collapse into a "More commands" menu
1483
+ instead of wrapping or being clipped. Widths are measured from the DOM rather than estimated.
1484
+ - ✨ **[EntityGrid](#entitygrid)** β€” a subgrid with columns named from entity metadata, server-side
1485
+ paging via `Prefer: odata.maxpagesize` and `@odata.nextLink`, server-side sorting, and lookups
1486
+ rendered from their formatted-value annotations rather than as GUIDs.
1487
+ - ✨ **[DateTimeField](#datetimefield)** β€” respects the attribute's `DateTimeBehavior`, so `DateOnly`
1488
+ and `TimeZoneIndependent` values never pass through UTC and cannot drift a day.
1489
+ - ✨ **[OptionSetField](#optionsetfield)** β€” optionset and multi-select picklist field that reads
1490
+ global option sets as well as local ones, and round-trips multi-selects as the comma-separated
1491
+ string Dynamics stores.
1492
+ - ✨ **[RecordHoverCard](#recordhovercard)** β€” lazy record loading gated on hover intent, so dragging
1493
+ a pointer across a grid column does not fire a request per row.
1494
+ - ✨ **[Web API client](#web-api-client)** β€” one client with a process-wide metadata cache shared by
1495
+ the new components. Promises are cached rather than values, so components mounting in the same tick
1496
+ share a round trip; failures are not cached. Lookup and QueryBuilder are not yet migrated onto it.
1497
+ - πŸ”§ Test harness split into one tab per component.
1498
+
1499
+ ### 2026.8.40
1500
+
1501
+ QueryBuilder layout and query options.
1502
+
1503
+ - πŸ› **Toolbar was crushed when the query grew.** In a height-constrained parent the header and
1504
+ toolbar were the only flex items able to shrink, so they were compressed and clipped instead of
1505
+ the filter list scrolling. Header and toolbar are now pinned, and the filter groups plus previews
1506
+ scroll together as one region. See [Layout and Scrolling](#layout-and-scrolling).
1507
+ - ✨ **Root `<fetch>` query options.** Generated FetchXML now carries `mapping`, `no-lock` and
1508
+ `distinct`, matching what the Dynamics advanced-find editor emits. `distinct` defaults to `true`.
1509
+ New `distinct`, `noLock` and `top` props override per instance, and options on an imported query
1510
+ are preserved through a serialize round-trip rather than silently dropped.
1511
+ - ✨ Preview cards grow to fit their content instead of scrolling internally.
1512
+ - πŸ’„ Component header now reads "Query Builder: {entity}" rather than "Edit filters: {entity}".
1513
+ - πŸ”§ Test harness split into **Lookup** and **Query Builder** tabs.
1514
+
1515
+ ### 2026.8.36
1516
+
1517
+ QueryBuilder field-type and FetchXML correctness pass.
1518
+
1519
+ - πŸ› **Every field resolved as `string`.** `dataTypeFromAttribute` compared `AttributeTypeName.Value`
1520
+ (which is suffixed β€” `MoneyType`, `PicklistType`, `BooleanType`) against unsuffixed names, so only
1521
+ lookups were typed correctly. Money fields offered "Contains", optionsets rendered a text box, and
1522
+ booleans never reached their branch.
1523
+ - πŸ› **Optionset and boolean options were never loaded** for main-entity fields. The component's field
1524
+ loader fetched attributes and lookup targets but no option metadata.
1525
+ - πŸ› **Global option sets returned no options** β€” only `OptionSet` was expanded, never `GlobalOptionSet`.
1526
+ - πŸ› **Boolean fields** now use their Dynamics labels ("Allowed" / "Not Allowed") instead of hardcoded
1527
+ Yes/No, and match on truthiness so a saved `value="1"` no longer displays as "No".
1528
+ - πŸ› **"Does Not Contain" produced invalid FetchXML** (`operator="not-contain"`, which does not exist).
1529
+ Now serialized as `not-like` with `%` wildcards.
1530
+ - πŸ› **Date picker shifted the day** in UTC+ timezones β€” `toISOString()` converted local midnight to
1531
+ the previous UTC day.
1532
+ - πŸ› **`IsValidForAdvancedFind` was never requested**, so the filter meant to hide non-filterable
1533
+ attributes did nothing.
1534
+ - πŸ› **"Has Value" left the value box enabled**; no-value operators now disable it correctly.
1535
+ - πŸ› **"Last X Days" rendered a date picker** instead of a number input.
1536
+ - πŸ› **`not-between` and fiscal period-and-year operators had no second value input.**
1537
+ - πŸ› **`link-entity` guessed `from="<entity>id"`**, which is wrong for activity entities
1538
+ (`email`, `task`, `appointment` all use `activityid`). Now uses `PrimaryIdAttribute`.
1539
+ - πŸ› **Invalid OData output.** Untranslatable operators were emitted as a `/* comment */` in the filter
1540
+ string; nested related-entity conditions were silently coerced to `eq`. Both are now omitted.
1541
+ - ✨ **Edit FetchXML** toolbar button β€” opens the current query as editable FetchXML to tweak or paste
1542
+ over (`showEditFetchXmlButton`).
1543
+ - ✨ **Show/Hide OData and FetchXML** toolbar toggles (`showPreviewToggleButtons`).
1544
+ - ✨ `QueryBuilderApplyResult.odataUnsupported` reports conditions OData cannot express, surfaced as a
1545
+ warning in the OData preview. New `isOperatorConvertibleToOData` helper.
1546
+ - ✨ Option metadata is fetched once per attribute type rather than once per field.
1547
+ - πŸ’„ Softer, more rounded containers matching other Dynamics surfaces.
1548
+ - ⚠️ **Breaking:** `odataUnsupported` is a required field on `QueryBuilderApplyResult`. Consumers only
1549
+ reading the result are unaffected; anyone constructing the type will need to add it.
1550
+
1551
+ ### 2026.8.30
1552
+
1553
+ - ✨ Added multi-entity filter pattern with drill-down header ("← All" back button) in test harness
1554
+ - ✨ Added "Details Only (No Secondary Text)" example to test harness
1555
+ - ✨ Support for React elements in `secondaryText` and `details[].value` (Badges, icons, styled text)
1556
+ - ♻️ Improved `aria-selected`/`aria-disabled` attribute handling
1557
+ - πŸ“ Documentation overhaul with complete API reference and examples
1558
+
1559
+ ### 2026.6.12
1560
+
1561
+ - ✨ Added React 19 support to peerDependencies
1562
+ - πŸ“ Updated README with cross-document support documentation
1563
+
1564
+ ### 2026.2.19
1565
+
1566
+ - πŸ› Fixed cross-document dismiss in Dynamics 365 iframes using `ownerDocument`
1567
+
1568
+ ### 2026.2.17
1569
+
1570
+ - πŸ› Removed `requestAnimationFrame` β€” handler registers immediately
1571
+ - πŸ› Switched to capture phase for dismiss handler to prevent D365 DOM interference
1572
+
1573
+ ### 2026.2.15
1574
+
1575
+ - ✨ Added controlled `open` and `onOpenChange` props for programmatic dropdown control
1576
+ - πŸ› Removed `requestAnimationFrame` from dismiss handler
1577
+
1578
+ ### 2026.2.13
1579
+
1580
+ - ♻️ Rebuilt popup from scratch using custom element (Fluent UI Popover had unexpected behavior)
1581
+
1582
+ ### 2026.2.11
1583
+
1584
+ - πŸ› Added `onOpenChange` callback to sync internal state with Popover dismiss events (outside click, Escape, focus loss)
1585
+
1586
+ ### 2026.2.10
1587
+
1588
+ - πŸ› Fixed Lookup not allowing space character in search input
1589
+
1590
+ ### 2026.2.8
1591
+
1592
+ - ✨ **QueryBuilder**: Native API integration for field metadata
1593
+ - ✨ **QueryBuilder**: Lookup field support with related entity validation
1594
+
1595
+ ### 2026.2.7
1596
+
1597
+ - ♻️ Removed `Xrm` global dependency β€” now uses native `/api/data/v9.2/` fetch calls
1598
+
1599
+ ### 2026.2.6
1600
+
1601
+ - ♻️ **QueryBuilder**: Refactored to reuse shared components and styles
1602
+
1603
+ ### 2026.2.5
1604
+
1605
+ - ✨ **QueryBuilder**: Initial release (beta) β€” Advanced Find-style query builder
1606
+ - ♻️ **Lookup**: Changed from options-only to popup-based rendering
1607
+
1608
+ ### 2026.2.3
1609
+
1610
+ - ✨ Added `id` prop β€” auto-generated if not provided
1611
+
1612
+ ### 2026.2.2
1613
+
1614
+ - πŸ› Fixed classic JSX transform for React 16 compatibility
1615
+ - ✨ Added React 16.8+ support
1616
+
1617
+ ### 2026.2.1
1618
+
1619
+ - πŸŽ‰ Initial release
1620
+ - ✨ Lookup component with async search, expandable details, header/footer
1621
+
1622
+ ## License
1623
+
1624
+ MIT