fluentui-extended 2026.8.36 → 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 +652 -14
- package/dist/index.d.mts +1154 -2
- package/dist/index.d.ts +1154 -2
- package/dist/index.js +5510 -402
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5476 -404
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -3
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
|
|
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
|
-

|
|
61
|
+
|
|
62
|
+

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

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

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

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

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

|
|
108
|
+
|
|
109
|
+

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

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

|
|
127
|
+
|
|
128
|
+

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

|
|
138
|
+
|
|
139
|
+

|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
| **
|
|
266
|
-
| **
|
|
267
|
-
| **
|
|
268
|
-
| **
|
|
269
|
-
| **
|
|
270
|
-
| **
|
|
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) |
|
|
@@ -627,6 +764,38 @@ const fields: QueryBuilderField[] = [
|
|
|
627
764
|
/>
|
|
628
765
|
```
|
|
629
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
|
+
|
|
630
799
|
### QueryBuilder Props
|
|
631
800
|
|
|
632
801
|
| Prop | Type | Default | Description |
|
|
@@ -636,6 +805,9 @@ const fields: QueryBuilderField[] = [
|
|
|
636
805
|
| `fields` | `QueryBuilderField[]` | - | Fields for filtering (auto-loaded via Web API if omitted) |
|
|
637
806
|
| `initialFetchXml` | `string` | - | FetchXML to pre-populate the query builder |
|
|
638
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 |
|
|
639
811
|
| `onSerializedChange` | `(result: QueryBuilderApplyResult) => void` | - | Called when query changes |
|
|
640
812
|
| `onLookupSearch` | `(fieldId: string, searchText: string) => Promise<LookupOption[]>` | - | Lookup field search handler |
|
|
641
813
|
| `showODataPreview` | `boolean` | `false` | Initial visibility of the OData filter preview |
|
|
@@ -747,6 +919,407 @@ be expressed in OData — see [Queries That OData Cannot Express](#queries-that-
|
|
|
747
919
|
|
|
748
920
|
---
|
|
749
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
|
+

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

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

|
|
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.
|
|
1320
|
+
|
|
1321
|
+
---
|
|
1322
|
+
|
|
750
1323
|
## Acknowledgments
|
|
751
1324
|
|
|
752
1325
|
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.
|
|
@@ -761,6 +1334,71 @@ This library extends [Microsoft's Fluent UI React v9](https://react.fluentui.dev
|
|
|
761
1334
|
|
|
762
1335
|
> Version format: `YYYY.M.DD` (e.g., `2026.8.30` = August 30, 2026)
|
|
763
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
|
+
|
|
764
1402
|
### 2026.8.36
|
|
765
1403
|
|
|
766
1404
|
QueryBuilder field-type and FetchXML correctness pass.
|