@spfx-fast/sharepoint-ui 0.4.1 → 0.4.3
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/AGENTS.md +23 -3
- package/QUICKSTART.md +133 -0
- package/README.md +21 -878
- package/components.json +178 -33
- package/dist/appearance.js +4 -4
- package/dist/elements/alert.js +26 -2
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/carousel.js +5 -2
- package/dist/elements/color-picker.js +1 -1
- package/dist/elements/combobox.js +5 -5
- package/dist/elements/data-grid-model.d.ts +3 -0
- package/dist/elements/data-grid.d.ts +2 -1
- package/dist/elements/data-grid.js +19 -10
- package/dist/elements/date-picker.js +1 -1
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/floating-position.d.ts +10 -0
- package/dist/elements/floating-position.js +53 -0
- package/dist/elements/info.js +1 -1
- package/dist/elements/input.js +2 -2
- package/dist/elements/link.js +1 -1
- package/dist/elements/message-bar.js +33 -5
- package/dist/elements/nav.js +4 -1
- package/dist/elements/overflow.d.ts +1 -0
- package/dist/elements/overflow.js +2 -2
- package/dist/elements/popover.js +1 -1
- package/dist/elements/rating.js +3 -3
- package/dist/elements/rich-text-editor.d.ts +15 -1
- package/dist/elements/rich-text-editor.js +938 -79
- package/dist/elements/search.js +3 -10
- package/dist/elements/select.js +2 -2
- package/dist/elements/shared.d.ts +4 -1
- package/dist/elements/shared.js +14 -1
- package/dist/elements/sharepoint-field-basic-controls.d.ts +1 -0
- package/dist/elements/sharepoint-field-basic-controls.js +14 -0
- package/dist/elements/sharepoint-fields.d.ts +29 -0
- package/dist/elements/sharepoint-fields.js +460 -0
- package/dist/elements/spin-button.js +2 -2
- package/dist/elements/tag-picker.js +29 -11
- package/dist/elements/textarea.js +2 -2
- package/dist/elements/toaster.js +2 -1
- package/dist/elements/tooltip.js +18 -1
- package/dist/elements/tree.js +1 -1
- package/dist/elements.d.ts +3 -2
- package/dist/elements.js +4 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/react.d.ts +188 -19
- package/dist/react.js +321 -61
- package/dist/sharepoint-fields.d.ts +135 -0
- package/dist/sharepoint-fields.js +402 -0
- package/dist/theme.d.ts +9 -1
- package/dist/theme.js +78 -1
- package/package.json +11 -2
- package/skills/sharepoint-ui/SKILL.md +1 -1
- package/skills/sharepoint-ui/references/sharepoint-patterns.md +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
type SharePointBoolean = boolean | string;
|
|
2
|
+
export type SharePointMultiLineField = {
|
|
3
|
+
richText?: SharePointBoolean;
|
|
4
|
+
restrictedMode?: SharePointBoolean;
|
|
5
|
+
richTextMode?: string;
|
|
6
|
+
allowHyperlink?: SharePointBoolean;
|
|
7
|
+
RichText?: SharePointBoolean;
|
|
8
|
+
RestrictedMode?: SharePointBoolean;
|
|
9
|
+
RichTextMode?: string;
|
|
10
|
+
AllowHyperlink?: SharePointBoolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function getRichTextEditorFieldOptions(field: SharePointMultiLineField): {
|
|
13
|
+
contentMode: "plain" | "restricted" | "rich" | "html";
|
|
14
|
+
allowHyperlink: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type SharePointFieldMetadata = SharePointMultiLineField & {
|
|
17
|
+
Title?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
DisplayName?: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
InternalName?: string;
|
|
22
|
+
internalName?: string;
|
|
23
|
+
TypeAsString?: string;
|
|
24
|
+
typeAsString?: string;
|
|
25
|
+
FieldTypeKind?: number;
|
|
26
|
+
fieldTypeKind?: number;
|
|
27
|
+
Description?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
Required?: SharePointBoolean;
|
|
30
|
+
required?: SharePointBoolean;
|
|
31
|
+
ReadOnlyField?: SharePointBoolean;
|
|
32
|
+
readOnlyField?: SharePointBoolean;
|
|
33
|
+
Hidden?: SharePointBoolean;
|
|
34
|
+
hidden?: SharePointBoolean;
|
|
35
|
+
Choices?: readonly string[];
|
|
36
|
+
choices?: readonly string[];
|
|
37
|
+
FillInChoice?: SharePointBoolean;
|
|
38
|
+
fillInChoice?: SharePointBoolean;
|
|
39
|
+
EditFormat?: string | number;
|
|
40
|
+
editFormat?: string | number;
|
|
41
|
+
AllowMultipleValues?: SharePointBoolean;
|
|
42
|
+
allowMultipleValues?: SharePointBoolean;
|
|
43
|
+
DisplayFormat?: string | number;
|
|
44
|
+
displayFormat?: string | number;
|
|
45
|
+
Decimals?: string | number;
|
|
46
|
+
decimals?: string | number;
|
|
47
|
+
MaxLength?: string | number;
|
|
48
|
+
maxLength?: string | number;
|
|
49
|
+
MinimumValue?: string | number;
|
|
50
|
+
minimumValue?: string | number;
|
|
51
|
+
MaximumValue?: string | number;
|
|
52
|
+
maximumValue?: string | number;
|
|
53
|
+
Min?: string | number;
|
|
54
|
+
min?: string | number;
|
|
55
|
+
Max?: string | number;
|
|
56
|
+
max?: string | number;
|
|
57
|
+
LookupList?: string;
|
|
58
|
+
lookupList?: string;
|
|
59
|
+
LookupField?: string;
|
|
60
|
+
lookupField?: string;
|
|
61
|
+
LookupWebId?: string;
|
|
62
|
+
lookupWebId?: string;
|
|
63
|
+
};
|
|
64
|
+
export type SharePointFieldControl = "input" | "textarea" | "rich-text-editor" | "select" | "radio-group" | "checkbox" | "multi-choice" | "date-picker" | "combobox" | "read-only" | "unsupported";
|
|
65
|
+
export type SharePointFieldAdapter = {
|
|
66
|
+
control: SharePointFieldControl;
|
|
67
|
+
fieldType: string;
|
|
68
|
+
name: string;
|
|
69
|
+
label: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
required: boolean;
|
|
72
|
+
readOnly: boolean;
|
|
73
|
+
hidden: boolean;
|
|
74
|
+
multiple?: boolean;
|
|
75
|
+
inputType?: "text" | "number" | "url" | "datetime-local";
|
|
76
|
+
urlFormat?: "hyperlink" | "picture";
|
|
77
|
+
step?: number | "any";
|
|
78
|
+
min?: number;
|
|
79
|
+
max?: number;
|
|
80
|
+
maxLength?: number;
|
|
81
|
+
dateMode?: "date" | "date-time";
|
|
82
|
+
choices?: readonly string[];
|
|
83
|
+
allowFillIn?: boolean;
|
|
84
|
+
richText?: ReturnType<typeof getRichTextEditorFieldOptions>;
|
|
85
|
+
lookupSource?: {
|
|
86
|
+
listId: string;
|
|
87
|
+
displayField: string;
|
|
88
|
+
webId?: string;
|
|
89
|
+
};
|
|
90
|
+
unsupportedReason?: string;
|
|
91
|
+
};
|
|
92
|
+
export type SharePointLookupId = number | string;
|
|
93
|
+
export type SharePointLookupValue = SharePointLookupId | readonly SharePointLookupId[] | null;
|
|
94
|
+
export type SharePointLookupUpdate = Record<string, number | null | {
|
|
95
|
+
results: number[];
|
|
96
|
+
}>;
|
|
97
|
+
export type SharePointChoiceValue = string | readonly string[] | null;
|
|
98
|
+
export type SharePointChoiceUpdate = Record<string, string | null | {
|
|
99
|
+
results: string[];
|
|
100
|
+
}>;
|
|
101
|
+
export type SharePointUrlValue = {
|
|
102
|
+
url: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
};
|
|
105
|
+
export type SharePointFieldValue = string | number | boolean | Date | SharePointUrlValue | readonly string[] | readonly SharePointLookupId[] | null;
|
|
106
|
+
export type SharePointFieldValues = Readonly<Record<string, SharePointFieldValue | undefined>>;
|
|
107
|
+
export type SharePointFieldUpdateValue = string | number | boolean | null | {
|
|
108
|
+
results: Array<string | number>;
|
|
109
|
+
} | {
|
|
110
|
+
__metadata: {
|
|
111
|
+
type: "SP.FieldUrlValue";
|
|
112
|
+
};
|
|
113
|
+
Url: string;
|
|
114
|
+
Description: string;
|
|
115
|
+
};
|
|
116
|
+
export type SharePointFieldUpdate = Record<string, SharePointFieldUpdateValue>;
|
|
117
|
+
/** Map SharePoint REST/CSOM field metadata to a control recipe without importing any UI component code. */
|
|
118
|
+
export declare function getSharePointFieldAdapter(field: SharePointFieldMetadata): SharePointFieldAdapter;
|
|
119
|
+
/** Build a bounded REST request for a list's field definitions and their type-specific metadata. */
|
|
120
|
+
export declare function getSharePointFieldMetadataUrl(listWebUrl: string, listId: string, pageSize?: number): string;
|
|
121
|
+
/** Build the REST endpoint for a single item in a SharePoint list. */
|
|
122
|
+
export declare function getSharePointListItemUrl(webUrl: string, listId: string, itemId: number): string;
|
|
123
|
+
/** Build a paged REST URL for options in a lookup field's source list. Pass that list's web URL. */
|
|
124
|
+
export declare function getSharePointLookupOptionsUrl(field: SharePointFieldMetadata, lookupWebUrl: string, pageSize?: number): string;
|
|
125
|
+
/** Resolve a SharePoint OData next link and reject links that would send host credentials to another origin. */
|
|
126
|
+
export declare function getSharePointNextPageUrl(webUrl: string, nextLink?: string | null): string | null;
|
|
127
|
+
/** Serialize selected SharePoint lookup/person IDs for an item create or update body. */
|
|
128
|
+
export declare function serializeSharePointLookupValue(field: SharePointFieldMetadata, value: SharePointLookupValue): SharePointLookupUpdate;
|
|
129
|
+
/** Serialize SharePoint Choice and MultiChoice values for an item create or update body. */
|
|
130
|
+
export declare function serializeSharePointChoiceValue(field: SharePointFieldMetadata, value: SharePointChoiceValue): SharePointChoiceUpdate;
|
|
131
|
+
/** Serialize a common SharePoint list-field value into a REST item create/update body. */
|
|
132
|
+
export declare function serializeSharePointFieldValue(field: SharePointFieldMetadata, value: SharePointFieldValue): SharePointFieldUpdate;
|
|
133
|
+
/** Build one SharePoint item patch from values keyed by field InternalName; omitted values stay untouched. */
|
|
134
|
+
export declare function serializeSharePointFieldValues(fields: readonly SharePointFieldMetadata[], values: SharePointFieldValues): SharePointFieldUpdate;
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
export function getRichTextEditorFieldOptions(field) {
|
|
2
|
+
const asBoolean = (value) => value === undefined ? undefined : value === true || (typeof value === "string" && value.trim().toLowerCase() === "true");
|
|
3
|
+
const richText = asBoolean(field.RichText ?? field.richText);
|
|
4
|
+
const restricted = asBoolean(field.RestrictedMode ?? field.restrictedMode) ?? false;
|
|
5
|
+
const mode = (field.RichTextMode ?? field.richTextMode)?.trim().toLowerCase();
|
|
6
|
+
const contentMode = richText === false ? "plain" : restricted ? "restricted" : mode === "fullhtml" ? "html" : mode === "compatible" || richText === true ? "rich" : "html";
|
|
7
|
+
const fieldAllowsHyperlinks = asBoolean(field.AllowHyperlink ?? field.allowHyperlink);
|
|
8
|
+
// Missing field metadata leaves safe, sanitized links enabled; an explicit SharePoint prohibition always wins.
|
|
9
|
+
return {
|
|
10
|
+
contentMode,
|
|
11
|
+
allowHyperlink: contentMode !== "plain" && contentMode !== "restricted" && fieldAllowsHyperlinks !== false
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const booleanValue = (value) => value === true || (typeof value === "string" && value.trim().toLowerCase() === "true");
|
|
15
|
+
const fieldTypeNames = {
|
|
16
|
+
1: "Integer", 2: "Text", 3: "Note", 4: "DateTime", 5: "Counter", 6: "Choice", 7: "Lookup", 8: "Boolean",
|
|
17
|
+
9: "Number", 10: "Currency", 11: "URL", 12: "Computed", 15: "MultiChoice", 17: "Calculated", 20: "User", 32: "OutcomeChoice"
|
|
18
|
+
};
|
|
19
|
+
/** Map SharePoint REST/CSOM field metadata to a control recipe without importing any UI component code. */
|
|
20
|
+
export function getSharePointFieldAdapter(field) {
|
|
21
|
+
const fieldTypeKind = field.FieldTypeKind ?? field.fieldTypeKind;
|
|
22
|
+
const kind = field.TypeAsString ?? field.typeAsString ?? (fieldTypeKind == null ? "Unknown" : fieldTypeNames[fieldTypeKind] ?? `FieldTypeKind:${fieldTypeKind}`);
|
|
23
|
+
const normalized = kind.replace(/^SP\.Field/, "").toLowerCase();
|
|
24
|
+
const name = field.InternalName ?? field.internalName ?? "";
|
|
25
|
+
const adapter = {
|
|
26
|
+
control: "unsupported",
|
|
27
|
+
fieldType: kind,
|
|
28
|
+
name,
|
|
29
|
+
label: field.DisplayName ?? field.displayName ?? field.Title ?? field.title ?? name,
|
|
30
|
+
description: field.Description ?? field.description,
|
|
31
|
+
required: booleanValue(field.Required ?? field.required),
|
|
32
|
+
readOnly: booleanValue(field.ReadOnlyField ?? field.readOnlyField),
|
|
33
|
+
hidden: booleanValue(field.Hidden ?? field.hidden)
|
|
34
|
+
};
|
|
35
|
+
const finish = (control) => ({ ...adapter, control });
|
|
36
|
+
if (["text", "integer", "number", "currency", "url"].includes(normalized)) {
|
|
37
|
+
const control = finish("input");
|
|
38
|
+
control.inputType = normalized === "url" ? "url" : ["integer", "number", "currency"].includes(normalized) ? "number" : "text";
|
|
39
|
+
if (normalized === "url") {
|
|
40
|
+
const displayFormat = String(field.DisplayFormat ?? field.displayFormat ?? "0").trim().toLowerCase();
|
|
41
|
+
control.urlFormat = displayFormat === "1" || displayFormat === "image" || displayFormat === "picture" ? "picture" : "hyperlink";
|
|
42
|
+
}
|
|
43
|
+
control.maxLength = getConfiguredMaxLength(field);
|
|
44
|
+
if (control.inputType === "number") {
|
|
45
|
+
const decimalPlaces = getConfiguredDecimalPlaces(field);
|
|
46
|
+
control.step = normalized === "integer" || decimalPlaces === 0 ? 1 : decimalPlaces == null ? "any" : 10 ** -decimalPlaces;
|
|
47
|
+
control.min = getConfiguredNumber(field.MinimumValue ?? field.minimumValue ?? field.Min ?? field.min);
|
|
48
|
+
control.max = getConfiguredNumber(field.MaximumValue ?? field.maximumValue ?? field.Max ?? field.max);
|
|
49
|
+
}
|
|
50
|
+
return control;
|
|
51
|
+
}
|
|
52
|
+
if (normalized === "note") {
|
|
53
|
+
const richText = getRichTextEditorFieldOptions(field);
|
|
54
|
+
return richText.contentMode === "plain" ? finish("textarea") : { ...finish("rich-text-editor"), richText };
|
|
55
|
+
}
|
|
56
|
+
if (normalized === "choice" || normalized === "outcomechoice") {
|
|
57
|
+
const format = field.EditFormat ?? field.editFormat;
|
|
58
|
+
return {
|
|
59
|
+
...finish(format === 1 || String(format).toLowerCase() === "radiobuttons" ? "radio-group" : "select"),
|
|
60
|
+
choices: field.Choices ?? field.choices ?? [],
|
|
61
|
+
allowFillIn: booleanValue(field.FillInChoice ?? field.fillInChoice)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (normalized === "multichoice") {
|
|
65
|
+
return { ...finish("multi-choice"), choices: field.Choices ?? field.choices ?? [], allowFillIn: booleanValue(field.FillInChoice ?? field.fillInChoice), multiple: true };
|
|
66
|
+
}
|
|
67
|
+
if (normalized === "boolean")
|
|
68
|
+
return finish("checkbox");
|
|
69
|
+
if (normalized === "datetime") {
|
|
70
|
+
const displayFormat = field.DisplayFormat ?? field.displayFormat;
|
|
71
|
+
const dateMode = displayFormat === 1 || String(displayFormat).toLowerCase() === "1" || String(displayFormat).toLowerCase() === "datetime" ? "date-time" : "date";
|
|
72
|
+
return dateMode === "date-time"
|
|
73
|
+
? { ...finish("input"), inputType: "datetime-local", dateMode }
|
|
74
|
+
: { ...finish("date-picker"), dateMode };
|
|
75
|
+
}
|
|
76
|
+
if (["lookup", "lookupmulti", "user", "usermulti"].includes(normalized)) {
|
|
77
|
+
const lookupList = field.LookupList ?? field.lookupList;
|
|
78
|
+
const lookupField = field.LookupField ?? field.lookupField;
|
|
79
|
+
const lookupSource = normalized.startsWith("lookup") && lookupList && lookupField
|
|
80
|
+
? { listId: lookupList, displayField: lookupField, webId: field.LookupWebId ?? field.lookupWebId }
|
|
81
|
+
: undefined;
|
|
82
|
+
return { ...finish("combobox"), multiple: normalized.endsWith("multi") || booleanValue(field.AllowMultipleValues ?? field.allowMultipleValues), lookupSource };
|
|
83
|
+
}
|
|
84
|
+
if (["counter", "computed", "calculated"].includes(normalized))
|
|
85
|
+
return { ...finish("read-only"), readOnly: true };
|
|
86
|
+
return { ...adapter, unsupportedReason: "This SharePoint field type needs a specialized data source or renderer." };
|
|
87
|
+
}
|
|
88
|
+
/** Build a bounded REST request for a list's field definitions and their type-specific metadata. */
|
|
89
|
+
export function getSharePointFieldMetadataUrl(listWebUrl, listId, pageSize = 100) {
|
|
90
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 500)
|
|
91
|
+
throw new RangeError("pageSize must be an integer from 1 to 500.");
|
|
92
|
+
const normalizedListId = normalizeSharePointListId(listId, "listId must be a SharePoint list GUID.");
|
|
93
|
+
const url = parseSharePointWebUrl(listWebUrl, "listWebUrl");
|
|
94
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/_api/web/lists(guid'${normalizedListId}')/fields`;
|
|
95
|
+
url.search = new URLSearchParams({ "$select": "*", "$top": String(pageSize) }).toString();
|
|
96
|
+
url.hash = "";
|
|
97
|
+
return url.toString();
|
|
98
|
+
}
|
|
99
|
+
/** Build the REST endpoint for a single item in a SharePoint list. */
|
|
100
|
+
export function getSharePointListItemUrl(webUrl, listId, itemId) {
|
|
101
|
+
if (!Number.isSafeInteger(itemId) || itemId < 1)
|
|
102
|
+
throw new RangeError("itemId must be a positive safe integer.");
|
|
103
|
+
const normalizedListId = normalizeSharePointListId(listId, "listId must be a SharePoint list GUID.");
|
|
104
|
+
const url = parseSharePointWebUrl(webUrl, "webUrl");
|
|
105
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/_api/web/lists(guid'${normalizedListId}')/items(${itemId})`;
|
|
106
|
+
url.search = "";
|
|
107
|
+
url.hash = "";
|
|
108
|
+
return url.toString();
|
|
109
|
+
}
|
|
110
|
+
/** Build a paged REST URL for options in a lookup field's source list. Pass that list's web URL. */
|
|
111
|
+
export function getSharePointLookupOptionsUrl(field, lookupWebUrl, pageSize = 100) {
|
|
112
|
+
const adapter = getSharePointFieldAdapter(field);
|
|
113
|
+
const source = adapter.lookupSource;
|
|
114
|
+
if (adapter.control !== "combobox" || !source)
|
|
115
|
+
throw new TypeError("This field does not include SharePoint lookup-list metadata.");
|
|
116
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 500)
|
|
117
|
+
throw new RangeError("pageSize must be an integer from 1 to 500.");
|
|
118
|
+
const listId = normalizeSharePointListId(source.listId, "LookupList must contain a SharePoint list GUID.");
|
|
119
|
+
if (!/^[A-Za-z_][A-Za-z\d_]*$/.test(source.displayField))
|
|
120
|
+
throw new TypeError("LookupField must be a SharePoint internal field name.");
|
|
121
|
+
const url = parseSharePointWebUrl(lookupWebUrl, "lookupWebUrl");
|
|
122
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/_api/web/lists(guid'${listId}')/items`;
|
|
123
|
+
url.search = new URLSearchParams({
|
|
124
|
+
"$select": `Id,${source.displayField}`,
|
|
125
|
+
"$orderby": `${source.displayField} asc`,
|
|
126
|
+
"$top": String(pageSize)
|
|
127
|
+
}).toString();
|
|
128
|
+
url.hash = "";
|
|
129
|
+
return url.toString();
|
|
130
|
+
}
|
|
131
|
+
/** Resolve a SharePoint OData next link and reject links that would send host credentials to another origin. */
|
|
132
|
+
export function getSharePointNextPageUrl(webUrl, nextLink) {
|
|
133
|
+
if (nextLink == null)
|
|
134
|
+
return null;
|
|
135
|
+
if (typeof nextLink !== "string")
|
|
136
|
+
throw new TypeError("nextLink must be a URL string or null.");
|
|
137
|
+
if (nextLink.trim() === "")
|
|
138
|
+
return null;
|
|
139
|
+
const base = new URL(webUrl);
|
|
140
|
+
if (!/^https?:$/.test(base.protocol) || base.username || base.password)
|
|
141
|
+
throw new TypeError("webUrl must be an HTTP(S) SharePoint web URL.");
|
|
142
|
+
const next = new URL(nextLink, base);
|
|
143
|
+
if (!/^https?:$/.test(next.protocol) || next.origin !== base.origin || next.username || next.password) {
|
|
144
|
+
throw new TypeError("SharePoint next links must resolve to the same HTTP(S) origin as webUrl.");
|
|
145
|
+
}
|
|
146
|
+
next.hash = "";
|
|
147
|
+
return next.toString();
|
|
148
|
+
}
|
|
149
|
+
/** Serialize selected SharePoint lookup/person IDs for an item create or update body. */
|
|
150
|
+
export function serializeSharePointLookupValue(field, value) {
|
|
151
|
+
const adapter = getSharePointFieldAdapter(field);
|
|
152
|
+
const type = adapter.fieldType.replace(/^SP\.Field/, "").toLowerCase();
|
|
153
|
+
if (adapter.control !== "combobox" || !["lookup", "lookupmulti", "user", "usermulti"].includes(type)) {
|
|
154
|
+
throw new TypeError("Only SharePoint Lookup and User fields can be serialized as ID selections.");
|
|
155
|
+
}
|
|
156
|
+
if (!adapter.name)
|
|
157
|
+
throw new TypeError("The SharePoint field must include InternalName.");
|
|
158
|
+
assertSharePointFieldValueWritable(adapter, value);
|
|
159
|
+
const key = `${adapter.name}Id`;
|
|
160
|
+
if (adapter.multiple) {
|
|
161
|
+
if (value == null)
|
|
162
|
+
return { [key]: { results: [] } };
|
|
163
|
+
if (!isLookupIdArray(value))
|
|
164
|
+
throw new TypeError("A multi-value field requires an array of selected IDs.");
|
|
165
|
+
return { [key]: { results: [...new Set(value.map(parseLookupId))] } };
|
|
166
|
+
}
|
|
167
|
+
if (isLookupIdArray(value))
|
|
168
|
+
throw new TypeError("A single-value field accepts one selected ID or null.");
|
|
169
|
+
return { [key]: value == null || value === "" ? null : parseLookupId(value) };
|
|
170
|
+
}
|
|
171
|
+
/** Serialize SharePoint Choice and MultiChoice values for an item create or update body. */
|
|
172
|
+
export function serializeSharePointChoiceValue(field, value) {
|
|
173
|
+
const adapter = getSharePointFieldAdapter(field);
|
|
174
|
+
const type = adapter.fieldType.replace(/^SP\.Field/, "").toLowerCase();
|
|
175
|
+
const multiple = type === "multichoice";
|
|
176
|
+
if (!["choice", "outcomechoice", "multichoice"].includes(type)) {
|
|
177
|
+
throw new TypeError("Only SharePoint Choice and MultiChoice fields can be serialized as choice values.");
|
|
178
|
+
}
|
|
179
|
+
if (!adapter.name)
|
|
180
|
+
throw new TypeError("The SharePoint field must include InternalName.");
|
|
181
|
+
assertSharePointFieldValueWritable(adapter, value);
|
|
182
|
+
const choices = field.Choices ?? field.choices ?? [];
|
|
183
|
+
const allowFillIn = booleanValue(field.FillInChoice ?? field.fillInChoice);
|
|
184
|
+
const validateChoice = (choice) => {
|
|
185
|
+
if (choices.length && !choices.includes(choice) && !allowFillIn)
|
|
186
|
+
throw new RangeError(`“${choice}” is not an allowed value for ${adapter.label}.`);
|
|
187
|
+
return choice;
|
|
188
|
+
};
|
|
189
|
+
if (multiple) {
|
|
190
|
+
if (value == null)
|
|
191
|
+
return { [adapter.name]: { results: [] } };
|
|
192
|
+
if (!isChoiceValueArray(value))
|
|
193
|
+
throw new TypeError("A MultiChoice field requires an array of selected values.");
|
|
194
|
+
return { [adapter.name]: { results: [...new Set(value.map(validateChoice))] } };
|
|
195
|
+
}
|
|
196
|
+
if (isChoiceValueArray(value))
|
|
197
|
+
throw new TypeError("A Choice field accepts one value or null.");
|
|
198
|
+
return { [adapter.name]: value == null || value === "" ? null : validateChoice(value) };
|
|
199
|
+
}
|
|
200
|
+
/** Serialize a common SharePoint list-field value into a REST item create/update body. */
|
|
201
|
+
export function serializeSharePointFieldValue(field, value) {
|
|
202
|
+
const adapter = getSharePointFieldAdapter(field);
|
|
203
|
+
const type = adapter.fieldType.replace(/^SP\.Field/, "").toLowerCase();
|
|
204
|
+
if (!adapter.name)
|
|
205
|
+
throw new TypeError("The SharePoint field must include InternalName.");
|
|
206
|
+
if (["lookup", "lookupmulti", "user", "usermulti"].includes(type)) {
|
|
207
|
+
return serializeSharePointLookupValue(field, value);
|
|
208
|
+
}
|
|
209
|
+
if (["choice", "outcomechoice", "multichoice"].includes(type)) {
|
|
210
|
+
return serializeSharePointChoiceValue(field, value);
|
|
211
|
+
}
|
|
212
|
+
assertSharePointFieldValueWritable(adapter, value);
|
|
213
|
+
if (["text", "note"].includes(type)) {
|
|
214
|
+
if (value != null && typeof value !== "string")
|
|
215
|
+
throw new TypeError(`${adapter.label} requires a string value or null.`);
|
|
216
|
+
const maxLength = getConfiguredMaxLength(field);
|
|
217
|
+
if (typeof value === "string" && maxLength != null && value.length > maxLength)
|
|
218
|
+
throw new RangeError(`${adapter.label} allows at most ${maxLength} characters.`);
|
|
219
|
+
return { [adapter.name]: value };
|
|
220
|
+
}
|
|
221
|
+
if (type === "boolean") {
|
|
222
|
+
if (value != null && typeof value !== "boolean")
|
|
223
|
+
throw new TypeError(`${adapter.label} requires a boolean value or null.`);
|
|
224
|
+
return { [adapter.name]: value };
|
|
225
|
+
}
|
|
226
|
+
if (["integer", "number", "currency"].includes(type)) {
|
|
227
|
+
if (value == null || value === "")
|
|
228
|
+
return { [adapter.name]: null };
|
|
229
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" && /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value.trim()) ? Number(value) : NaN;
|
|
230
|
+
if (!Number.isFinite(numeric) || (type === "integer" && !Number.isSafeInteger(numeric))) {
|
|
231
|
+
throw new TypeError(`${adapter.label} requires a ${type === "integer" ? "safe integer" : "finite number"}, numeric string, or null.`);
|
|
232
|
+
}
|
|
233
|
+
const allowedDecimals = type === "integer" ? 0 : getConfiguredDecimalPlaces(field);
|
|
234
|
+
const precisionSource = typeof value === "string" || typeof value === "number" ? value : numeric;
|
|
235
|
+
if (allowedDecimals != null && getNumericDecimalPlaces(precisionSource) > allowedDecimals) {
|
|
236
|
+
throw new RangeError(`${adapter.label} allows at most ${allowedDecimals} decimal places.`);
|
|
237
|
+
}
|
|
238
|
+
const minimum = getConfiguredNumber(field.MinimumValue ?? field.minimumValue ?? field.Min ?? field.min);
|
|
239
|
+
const maximum = getConfiguredNumber(field.MaximumValue ?? field.maximumValue ?? field.Max ?? field.max);
|
|
240
|
+
if (minimum != null && numeric < minimum)
|
|
241
|
+
throw new RangeError(`${adapter.label} must be at least ${minimum}.`);
|
|
242
|
+
if (maximum != null && numeric > maximum)
|
|
243
|
+
throw new RangeError(`${adapter.label} must be at most ${maximum}.`);
|
|
244
|
+
return { [adapter.name]: numeric };
|
|
245
|
+
}
|
|
246
|
+
if (type === "datetime") {
|
|
247
|
+
if (value == null || value === "")
|
|
248
|
+
return { [adapter.name]: null };
|
|
249
|
+
if (value instanceof Date) {
|
|
250
|
+
if (!Number.isFinite(value.getTime()))
|
|
251
|
+
throw new TypeError(`${adapter.label} requires a valid date, ISO date string, or null.`);
|
|
252
|
+
return { [adapter.name]: value.toISOString() };
|
|
253
|
+
}
|
|
254
|
+
if (typeof value !== "string")
|
|
255
|
+
throw new TypeError(`${adapter.label} requires a valid date, ISO date string, or null.`);
|
|
256
|
+
const iso = serializeSharePointDateValue(value);
|
|
257
|
+
if (!iso)
|
|
258
|
+
throw new TypeError(`${adapter.label} requires a valid ISO date or date-time string.`);
|
|
259
|
+
return { [adapter.name]: iso };
|
|
260
|
+
}
|
|
261
|
+
if (type === "url") {
|
|
262
|
+
if (value == null || value === "")
|
|
263
|
+
return { [adapter.name]: null };
|
|
264
|
+
const link = typeof value === "string" ? { url: value, description: value.trim() } : isSharePointUrlValue(value) ? value : undefined;
|
|
265
|
+
if (!link)
|
|
266
|
+
throw new TypeError(`${adapter.label} requires a URL string, URL value, or null.`);
|
|
267
|
+
const url = link.url.trim();
|
|
268
|
+
const maxLength = Math.min(255, getConfiguredMaxLength(field) ?? 255);
|
|
269
|
+
if (url.length > maxLength)
|
|
270
|
+
throw new RangeError(`${adapter.label} URL cannot exceed ${maxLength} characters.`);
|
|
271
|
+
if (url && !isSharePointFieldUrl(url))
|
|
272
|
+
throw new TypeError(`${adapter.label} must be an HTTP(S) or server-relative URL.`);
|
|
273
|
+
const description = link.description ?? url;
|
|
274
|
+
if (typeof description !== "string")
|
|
275
|
+
throw new TypeError(`${adapter.label} URL description must be a string.`);
|
|
276
|
+
if (description.length > 255)
|
|
277
|
+
throw new RangeError(`${adapter.label} URL description cannot exceed 255 characters.`);
|
|
278
|
+
return { [adapter.name]: { __metadata: { type: "SP.FieldUrlValue" }, Url: url, Description: description } };
|
|
279
|
+
}
|
|
280
|
+
throw new TypeError(adapter.unsupportedReason ?? `${adapter.fieldType} fields cannot be serialized by this helper.`);
|
|
281
|
+
}
|
|
282
|
+
/** Build one SharePoint item patch from values keyed by field InternalName; omitted values stay untouched. */
|
|
283
|
+
export function serializeSharePointFieldValues(fields, values) {
|
|
284
|
+
const update = {};
|
|
285
|
+
const fieldsByName = new Map();
|
|
286
|
+
for (const field of fields) {
|
|
287
|
+
const adapter = getSharePointFieldAdapter(field);
|
|
288
|
+
if (!adapter.name)
|
|
289
|
+
throw new TypeError("Every SharePoint field must include InternalName.");
|
|
290
|
+
if (fieldsByName.has(adapter.name))
|
|
291
|
+
throw new TypeError(`Duplicate SharePoint field InternalName: ${adapter.name}.`);
|
|
292
|
+
fieldsByName.set(adapter.name, { field, adapter });
|
|
293
|
+
}
|
|
294
|
+
for (const [name, value] of Object.entries(values)) {
|
|
295
|
+
const entry = fieldsByName.get(name);
|
|
296
|
+
if (!entry)
|
|
297
|
+
throw new TypeError(`No SharePoint field metadata was supplied for ${name}.`);
|
|
298
|
+
const { field, adapter } = entry;
|
|
299
|
+
if (value === undefined) {
|
|
300
|
+
if (adapter.required)
|
|
301
|
+
throw new TypeError(`${adapter.label} is required.`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const [name, serializedValue] of Object.entries(serializeSharePointFieldValue(field, value))) {
|
|
305
|
+
if (Object.prototype.hasOwnProperty.call(update, name))
|
|
306
|
+
throw new TypeError(`Multiple SharePoint fields map to update property ${name}.`);
|
|
307
|
+
Object.defineProperty(update, name, { configurable: true, enumerable: true, value: serializedValue, writable: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return update;
|
|
311
|
+
}
|
|
312
|
+
function isLookupIdArray(value) {
|
|
313
|
+
return Array.isArray(value);
|
|
314
|
+
}
|
|
315
|
+
function isChoiceValueArray(value) {
|
|
316
|
+
return Array.isArray(value);
|
|
317
|
+
}
|
|
318
|
+
function isSharePointFieldUrl(value) {
|
|
319
|
+
if (value.startsWith("/") && !value.startsWith("//"))
|
|
320
|
+
return true;
|
|
321
|
+
try {
|
|
322
|
+
const url = new URL(value);
|
|
323
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function normalizeSharePointListId(value, errorMessage) {
|
|
330
|
+
const normalized = value.trim().replace(/[{}]/g, "");
|
|
331
|
+
if (!/^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(normalized))
|
|
332
|
+
throw new TypeError(errorMessage);
|
|
333
|
+
return normalized;
|
|
334
|
+
}
|
|
335
|
+
function parseSharePointWebUrl(value, label) {
|
|
336
|
+
const url = new URL(value);
|
|
337
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password)
|
|
338
|
+
throw new TypeError(`${label} must be an HTTP(S) SharePoint web URL.`);
|
|
339
|
+
return url;
|
|
340
|
+
}
|
|
341
|
+
function isSharePointUrlValue(value) {
|
|
342
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value.url === "string";
|
|
343
|
+
}
|
|
344
|
+
function isEmptySharePointFieldValue(value) {
|
|
345
|
+
if (value == null)
|
|
346
|
+
return true;
|
|
347
|
+
if (typeof value === "string")
|
|
348
|
+
return value.trim() === "";
|
|
349
|
+
if (Array.isArray(value))
|
|
350
|
+
return value.length === 0;
|
|
351
|
+
return isSharePointUrlValue(value) && value.url.trim() === "";
|
|
352
|
+
}
|
|
353
|
+
function assertSharePointFieldValueWritable(adapter, value) {
|
|
354
|
+
if (adapter.readOnly)
|
|
355
|
+
throw new TypeError(`${adapter.label} is read-only and cannot be serialized for an item update.`);
|
|
356
|
+
if (adapter.required && isEmptySharePointFieldValue(value))
|
|
357
|
+
throw new TypeError(`${adapter.label} is required.`);
|
|
358
|
+
}
|
|
359
|
+
function serializeSharePointDateValue(value) {
|
|
360
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
361
|
+
const dateTime = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
|
|
362
|
+
const match = dateOnly ?? dateTime;
|
|
363
|
+
if (!match)
|
|
364
|
+
return undefined;
|
|
365
|
+
const day = new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00.000Z`);
|
|
366
|
+
if (!Number.isFinite(day.getTime()) || day.toISOString().slice(0, 10) !== `${match[1]}-${match[2]}-${match[3]}`)
|
|
367
|
+
return undefined;
|
|
368
|
+
const parsed = new Date(value);
|
|
369
|
+
if (!Number.isFinite(parsed.getTime()))
|
|
370
|
+
return undefined;
|
|
371
|
+
if (dateOnly || dateTime?.[4])
|
|
372
|
+
return value;
|
|
373
|
+
return parsed.toISOString();
|
|
374
|
+
}
|
|
375
|
+
function getConfiguredDecimalPlaces(field) {
|
|
376
|
+
const decimals = field.Decimals ?? field.decimals;
|
|
377
|
+
const value = typeof decimals === "number" ? decimals : typeof decimals === "string" && /^\d+$/.test(decimals.trim()) ? Number(decimals) : undefined;
|
|
378
|
+
return value != null && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
379
|
+
}
|
|
380
|
+
function getConfiguredMaxLength(field) {
|
|
381
|
+
const value = field.MaxLength ?? field.maxLength;
|
|
382
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value) : undefined;
|
|
383
|
+
return parsed != null && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
384
|
+
}
|
|
385
|
+
function getConfiguredNumber(value) {
|
|
386
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value.trim()) ? Number(value.trim()) : undefined;
|
|
387
|
+
return parsed != null && Number.isFinite(parsed) ? parsed : undefined;
|
|
388
|
+
}
|
|
389
|
+
function getNumericDecimalPlaces(value) {
|
|
390
|
+
const [coefficient, exponentText] = String(value).trim().toLowerCase().split("e");
|
|
391
|
+
const [integer = "", fraction = ""] = coefficient.split(".");
|
|
392
|
+
const exponent = Number(exponentText ?? 0);
|
|
393
|
+
const significantFractionLength = fraction.replace(/0+$/, "").length;
|
|
394
|
+
const removableIntegerZeros = exponent < 0 ? integer.match(/0+$/)?.[0].length ?? 0 : 0;
|
|
395
|
+
return Math.max(0, significantFractionLength - exponent - removableIntegerZeros);
|
|
396
|
+
}
|
|
397
|
+
function parseLookupId(value) {
|
|
398
|
+
const id = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value;
|
|
399
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 1)
|
|
400
|
+
throw new RangeError("SharePoint lookup IDs must be positive safe integers.");
|
|
401
|
+
return id;
|
|
402
|
+
}
|
package/dist/theme.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type SharePointUIDesignTheme = {
|
|
|
17
17
|
palette?: Record<string, string>;
|
|
18
18
|
/** Any Fluent v9 theme values, exposed under their native CSS variable names and composable with the other design-pack groups. */
|
|
19
19
|
fluentTokens?: FluentThemeTokens;
|
|
20
|
-
/** Brand
|
|
20
|
+
/** Brand seed for an accent scale and softly tinted surfaces. Hex values also get an AA-aware on-accent foreground; other CSS colors can set colors.accentForeground explicitly. */
|
|
21
21
|
brandColor?: string;
|
|
22
22
|
/** App- or component-specific CSS custom properties, merged after semantic roles and before direct provider tokens. */
|
|
23
23
|
customProperties?: Record<`--${string}`, string | number>;
|
|
@@ -156,6 +156,14 @@ export type SharePointUIDesignTheme = {
|
|
|
156
156
|
toast?: string | number;
|
|
157
157
|
};
|
|
158
158
|
};
|
|
159
|
+
export type SharePointThemeColorPreset = {
|
|
160
|
+
name: string;
|
|
161
|
+
value: string;
|
|
162
|
+
color: string;
|
|
163
|
+
label: string;
|
|
164
|
+
};
|
|
165
|
+
/** Build named swatches from the current SharePoint palette; custom tenant keys are preserved. */
|
|
166
|
+
export declare function getSharePointThemeColorPresets(palette?: Readonly<Record<string, string | undefined>>, labelForKey?: (key: string) => string): SharePointThemeColorPreset[];
|
|
159
167
|
/** Compose reusable theme packs left-to-right; later packs override earlier values within each group. */
|
|
160
168
|
export declare function composeSharePointUIDesignThemes(...themes: Array<SharePointUIDesignTheme | undefined>): SharePointUIDesignTheme;
|
|
161
169
|
export declare function getSharePointUIDesignTokens(theme?: SharePointUIDesignTheme): Record<string, string>;
|