fluentui-extended 2026.8.35 → 2026.8.40

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
@@ -16,16 +16,50 @@ This project is **open source** and **free to use**. It is provided as-is, witho
16
16
  ## Installation
17
17
 
18
18
  ```bash
19
- npm install fluentui-extended @fluentui/react-components @fluentui/react-icons
19
+ npm install fluentui-extended \
20
+ @fluentui/react-components \
21
+ @fluentui/react-icons \
22
+ @fluentui/react-datepicker-compat \
23
+ @fluentui/react-calendar-compat
20
24
  ```
21
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
+
22
54
  ## Components
23
55
 
24
56
  ### Lookup
25
57
 
26
58
  A searchable dropdown component styled after Dynamics 365 lookup fields. Supports async search, expandable option details, and customizable header/footer.
27
59
 
28
- ![Lookup Component](assets/screenshot-lookup.png)
60
+ ![Lookup resolved](assets/screenshot-lookup-rest.png)
61
+
62
+ ![Lookup open](assets/screenshot-lookup-open.png)
29
63
 
30
64
  ### QueryBuilder
31
65
 
@@ -33,7 +67,76 @@ A searchable dropdown component styled after Dynamics 365 lookup fields. Support
33
67
 
34
68
  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
69
 
36
- ![QueryBuilder Component](assets/screenshot-querybuilder.png)
70
+ ![QueryBuilder](assets/screenshot-querybuilder.png)
71
+
72
+ ### CommandBar
73
+
74
+ > **🚧 Beta**
75
+
76
+ A Dynamics-style command bar. Commands that no longer fit collapse into a "More commands" menu
77
+ rather than wrapping to a second row or being clipped.
78
+
79
+ ![CommandBar](assets/screenshot-commandbar.png)
80
+
81
+ ### EntityGrid
82
+
83
+ > **🧪 Experimental / in development** — the API and behaviour are still moving. Not recommended
84
+ > for production use yet.
85
+
86
+ A subgrid backed by the Web API: columns named from entity metadata, server-side paging and
87
+ sorting, and lookups rendered as names rather than GUIDs.
88
+
89
+ ![EntityGrid](assets/screenshot-entitygrid.png)
90
+
91
+ ### DateTimeField
92
+
93
+ > **🚧 Beta**
94
+
95
+ A date/time field that respects the attribute's Dynamics `DateTimeBehavior`, so `DateOnly` values
96
+ cannot drift a day across timezones.
97
+
98
+ ![DateTimeField](assets/screenshot-datetimefield.png)
99
+
100
+ ### OptionSetField
101
+
102
+ > **🚧 Beta**
103
+
104
+ An optionset / multi-select picklist field that loads its options from metadata, including global
105
+ option sets, and round-trips multi-selects in the comma-separated form Dynamics stores.
106
+
107
+ ![OptionSetField](assets/screenshot-optionset-closed.png)
108
+
109
+ ![OptionSetField open](assets/screenshot-optionset-open.png)
110
+
111
+ ### RecordHoverCard
112
+
113
+ > **🚧 Beta**
114
+
115
+ A hover card for a record reference. The record is fetched lazily once the pointer settles, and the
116
+ result is held so re-opening costs nothing.
117
+
118
+ ![RecordHoverCard](assets/screenshot-hovercard.png)
119
+
120
+ ### SystemUserPersona
121
+
122
+ > **🚧 Beta**
123
+
124
+ A Dynamics systemuser persona with the contact card a persona shows on a model-driven form.
125
+
126
+ ![SystemUserPersona](assets/screenshot-persona.png)
127
+
128
+ ![SystemUserPersona contact card](assets/screenshot-persona-card.png)
129
+
130
+ ### OwnerLookup
131
+
132
+ > **🚧 Beta**
133
+
134
+ A preconfigured `Lookup` for `ownerid`. An owner is a user *or* a team, so both are searched;
135
+ selections render as the usual Lookup badges and multi-select comes for free.
136
+
137
+ ![OwnerLookup resolved](assets/screenshot-ownerlookup-rest.png)
138
+
139
+ ![OwnerLookup users and teams](assets/screenshot-ownerlookup-open.png)
37
140
 
38
141
  ## Quick Start
39
142
 
@@ -257,25 +360,59 @@ const options: LookupOption[] = [
257
360
 
258
361
  ## Test Harness Examples
259
362
 
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 |
363
+ Run the test harness with `npm run harness` to see all examples in action. The harness is split into
364
+ one tab per component, so each can be viewed and screenshotted on its own.
365
+
366
+ | Example | Tab | Description |
367
+ |---------|-----|-------------|
368
+ | **Basic Lookup** | Lookup | Simple lookup with expandable details, no header/footer |
369
+ | **Header & Footer** | Lookup | D365-style with "Accounts" header and "New" / "Advanced" footer links |
370
+ | **Details Only (No Secondary Text)** | Lookup | Options with icons + details but no secondary text; demonstrates icon centering on single-line text |
371
+ | **Multi-Entity Filter** | Lookup | Toggle between Accounts/Contacts with drill-down header pattern (← All) |
372
+ | **Dynamic Search (Async API)** | Lookup | Simulated 800ms API delay with loading spinner; auto-loads top 5 on open |
373
+ | **Live Dynamics Lookup** | Lookup | Connects to real D365 environment via Xrm.WebApi (when connected) |
374
+ | **QueryBuilder** | Query Builder | Full Advanced Find-style query builder with FetchXML serialization |
375
+ | **Unknown / Invalid Fields** | Query Builder | FetchXML referencing attributes that match no known field, each flagged inline |
376
+ | **Command Bar** | Command Bar | Nine commands collapsing into an overflow menu; narrow the window to watch them move |
377
+ | **Pinned / no overflow** | Command Bar | A pinned command that never collapses, and horizontal scrolling with overflow disabled |
378
+ | **Entity Grid** | Entity Grid | Server-paged accounts with sorting, selection and formatted lookup values (needs a connection) |
379
+ | **DateTimeBehavior** | Fields | One picked date serialized three ways, showing which behaviours pass through UTC |
380
+ | **OptionSetField** | Fields | Single-select with metadata colours, and a multi-select round-tripping as "1,2" |
381
+ | **Record Hover Card** | Hover Card | Static and live record cards with lazy loading on hover intent |
271
382
 
272
383
  ## API Reference
273
384
 
385
+ ### Resolved Lookup (rest state)
386
+
387
+ Once a record is selected and the dropdown is closed, the field renders the way a resolved lookup
388
+ does on a Dynamics form: the table's icon (or its entity image), the record name as a link, a clear
389
+ button, and a magnifier rather than a chevron.
390
+
391
+ ```tsx
392
+ <Lookup
393
+ options={options}
394
+ selectedOption={selected}
395
+ onOptionSelect={setSelected}
396
+ entityIcon={<BuildingRegular />} // falls back to the option's own icon
397
+ entityImage={account.entityimage_url} // wins over the icon when the table has one
398
+ onRecordClick={(option) => openRecord(option.key)} // clicking the name opens the record
399
+ />
400
+ ```
401
+
402
+ Without `onRecordClick` the link styling is still applied but the click falls through to opening the
403
+ dropdown, so the affordance never becomes a dead end. Set `recordLinkAppearance={false}` to render the
404
+ value as plain input text instead.
405
+
274
406
  ### Lookup Props
275
407
 
276
408
  | Prop | Type | Default | Description |
277
409
  |------|------|---------|-------------|
278
410
  | `id` | `string` | auto-generated | Unique identifier for the lookup |
411
+ | `appearance` | `FieldAppearance` | `'filled-darker'` | See [Appearance](#appearance) |
412
+ | `entityIcon` | `React.ReactNode` | option's `icon` | Table icon shown at rest |
413
+ | `entityImage` | `string` | - | Entity image URL, shown in place of the icon |
414
+ | `recordLinkAppearance` | `boolean` | `true` | Render the resolved value as a link |
415
+ | `onRecordClick` | `(option: LookupOption) => void` | - | Called when the resolved value is clicked |
279
416
  | `options` | `LookupOption[]` | `[]` | Options to display in the dropdown |
280
417
  | `selectedKey` | `string \| null` | - | Selected option key (controlled) |
281
418
  | `selectedOption` | `LookupOption \| null` | - | Selected option object (recommended for async) |
@@ -473,21 +610,31 @@ const handleChange = (result: QueryBuilderApplyResult) => {
473
610
 
474
611
  ### Features
475
612
 
476
- #### Import/Export FetchXML
613
+ #### Import/Edit/Export FetchXML
477
614
 
478
- Users can download the current query as FetchXML or import existing FetchXML:
615
+ Download the current query as FetchXML, import FetchXML from elsewhere, or open the current
616
+ query as editable FetchXML:
479
617
 
480
618
  ```tsx
481
619
  <QueryBuilder
482
620
  entityName="account"
483
621
  showDownloadFetchXmlButton={true} // Default: true
484
622
  showUploadFetchXmlButton={true} // Default: true
623
+ showEditFetchXmlButton={true} // Default: true
485
624
  />
486
625
  ```
487
626
 
627
+ **Import FetchXML** opens an empty dialog for pasting in a query from elsewhere.
628
+
629
+ **Edit FetchXML** opens the same dialog prefilled with the current query's FetchXML, so you can
630
+ tweak it in place or select-all and paste a different query over it. Applying rebuilds the
631
+ builder from whatever is in the box. If the XML doesn't parse, your text is kept and the error
632
+ is shown inline.
633
+
488
634
  #### Live Preview
489
635
 
490
- Show real-time preview of the generated queries:
636
+ Show real-time preview of the generated queries. The previews can also be toggled from the
637
+ toolbar, so these props set the *initial* visibility rather than hiding the previews outright:
491
638
 
492
639
  ```tsx
493
640
  <QueryBuilder
@@ -495,9 +642,40 @@ Show real-time preview of the generated queries:
495
642
  fields={fields}
496
643
  showODataPreview={true}
497
644
  showFetchXmlPreview={true}
645
+ showPreviewToggleButtons={true} // Default: true
498
646
  />
499
647
  ```
500
648
 
649
+ #### Queries That OData Cannot Express
650
+
651
+ FetchXML has operators the OData `$filter` syntax has no equivalent for — relative dates
652
+ (`last-x-days`, `this-month`), fiscal periods, user context (`eq-userid`) and hierarchy
653
+ operators (`under`, `above`). These are evaluated by the FetchXML engine itself.
654
+
655
+ When a query uses one, it is **omitted from the OData filter** and reported on the result:
656
+
657
+ ```tsx
658
+ <QueryBuilder
659
+ entityName="account"
660
+ fields={fields}
661
+ onSerializedChange={(result) => {
662
+ if (result.odataUnsupported.length > 0) {
663
+ // The OData filter is NOT equivalent to the FetchXML - use result.fetchXml instead
664
+ console.warn('Not expressible in OData:', result.odataUnsupported);
665
+ }
666
+ }}
667
+ />
668
+ ```
669
+
670
+ Each entry gives the field and operator that could not be translated:
671
+
672
+ ```ts
673
+ { fieldId: 'createdon', fieldLabel: 'Created On', operator: 'last-x-days', operatorLabel: 'Last X Days' }
674
+ ```
675
+
676
+ The OData preview shows the same information as a warning. Use `isOperatorConvertibleToOData`
677
+ to check a single operator yourself.
678
+
501
679
  #### Validation with Dynamics 365 API
502
680
 
503
681
  The Validate button checks query structure and optionally tests against the Dynamics 365 API:
@@ -586,6 +764,38 @@ const fields: QueryBuilderField[] = [
586
764
  />
587
765
  ```
588
766
 
767
+ ### Layout and Scrolling
768
+
769
+ The header and toolbar stay pinned while the filter groups and previews scroll together as one
770
+ region. That scroll only engages when the parent constrains the height — give the wrapper a fixed
771
+ `height` (or `maxHeight`) and the component fills it:
772
+
773
+ ```tsx
774
+ <div style={{ height: 500, display: 'flex', flexDirection: 'column' }}>
775
+ <QueryBuilder entityName="account" entityDisplayName="Accounts" />
776
+ </div>
777
+ ```
778
+
779
+ In an unconstrained parent the component simply grows to fit its content and the page scrolls instead.
780
+
781
+ ### Query Options
782
+
783
+ The root `<fetch>` element carries the same attributes the Dynamics advanced-find editor emits:
784
+
785
+ ```xml
786
+ <fetch version="1.0" mapping="logical" no-lock="false" distinct="true">
787
+ ```
788
+
789
+ `distinct` defaults to `true`, which matters once related-entity filters are in play — a single
790
+ record can otherwise match several linked rows and appear more than once. Override per instance:
791
+
792
+ ```tsx
793
+ <QueryBuilder entityName="account" distinct={false} noLock top={50} />
794
+ ```
795
+
796
+ These props take precedence over whatever an imported query carried. When no prop is set, options
797
+ parsed from `initialFetchXml` are preserved rather than dropped on the next serialize.
798
+
589
799
  ### QueryBuilder Props
590
800
 
591
801
  | Prop | Type | Default | Description |
@@ -595,13 +805,18 @@ const fields: QueryBuilderField[] = [
595
805
  | `fields` | `QueryBuilderField[]` | - | Fields for filtering (auto-loaded via Web API if omitted) |
596
806
  | `initialFetchXml` | `string` | - | FetchXML to pre-populate the query builder |
597
807
  | `initialState` | `QueryBuilderState` | - | Initial query state object |
808
+ | `distinct` | `boolean` | `true` | Emit `distinct="…"` on the root `<fetch>` |
809
+ | `noLock` | `boolean` | `false` | Emit `no-lock="…"` on the root `<fetch>` |
810
+ | `top` | `number` | - | Emit `top="N"` to cap the row count; omitted when unset |
598
811
  | `onSerializedChange` | `(result: QueryBuilderApplyResult) => void` | - | Called when query changes |
599
812
  | `onLookupSearch` | `(fieldId: string, searchText: string) => Promise<LookupOption[]>` | - | Lookup field search handler |
600
- | `showODataPreview` | `boolean` | `false` | Show OData filter preview |
601
- | `showFetchXmlPreview` | `boolean` | `false` | Show FetchXML preview |
813
+ | `showODataPreview` | `boolean` | `false` | Initial visibility of the OData filter preview |
814
+ | `showFetchXmlPreview` | `boolean` | `false` | Initial visibility of the FetchXML preview |
815
+ | `showPreviewToggleButtons` | `boolean` | `true` | Show toolbar buttons that toggle the previews |
602
816
  | `showResetToDefaultButton` | `boolean` | `true` | Show Reset button |
603
817
  | `showDownloadFetchXmlButton` | `boolean` | `true` | Show Download FetchXML button |
604
818
  | `showUploadFetchXmlButton` | `boolean` | `true` | Show Import FetchXML button |
819
+ | `showEditFetchXmlButton` | `boolean` | `true` | Show Edit FetchXML button |
605
820
  | `showValidateButton` | `boolean` | `true` | Show Validate button |
606
821
  | `showDeleteAllFiltersButton` | `boolean` | `true` | Show Delete All button |
607
822
  | `onTrace` | `(message: string, data?: any) => void` | - | Debug/trace callback for component behavior |
@@ -613,10 +828,14 @@ interface QueryBuilderField {
613
828
  id: string; // Logical attribute name
614
829
  label: string; // Display label
615
830
  dataType: 'string' | 'number' | 'datetime' | 'boolean' | 'optionset' | 'lookup';
616
- options?: Array<{ label: string; value: number }>; // For optionset fields
831
+ options?: Array<{ label: string; value: string | number }>; // Optionset and boolean fields
617
832
  }
618
833
  ```
619
834
 
835
+ Options are loaded automatically from entity metadata when `fields` is omitted. Boolean fields
836
+ pick up their Dynamics labels (for example "Allowed" / "Not Allowed" rather than Yes / No), with
837
+ values `'1'` and `'0'` to match the FetchXML representation.
838
+
620
839
  ### QueryBuilderApplyResult
621
840
 
622
841
  ```ts
@@ -625,9 +844,21 @@ interface QueryBuilderApplyResult {
625
844
  fetchXmlFilter: string; // Just the <filter> element
626
845
  fetchXml: string; // Complete FetchXML document
627
846
  odataFilter: string; // OData $filter value
847
+ odataQuery?: string; // Full OData query URL (requires entitySetName)
848
+ odataUnsupported: QueryBuilderODataUnsupported[]; // Conditions OData cannot express
849
+ }
850
+
851
+ interface QueryBuilderODataUnsupported {
852
+ fieldId: string; // e.g. "createdon"
853
+ fieldLabel: string; // e.g. "Created On"
854
+ operator: string; // e.g. "last-x-days"
855
+ operatorLabel: string; // e.g. "Last X Days"
628
856
  }
629
857
  ```
630
858
 
859
+ When `odataUnsupported` is non-empty, `odataFilter` is **not** equivalent to `fetchXml` — the
860
+ untranslatable conditions have been left out. Use `fetchXml` to run the query.
861
+
631
862
  ### Programmatic API
632
863
 
633
864
  #### Serialize State
@@ -670,9 +901,422 @@ if (!result.isValid) {
670
901
 
671
902
  | Data Type | Operators |
672
903
  |-----------|-----------|
673
- | `string` | Contains, Does Not Contain, Starts With, Ends With, Equals, Not Equals, Is Empty, Has Value |
674
- | `number`, `datetime` | Greater Than, Greater Than Or Equal, Less Than, Less Than Or Equal, Between, Equals, Not Equals, Is Empty, Has Value |
675
- | `optionset`, `lookup`, `boolean` | Equals, Not Equals, Is Empty, Has Value |
904
+ | `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 |
905
+ | `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 |
906
+ | `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 |
907
+ | `optionset` | Equals, Not Equals, Is One Of, Is Not One Of, Is Empty, Has Value |
908
+ | `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 |
909
+ | `boolean` | Equals, Not Equals, Is Empty, Has Value |
910
+
911
+ Operators map to the [FetchXML condition operators][fetchxml-operators]. Note that FetchXML has
912
+ no `contains` operator — "Contains" and "Does Not Contain" are serialized as `like` / `not-like`
913
+ with `%` wildcards around the value.
914
+
915
+ Relative date, fiscal period, user-context and hierarchy operators are FetchXML-only and cannot
916
+ be expressed in OData — see [Queries That OData Cannot Express](#queries-that-odata-cannot-express).
917
+
918
+ [fetchxml-operators]: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/fetchxml/reference/operators
919
+
920
+ ---
921
+
922
+ ## CommandBar
923
+
924
+ Fluent ships `Toolbar` and `Overflow` as separate primitives. `CommandBar` composes them into the
925
+ behaviour a command bar needs: commands that no longer fit move into a "More commands" menu instead
926
+ of wrapping or being clipped. Widths are measured from the live DOM, so label length and icons are
927
+ accounted for rather than estimated.
928
+
929
+ ```tsx
930
+ import { CommandBar } from 'fluentui-extended';
931
+ import { AddRegular, EditRegular, DeleteRegular } from '@fluentui/react-icons';
932
+
933
+ <CommandBar
934
+ items={[
935
+ { key: 'new', text: 'New', icon: <AddRegular />, appearance: 'primary', onClick: handleNew },
936
+ { key: 'edit', text: 'Edit', icon: <EditRegular />, onClick: handleEdit },
937
+ { key: 'delete', text: 'Delete', icon: <DeleteRegular />, dividerBefore: true, onClick: handleDelete },
938
+ {
939
+ key: 'export',
940
+ text: 'Export',
941
+ subItems: [{ key: 'excel', text: 'Export to Excel', onClick: handleExport }],
942
+ },
943
+ ]}
944
+ />
945
+ ```
946
+
947
+ ### CommandBar Props
948
+
949
+ | Prop | Type | Default | Description |
950
+ |------|------|---------|-------------|
951
+ | `items` | `CommandBarItem[]` | - | Commands rendered from the left (required) |
952
+ | `farItems` | `CommandBarItem[]` | - | Right-aligned commands; never collapse |
953
+ | `size` | `'small' \| 'medium' \| 'large'` | `'small'` | Button size |
954
+ | `disableOverflow` | `boolean` | `false` | Scroll horizontally instead of collapsing |
955
+ | `overflowAriaLabel` | `string` | `'More commands'` | Label for the overflow trigger |
956
+
957
+ `CommandBarItem` carries `key`, `text`, `icon`, `onClick`, `disabled`, `appearance`, `checked` (renders
958
+ a toggle), `subItems` (renders a menu button, preserved as a submenu when overflowed), `dividerBefore`,
959
+ and `pinned`. A pinned command never collapses — use it sparingly, because one that does not fit is
960
+ clipped rather than moved.
961
+
962
+ ---
963
+
964
+ ## EntityGrid
965
+
966
+ > **🧪 Experimental / in development.** Expect the API to change.
967
+
968
+ A subgrid backed by the Web API. `DataGrid` renders rows you already have; `EntityGrid` fetches them.
969
+
970
+ Paging uses `Prefer: odata.maxpagesize` and follows `@odata.nextLink`, rather than `$top`/`$skip` —
971
+ Dynamics does not support `$skip` for arbitrary offsets, and `$top` suppresses the paging cookie
972
+ entirely. Because `nextLink` only moves forward, the URL of each visited page is kept so Previous can
973
+ replay it.
974
+
975
+ ```tsx
976
+ import { EntityGrid } from 'fluentui-extended';
977
+
978
+ <EntityGrid
979
+ entityName="account"
980
+ title="Accounts"
981
+ height={420}
982
+ pageSize={25}
983
+ selectable
984
+ columns={[
985
+ { name: 'name', width: 260 },
986
+ { name: 'accountnumber' },
987
+ { name: 'primarycontactid', label: 'Primary Contact' },
988
+ ]}
989
+ onRecordOpen={(id) => Xrm.Navigation.openForm({ entityName: 'account', entityId: id })}
990
+ />
991
+ ```
992
+
993
+ Cells prefer the `@OData.Community.Display.V1.FormattedValue` annotation Dynamics attaches, which is
994
+ what renders a lookup as a name and an optionset as its label rather than a GUID or an integer. The
995
+ grid requests those annotations for you.
996
+
997
+ ### EntityGrid Props
998
+
999
+ | Prop | Type | Default | Description |
1000
+ |------|------|---------|-------------|
1001
+ | `entityName` | `string` | - | Entity logical name (required) |
1002
+ | `columns` | `EntityGridColumn[]` | primary name attribute | Columns to render |
1003
+ | `filter` | `string` | - | OData filter applied to every page |
1004
+ | `defaultSort` | `EntityGridSort` | primary name ascending | Initial sort |
1005
+ | `pageSize` | `number` | `25` | Rows per page |
1006
+ | `selectable` | `boolean` | `false` | Show selection checkboxes |
1007
+ | `onRecordOpen` | `(id, record) => void` | - | Row activation handler |
1008
+ | `onSelectionChange` | `(ids: string[]) => void` | - | Selection handler |
1009
+ | `height` | `number \| string` | - | Fixed height for the scrolling body |
1010
+
1011
+ `EntityGridColumn` carries `name`, `label` (defaults to the metadata display name), `width`,
1012
+ `sortable`, and `render(formatted, record)` for custom cells.
1013
+
1014
+ Pair it with QueryBuilder by passing that component's `odataFilter` output as `filter`.
1015
+
1016
+ ---
1017
+
1018
+ ## DateTimeField
1019
+
1020
+ Dynamics has three `DateTimeBehavior` values and they do not agree on what a stored string means, so
1021
+ a single `new Date(value)` is wrong for two of the three:
1022
+
1023
+ | Behavior | Stored as | Conversion |
1024
+ |----------|-----------|------------|
1025
+ | `UserLocal` | UTC | Converted to the user's timezone |
1026
+ | `DateOnly` | Calendar date, no time or zone | None — must never shift |
1027
+ | `TimeZoneIndependent` | Wall-clock, no zone | None — shown exactly as entered |
1028
+
1029
+ The trap is that `new Date('2026-08-06')` parses as UTC midnight, which renders as the 5th anywhere
1030
+ west of Greenwich, while `toISOString()` on a local date shifts the day for any user east of it.
1031
+ `DateTimeField` handles both explicitly.
1032
+
1033
+ ```tsx
1034
+ import { DateTimeField } from 'fluentui-extended';
1035
+
1036
+ <DateTimeField
1037
+ label="Estimated Close Date"
1038
+ behavior="DateOnly"
1039
+ value={value}
1040
+ onChange={(stored) => setValue(stored)} // "2026-08-06", never an ISO timestamp
1041
+ />
1042
+ ```
1043
+
1044
+ Pass `entityName` and `attributeName` to read the behavior from metadata instead of declaring it.
1045
+ The conversion helpers are exported for use outside the component:
1046
+
1047
+ ```ts
1048
+ import { parseStoredValue, formatStoredValue } from 'fluentui-extended';
1049
+
1050
+ const date = parseStoredValue('2026-08-06', 'DateOnly'); // local midnight on the 6th
1051
+ const stored = formatStoredValue(date, 'DateOnly'); // "2026-08-06"
1052
+ ```
1053
+
1054
+ ### DateTimeField Props
1055
+
1056
+ | Prop | Type | Default | Description |
1057
+ |------|------|---------|-------------|
1058
+ | `value` | `string \| Date \| null` | - | Stored value, interpreted per `behavior` |
1059
+ | `onChange` | `(value: string \| null, date: Date \| null) => void` | - | Serialized value plus the Date |
1060
+ | `behavior` | `DateTimeBehavior` | `'UserLocal'` | How the attribute is stored |
1061
+ | `showTime` | `boolean` | `false` | Show a time picker; ignored for `DateOnly` |
1062
+ | `timeIntervalMinutes` | `number` | `30` | Spacing of the time dropdown entries |
1063
+ | `entityName` / `attributeName` | `string` | - | Read `behavior` from metadata |
1064
+ | `clearable` | `boolean` | `true` | Show a clear button |
1065
+
1066
+ ---
1067
+
1068
+ ## OptionSetField
1069
+
1070
+ ```tsx
1071
+ import { OptionSetField } from 'fluentui-extended';
1072
+
1073
+ // Options loaded from metadata
1074
+ <OptionSetField entityName="account" attributeName="industrycode" value={value} onChange={setValue} />
1075
+
1076
+ // Multi-select picklist
1077
+ <OptionSetField
1078
+ options={options}
1079
+ multiselect
1080
+ value={values} // accepts [1, 2] or the stored "1,2"
1081
+ onChange={(next) => setValues(next as number[])}
1082
+ />
1083
+ ```
1084
+
1085
+ Two Dynamics details this handles that a plain `Dropdown` does not. A **global option set** leaves
1086
+ `OptionSet` empty and puts its values on `GlobalOptionSet` instead — reading only the former is why a
1087
+ dropdown that should be populated comes back empty. And a **multi-select picklist** stores its value
1088
+ as a comma-separated string, so `"1,2"` and `[1, 2]` have to mean the same thing; `parseSelectedValues`
1089
+ and `formatMultiSelectValue` are exported for that conversion.
1090
+
1091
+ ### OptionSetField Props
1092
+
1093
+ | Prop | Type | Default | Description |
1094
+ |------|------|---------|-------------|
1095
+ | `options` | `OptionSetOption[]` | - | Options; omit to auto-load from metadata |
1096
+ | `entityName` / `attributeName` | `string` | - | Required for metadata auto-load |
1097
+ | `multiselect` | `boolean` | `false` | Multi-select picklist behaviour |
1098
+ | `value` | `number \| number[] \| string \| null` | - | Accepts every stored form |
1099
+ | `onChange` | `(value: number \| number[] \| null) => void` | - | Selection handler |
1100
+ | `showColors` | `boolean` | `false` | Render metadata colours as swatches |
1101
+ | `clearable` | `boolean` | `true` | Allow clearing the selection |
1102
+
1103
+ ---
1104
+
1105
+ ## RecordHoverCard
1106
+
1107
+ ```tsx
1108
+ import { RecordHoverCard } from 'fluentui-extended';
1109
+
1110
+ <RecordHoverCard
1111
+ entityName="account"
1112
+ recordId={record.accountid}
1113
+ columns={['accountnumber', 'telephone1', 'primarycontactid']}
1114
+ actions={<Link onClick={open}>Open record</Link>}
1115
+ >
1116
+ <Link>{record.name}</Link>
1117
+ </RecordHoverCard>
1118
+ ```
1119
+
1120
+ The record is fetched only after the pointer has settled on the anchor for `hoverDelayMs` (400 by
1121
+ default) — without that delay, dragging a pointer across a grid column fires a request per row. The
1122
+ result is held for the life of the anchor, so re-opening the same card costs nothing, while a failure
1123
+ is not cached so the next hover retries.
1124
+
1125
+ Pass `record` directly to skip loading entirely when the calling grid already has the data.
1126
+
1127
+ ### RecordHoverCard Props
1128
+
1129
+ | Prop | Type | Default | Description |
1130
+ |------|------|---------|-------------|
1131
+ | `children` | `React.ReactElement` | - | Anchor element (required) |
1132
+ | `entityName` / `recordId` | `string` | - | Required to load via the Web API |
1133
+ | `columns` | `string[]` | primary name only | Columns to request and show |
1134
+ | `record` | `RecordHoverCardRecord` | - | Supply the record and skip loading |
1135
+ | `mapRecord` | `(raw) => RecordHoverCardRecord` | - | Map a raw record onto the card |
1136
+ | `hoverDelayMs` | `number` | `400` | Delay before a hover triggers a fetch |
1137
+ | `actions` | `React.ReactNode` | - | Footer commands |
1138
+
1139
+ ---
1140
+
1141
+ ## SystemUserPersona
1142
+
1143
+ A Dynamics `systemuser` persona: avatar, name, job title, and the contact card a persona shows on a
1144
+ model-driven form. The record loads lazily — only once the pointer settles on the persona — so a grid
1145
+ column of them costs one request per card actually looked at, not one per row.
1146
+
1147
+ ```tsx
1148
+ import { SystemUserPersona } from 'fluentui-extended';
1149
+
1150
+ <SystemUserPersona
1151
+ userId={record._ownerid_value}
1152
+ presence="available" // Teams presence: supply it, Dynamics does not expose it
1153
+ cardActions={<Link onClick={open}>Open record</Link>}
1154
+ />
1155
+ ```
1156
+
1157
+ Pass `user` instead of `userId` to skip loading when the caller already has the record. The photo is
1158
+ addressed at `systemusers(id)/entityimage/$value` rather than selected as a column — `entityimage` is
1159
+ binary, and selecting it inline bloats every search response. Pass `imageUrl={null}` to force initials.
1160
+
1161
+ ### SystemUserPersona Props
1162
+
1163
+ | Prop | Type | Default | Description |
1164
+ |------|------|---------|-------------|
1165
+ | `userId` | `string` | - | systemuser GUID to load |
1166
+ | `user` | `SystemUserRecord` | - | Supply the record and skip loading |
1167
+ | `presence` | `PresenceBadgeStatus` | - | Teams presence badge |
1168
+ | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Avatar and text scale |
1169
+ | `avatarOnly` | `boolean` | `false` | Hide the name; it moves to a tooltip |
1170
+ | `showHoverCard` | `boolean` | `true` | Reveal the contact card on hover |
1171
+ | `additionalContact` | `SystemUserContactItem[]` | - | Extra rows in the Contact section |
1172
+ | `cardActions` | `React.ReactNode` | - | Footer content on the card |
1173
+ | `onClick` | `(user) => void` | - | Called when the name is clicked |
1174
+
1175
+ `SystemUserCard` is exported separately for rendering the card body outside a popover.
1176
+
1177
+ ---
1178
+
1179
+ ## Hover Cards
1180
+
1181
+ Any `Lookup` can reveal a record card when the pointer settles on an option — in the dropdown, on the
1182
+ resolved badge, or both. It is off by default and adds no wrapper to the DOM until enabled.
1183
+
1184
+ ```tsx
1185
+ <Lookup
1186
+ options={accounts.map((a) => ({ ...a, entityName: 'account' }))}
1187
+ showHoverCard
1188
+ hoverCardColumns={['accountnumber', 'telephone1', 'primarycontactid']}
1189
+ hoverCardActions={<Link onClick={open}>Open record</Link>}
1190
+ />
1191
+ ```
1192
+
1193
+ ![Lookup with a hover card](assets/screenshot-lookup-hovercard.png)
1194
+
1195
+ Each option carries its own reference — `entityName`, plus `recordId` when the key is not the GUID —
1196
+ and that is what the card fetches from. Loading is **lazy and gated on hover intent**: nothing is
1197
+ requested until a pointer has rested on a row for `hoverCardDelayMs` (400 by default), so a list of
1198
+ fifty results costs no extra requests until one is actually hovered, and dragging across the list
1199
+ fires nothing at all. Results are cached per anchor; failures are not, so the next hover retries.
1200
+
1201
+ | Prop | Type | Default | Description |
1202
+ |------|------|---------|-------------|
1203
+ | `showHoverCard` | `boolean` | `false` | Enable the card |
1204
+ | `hoverCardColumns` | `string[]` | - | Columns fetched and listed on the card |
1205
+ | `renderHoverCard` | `(option) => ReactNode` | - | Build the body yourself; return `null` to suppress |
1206
+ | `hoverCardTarget` | `'list' \| 'rest' \| 'both'` | `'both'` | Which surfaces offer the card |
1207
+ | `hoverCardDelayMs` | `number` | `400` | Hover-intent delay before opening and fetching |
1208
+ | `hoverCardActions` | `React.ReactNode` | - | Footer content on the card |
1209
+
1210
+ ---
1211
+
1212
+ ## OwnerLookup
1213
+
1214
+ `ownerid` is polymorphic: an owner is a **systemuser or a team**. `OwnerLookup` is a preconfigured
1215
+ `Lookup` that knows this — it owns the querying and how owners present, and hands everything else to
1216
+ Lookup, so the resolved value is the same badge any lookup uses and multi-select needs no extra work.
1217
+
1218
+ ```tsx
1219
+ import { OwnerLookup } from 'fluentui-extended';
1220
+
1221
+ <OwnerLookup
1222
+ label="Owner"
1223
+ selectedOwner={owner}
1224
+ onOwnerSelect={setOwner}
1225
+ onOwnerClick={(o) => openRecord(o.type, o.id)}
1226
+ />
1227
+
1228
+ // Multi-select
1229
+ <OwnerLookup multiSelect selectedOwners={owners} onOwnersSelect={setOwners} />
1230
+ ```
1231
+
1232
+ ![OwnerLookup multi-select](assets/screenshot-ownerlookup-multi.png)
1233
+
1234
+ `types` defaults to `['systemuser']`. Pass both and the lookup **grows a header automatically**,
1235
+ letting the user narrow to Users or Teams the way a polymorphic Dynamics lookup does — no extra
1236
+ wiring:
1237
+
1238
+ ```tsx
1239
+ <OwnerLookup types={['systemuser', 'team']} selectedOwner={owner} onOwnerSelect={setOwner} />
1240
+ ```
1241
+
1242
+ ![OwnerLookup users and teams](assets/screenshot-ownerlookup-open.png)
1243
+
1244
+ Users and teams are queried in parallel and merged with users first, matching how the Dynamics owner
1245
+ lookup groups results. One type failing does not lose the other — a caller with no read access to
1246
+ teams still gets users. Users are filtered to enabled interactive accounts, and teams to
1247
+ `teamtype eq 0`: access teams and AAD-managed teams cannot own records, so offering them would
1248
+ produce an unassignable selection.
1249
+
1250
+ Hovering a user shows the full persona contact card; a team shows its description, business unit and
1251
+ administrator. Pass `types={['systemuser']}` for a people-only picker.
1252
+
1253
+ ### OwnerLookup Props
1254
+
1255
+ | Prop | Type | Default | Description |
1256
+ |------|------|---------|-------------|
1257
+ | `selectedOwner` | `OwnerRecord \| null` | - | Selection (controlled) |
1258
+ | `onOwnerSelect` | `(owner \| null) => void` | - | Selection handler |
1259
+ | `multiSelect` | `boolean` | `false` | Render selections as badges |
1260
+ | `selectedOwners` / `onOwnersSelect` | | - | Multi-select selection |
1261
+ | `types` | `OwnerType[]` | `['systemuser']` | Pass both to get the Users/Teams header |
1262
+ | `owners` | `OwnerRecord[]` | - | Supply a roster instead of querying |
1263
+ | `onSearch` | `(text) => Promise<OwnerRecord[]>` | - | Custom search |
1264
+ | `includeDisabled` | `boolean` | `false` | Include disabled user accounts |
1265
+ | `presence` | `Record<string, PresenceBadgeStatus>` | - | Presence keyed by owner id |
1266
+ | `showHoverCard` | `boolean` | `true` | Contact card on results and badges |
1267
+ | `onOwnerClick` | `(owner) => void` | - | Called when a resolved name is clicked |
1268
+
1269
+ ---
1270
+
1271
+ ## Documentation Screenshots
1272
+
1273
+ Component screenshots are generated, not taken by hand:
1274
+
1275
+ ```bash
1276
+ npm run shots # capture everything into assets/
1277
+ npm run shots lookup-open # or just one
1278
+ ```
1279
+
1280
+ `?shot=<id>` on the harness renders a **single populated component** with no surrounding chrome —
1281
+ no header, no tabs, no sibling examples — inside a fixed-width `#shot-frame`. The capture script
1282
+ visits each one and screenshots that element, so the output is already a tight crop at a stable size,
1283
+ with no manual cropping. `?shot=index` lists what is available.
1284
+
1285
+ Components that open a surface get one shot per state (`lookup-rest` / `lookup-open`,
1286
+ `optionset-closed` / `optionset-open` / `optionset-multi`), because a capture script cannot reliably
1287
+ drive a pointer, and those states are what the docs need to show.
1288
+
1289
+ Data-backed components are populated from fixtures rather than a live org: shot mode swaps the
1290
+ library transport via `setWebApiFetch`, so captures never depend on what happens to be in someone's
1291
+ environment and no real customer data reaches the docs. Add or edit shots in
1292
+ [`testHarness/shots/registry.tsx`](testHarness/shots/registry.tsx).
1293
+
1294
+ ---
1295
+
1296
+ ## Web API Client
1297
+
1298
+ The metadata-aware components share one Web API client with a process-wide metadata cache, so two
1299
+ components mounting in the same tick share a single round trip. Metadata is immutable for the life of
1300
+ a page, and failures are not cached.
1301
+
1302
+ ```ts
1303
+ import { setWebApiBaseUrl, setWebApiFetch, getEntityDefinition, clearMetadataCache } from 'fluentui-extended';
1304
+
1305
+ // Standalone / SPA usage - defaults to a relative path, which works inside Dynamics
1306
+ setWebApiBaseUrl('https://contoso.crm.dynamics.com/api/data/v9.2');
1307
+
1308
+ // Supply your own authenticated transport
1309
+ setWebApiFetch((url, init) => authenticatedFetch(url, init));
1310
+
1311
+ const definition = await getEntityDefinition('account'); // EntitySetName, PrimaryIdAttribute, ...
1312
+ ```
1313
+
1314
+ Exports: `webApiGet`, `setWebApiFetch`, `setWebApiBaseUrl`, `getWebApiBaseUrl`, `WebApiError`,
1315
+ `getEntityDefinition`, `getEntityAttributes`, `getEntityOptionSets`, `getAttributeOptions`,
1316
+ `clearMetadataCache`.
1317
+
1318
+ > **Note:** Lookup and QueryBuilder still use their own internal fetch logic and do not yet share
1319
+ > this client.
676
1320
 
677
1321
  ---
678
1322
 
@@ -690,6 +1334,107 @@ This library extends [Microsoft's Fluent UI React v9](https://react.fluentui.dev
690
1334
 
691
1335
  > Version format: `YYYY.M.DD` (e.g., `2026.8.30` = August 30, 2026)
692
1336
 
1337
+ ### Unreleased
1338
+
1339
+ Seven new Dynamics 365 components, plus the shared Web API client they sit on.
1340
+
1341
+ - ✨ **[SystemUserPersona](#systemuserpersona)** — a `systemuser` persona with the contact card a
1342
+ persona shows on a model-driven form. Loads lazily on hover intent; the record photo is addressed
1343
+ by URL rather than selected inline, since `entityimage` is binary and bloats every response.
1344
+ - ✨ **[OwnerLookup](#ownerlookup)** — a preconfigured `Lookup` for `ownerid`, which is polymorphic:
1345
+ an owner is a systemuser *or* a team. Both are searched in parallel, results render as personas or
1346
+ team glyphs, selections show as the usual Lookup badges, and multi-select comes from Lookup.
1347
+ - ✨ **[Lookup hover cards](#hover-cards)** — `showHoverCard` adds a lazy record card to the dropdown
1348
+ rows, the resolved badge, or both. Supply `hoverCardColumns` to have it fetched from the Web API,
1349
+ or `renderHoverCard` to build the body yourself. Nothing loads until a pointer settles.
1350
+ - 🔧 **[Generated documentation screenshots](#documentation-screenshots).** `npm run shots` captures
1351
+ each component in isolation via `?shot=<id>`, populated from fixtures rather than a live org.
1352
+ - ✨ `open` on `OptionSetField` and `RecordHoverCard` for rendering an expanded state without
1353
+ driving a pointer — used by the captures, and useful in tests.
1354
+
1355
+ - 💄 **`filled-darker` is now the default appearance** across every field component — Lookup,
1356
+ QueryBuilder, DateTimeField and OptionSetField — matching native Dynamics 365. Fluent's default is
1357
+ `outline`. **Breaking for anyone relying on the previous outline look**; pass
1358
+ `appearance="outline"` to restore it. See [Appearance](#appearance).
1359
+ - 💄 **Resolved lookups now render like Dynamics at rest**: table icon or entity image, the record
1360
+ name as a link, and a magnifier in place of the chevron. New `entityIcon`, `entityImage`,
1361
+ `recordLinkAppearance` and `onRecordClick` props.
1362
+ - 🐛 **Attribute metadata requests failed against live environments.** `Format` was included in the
1363
+ `$select` against the base `Attributes` collection, but it is declared on derived types — Dynamics
1364
+ rejects the whole request with *"Could not find a property named 'Format' on type
1365
+ 'Microsoft.Dynamics.CRM.AttributeMetadata'"*. `Format` and `DateTimeBehavior` are now fetched
1366
+ through cast segments and merged in, which also means `DateTimeField`'s metadata auto-load works
1367
+ (it could never have resolved a behavior before).
1368
+
1369
+ - ✨ **[CommandBar](#commandbar)** — commands that no longer fit collapse into a "More commands" menu
1370
+ instead of wrapping or being clipped. Widths are measured from the DOM rather than estimated.
1371
+ - ✨ **[EntityGrid](#entitygrid)** — a subgrid with columns named from entity metadata, server-side
1372
+ paging via `Prefer: odata.maxpagesize` and `@odata.nextLink`, server-side sorting, and lookups
1373
+ rendered from their formatted-value annotations rather than as GUIDs.
1374
+ - ✨ **[DateTimeField](#datetimefield)** — respects the attribute's `DateTimeBehavior`, so `DateOnly`
1375
+ and `TimeZoneIndependent` values never pass through UTC and cannot drift a day.
1376
+ - ✨ **[OptionSetField](#optionsetfield)** — optionset and multi-select picklist field that reads
1377
+ global option sets as well as local ones, and round-trips multi-selects as the comma-separated
1378
+ string Dynamics stores.
1379
+ - ✨ **[RecordHoverCard](#recordhovercard)** — lazy record loading gated on hover intent, so dragging
1380
+ a pointer across a grid column does not fire a request per row.
1381
+ - ✨ **[Web API client](#web-api-client)** — one client with a process-wide metadata cache shared by
1382
+ the new components. Promises are cached rather than values, so components mounting in the same tick
1383
+ share a round trip; failures are not cached. Lookup and QueryBuilder are not yet migrated onto it.
1384
+ - 🔧 Test harness split into one tab per component.
1385
+
1386
+ ### 2026.8.40
1387
+
1388
+ QueryBuilder layout and query options.
1389
+
1390
+ - 🐛 **Toolbar was crushed when the query grew.** In a height-constrained parent the header and
1391
+ toolbar were the only flex items able to shrink, so they were compressed and clipped instead of
1392
+ the filter list scrolling. Header and toolbar are now pinned, and the filter groups plus previews
1393
+ scroll together as one region. See [Layout and Scrolling](#layout-and-scrolling).
1394
+ - ✨ **Root `<fetch>` query options.** Generated FetchXML now carries `mapping`, `no-lock` and
1395
+ `distinct`, matching what the Dynamics advanced-find editor emits. `distinct` defaults to `true`.
1396
+ New `distinct`, `noLock` and `top` props override per instance, and options on an imported query
1397
+ are preserved through a serialize round-trip rather than silently dropped.
1398
+ - ✨ Preview cards grow to fit their content instead of scrolling internally.
1399
+ - 💄 Component header now reads "Query Builder: {entity}" rather than "Edit filters: {entity}".
1400
+ - 🔧 Test harness split into **Lookup** and **Query Builder** tabs.
1401
+
1402
+ ### 2026.8.36
1403
+
1404
+ QueryBuilder field-type and FetchXML correctness pass.
1405
+
1406
+ - 🐛 **Every field resolved as `string`.** `dataTypeFromAttribute` compared `AttributeTypeName.Value`
1407
+ (which is suffixed — `MoneyType`, `PicklistType`, `BooleanType`) against unsuffixed names, so only
1408
+ lookups were typed correctly. Money fields offered "Contains", optionsets rendered a text box, and
1409
+ booleans never reached their branch.
1410
+ - 🐛 **Optionset and boolean options were never loaded** for main-entity fields. The component's field
1411
+ loader fetched attributes and lookup targets but no option metadata.
1412
+ - 🐛 **Global option sets returned no options** — only `OptionSet` was expanded, never `GlobalOptionSet`.
1413
+ - 🐛 **Boolean fields** now use their Dynamics labels ("Allowed" / "Not Allowed") instead of hardcoded
1414
+ Yes/No, and match on truthiness so a saved `value="1"` no longer displays as "No".
1415
+ - 🐛 **"Does Not Contain" produced invalid FetchXML** (`operator="not-contain"`, which does not exist).
1416
+ Now serialized as `not-like` with `%` wildcards.
1417
+ - 🐛 **Date picker shifted the day** in UTC+ timezones — `toISOString()` converted local midnight to
1418
+ the previous UTC day.
1419
+ - 🐛 **`IsValidForAdvancedFind` was never requested**, so the filter meant to hide non-filterable
1420
+ attributes did nothing.
1421
+ - 🐛 **"Has Value" left the value box enabled**; no-value operators now disable it correctly.
1422
+ - 🐛 **"Last X Days" rendered a date picker** instead of a number input.
1423
+ - 🐛 **`not-between` and fiscal period-and-year operators had no second value input.**
1424
+ - 🐛 **`link-entity` guessed `from="<entity>id"`**, which is wrong for activity entities
1425
+ (`email`, `task`, `appointment` all use `activityid`). Now uses `PrimaryIdAttribute`.
1426
+ - 🐛 **Invalid OData output.** Untranslatable operators were emitted as a `/* comment */` in the filter
1427
+ string; nested related-entity conditions were silently coerced to `eq`. Both are now omitted.
1428
+ - ✨ **Edit FetchXML** toolbar button — opens the current query as editable FetchXML to tweak or paste
1429
+ over (`showEditFetchXmlButton`).
1430
+ - ✨ **Show/Hide OData and FetchXML** toolbar toggles (`showPreviewToggleButtons`).
1431
+ - ✨ `QueryBuilderApplyResult.odataUnsupported` reports conditions OData cannot express, surfaced as a
1432
+ warning in the OData preview. New `isOperatorConvertibleToOData` helper.
1433
+ - ✨ Option metadata is fetched once per attribute type rather than once per field.
1434
+ - 💄 Softer, more rounded containers matching other Dynamics surfaces.
1435
+ - ⚠️ **Breaking:** `odataUnsupported` is a required field on `QueryBuilderApplyResult`. Consumers only
1436
+ reading the result are unaffected; anyone constructing the type will need to add it.
1437
+
693
1438
  ### 2026.8.30
694
1439
 
695
1440
  - ✨ Added multi-entity filter pattern with drill-down header ("← All" back button) in test harness