

Last date modified: August 21 2025
Custom pages are used to tailor the look and feel of applications that you develop on the Relativity platform. You can develop your own cascading style sheets (CSS), JavaScript, HTML, and images for your custom pages.
In this lesson, you will learn how to complete these tasks:
Estimated completion time - 3 hours
You start creating a simple custom page by adding HTML to an index.html file. As you work through this lesson, you continue building the page by adding JavaScript, CSS, and more HTML. Use the code editor of your choice for working with these technologies in your custom page. This lesson uses Visual Studio Code.
Use the following steps to create a simple custom page:
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<title>HelloWikipedia Categories</title>
</head>
<body>
<div id="hw-container">Hello World!</div>
</body>
</html>
This section illustrates how to deploy a custom page in Relativity and associate it with a tab. For more information, see
Use the following steps to deploy a custom page in Relativity:
This workflow updates your application in the application library. Your application must be installed in a workspace. The Push to Library option performs the updates to the application in the library.
1
%applicationPath%/CustomPages/e57fa0fe-59fd-49eb-92ed-895f3e592cd1/index.html
You can set this field to any value. It controls the order that the tab is displayed in the drop-down menu of the parent tab.
Use the following steps to add client-side JavaScript to a custom page:
1
2
3
4
5
6
7
function startApplication() {
console.info("HelloWikipedia Categories application started");
const label = document.getElementById("hw-container");
label.innerText = "Hello from JavaScript!";
}
startApplication();
1
<script src="./scripts/main.js"></script>
After confirming that your custom page is working properly in Relativity, you can begin debugging your JavaScript in the browser. This lesson uses Google Chrome, but you could use other tools for this purpose.
Use the following steps to debug JavaScript in the browser:
In this step, you add functionality to your custom page, which provides users with the ability to search for the categories in Wikipedia using an existing Kepler service. Users should also be able to add categories as RDOs through the Object Manager service. For more information, see Kepler framework and Object Manager Fundamentals.
Your completed custom page should look like the following screen shot after you update the code and deploy it in Relativity. This screen shot illustrates a search on the word science.
Use the following steps to add new functionality to your custom page:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
<head>
<title>HelloWikipedia Categories</title>
<link href="./styles/style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="hw-container">
<div id="hw-search-container">
<span id="hw-search-label">Search Wikipedia for article categories</span>
<div id="hw-search">
<input id="hw-search-input" placeholder="Search for category" />
<button id="hw-category-search-button" class="hw-button" type="button">Search</button>
</div>
</div>
<div id="hw-results-container"></div>
</div>
<script type="module" src="./scripts/main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#hw-container {
margin: 0;
padding: 20px;
}
#hw-search-container {
margin-left: 8px;
}
#hw-results-container {
display: table;
width: auto;
max-width: 800px;
margin-top: 20px;
}
.hw-results-header {
color: #0670c1;
}
.hw-category-row {
display: table-row;
}
.hw-category-cell {
display: table-cell;
padding: 8px;
border-bottom: 1px solid #e2ebf3;
text-align: left;
}
.hw-button-cell {
display: table-cell;
padding-left: 12px;
}
#hw-search {
margin-top: 10px;
}
#hw-search-input {
width: 300px;
margin-right: 5px;
padding: 5px;
border-radius: 3px;
border: .0625rem solid #acbfd6;
line-height: 1.4rem;
}
#hw-search-label {
color: #0670c1;
}
.hw-button {
background-color: #0075e0;
border: none;
border-radius: 3px;
display: inline-block;
cursor: pointer;
color: #fff;
padding: 9px 23px;
text-decoration: none;
}
.hw-button:hover {
background-color: #0670c1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export class ApiFetchClient {
constructor(globalObjectService) {
this.globalObjectService = globalObjectService;
}
async get(apiEndpoint) {
const response = await this.globalObjectService.getWindow().fetch(this._getFullApiPath(apiEndpoint));
this._validateResponse(response);
return await response.json();
}
async post(apiEndpoint, body) {
const response = await this.globalObjectService.getWindow().fetch(this._getFullApiPath(apiEndpoint), this._getPostRequestInit(body));
this._validateResponse(response);
return await response.json();
}
_getPostRequestInit(payload) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Header': '-'
},
body: JSON.stringify(payload)
};
}
_validateResponse(response) {
if (!response.ok) {
throw new Error(response.statusText);
}
}
_getFullApiPath(apiEndpoint) {
return this.globalObjectService.getTopWindow().GetKeplerApplicationPath() + apiEndpoint;
}
}
1
2
3
4
5
6
7
8
9
10
export class WikiCategorySearchService {
constructor(fetchClient) {
this.fetchClient = fetchClient;
}
async search(categoryName) {
return await this.fetchClient.get(`wikipedia-management/v1/wikipedia-service/categories?prefix=${categoryName}`);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
export class CategoryService {
constructor(fetchClient, globalObjectService) {
this.fetchClient = fetchClient;
this.globalObjectService = globalObjectService;
this.appConstants = {
articleCategoryObjectTypeGuid: '6B20F149-1B17-4E9C-8403-439E98E8BFD2',
articleCategoryNameFieldGuid: '16D8A362-2923-45B7-8444-7339C57B3AF0',
overwriteArticleTextFieldGuid: '042E0329-1467-4993-8188-66615E103DE3',
automaticUpdatesEnabledFieldGuid: 'F365DE2E-A641-428F-9188-A3970A7C308F'
};
}
create(categoryName) {
const request = {
'request': {
'ObjectType': {
'Guid': this.appConstants.articleCategoryObjectTypeGuid
},
'FieldValues': [{
'Field': {
'Guid': this.appConstants.articleCategoryNameFieldGuid
},
'Value': categoryName
},
{
'Field': {
'Guid': this.appConstants.overwriteArticleTextFieldGuid
},
'Value': false
},
{
'Field': {
'Guid': this.appConstants.automaticUpdatesEnabledFieldGuid
},
'Value': true
}
]
}
};
return this.fetchClient.post(this._getObjectManagerMethodPath('create'), request);
}
async getCategories() {
const request = {
'request': {
'ObjectType': {
'Guid': this.appConstants.articleCategoryObjectTypeGuid
},
'Condition': '',
'Fields': [{
'Guid': this.appConstants.articleCategoryNameFieldGuid
}]
},
'start': 1,
'length': 99999
};
const result = await this.fetchClient.post(this._getObjectManagerMethodPath('queryslim'), request);
const mappedCategories = result.Objects.map(object => {
return object.Values[0];
});
return mappedCategories;
}
_getWorkspaceId() {
const windowObject = this.globalObjectService.getTopWindow();
const url = new URL(windowObject.location.href);
return url.searchParams.get('AppID');
}
_getObjectManagerMethodPath(methodName) {
const workspaceId = this._getWorkspaceId();
return `Relativity.Objects/workspace/${workspaceId}/object/${methodName}`;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
export class GlobalObjectService {
getTopWindow() {
return window.top;
}
getWindow() {
return window;
}
getDocument() {
return document;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export class ElementFactory {
constructor(globalObjectService) {
this.globalObjectService = globalObjectService;
}
createDiv(className) {
const div = this._createElement('div');
div.className = className;
return div;
}
createSpan(className) {
const span = this._createElement('span');
span.className = className;
return span;
}
createButton(buttonText, className) {
const button = this._createElement('button');
button.setAttribute('type', 'button');
button.innerText = buttonText;
button.className = className;
return button;
}
_createElement(tagName) {
const documentObject = this.globalObjectService.getDocument();
return documentObject.createElement(tagName);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
export class CategoryResultElementFactory {
constructor(elementFactory, createCategory) {
this.elementFactory = elementFactory;
this.createCategory = createCategory;
}
createHtmlElement(category) {
const row = this.elementFactory.createDiv('hw-category-row');
const categoryName = this.elementFactory.createSpan('hw-category-cell');
categoryName.innerText = category.name;
row.appendChild(categoryName);
if (!category.exists) {
const buttonDiv = this.elementFactory.createDiv('hw-button-cell');
const button = this._createButton('Create', category.name);
buttonDiv.appendChild(button);
row.appendChild(buttonDiv);
}
return row;
}
_createButton(buttonText, categoryName) {
const button = this.elementFactory.createButton(buttonText, 'hw-button');
button.addEventListener('click',
() => {
button.parentNode.removeChild(button);
this.createCategory(categoryName);
});
return button;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
export class SearchResultsPresenter {
constructor(
categoryService,
elementFactory,
categoryResultElementFactory,
globalObjectService) {
this.categoryService = categoryService;
this.elementFactory = elementFactory;
this.categoryResultElementFactory = categoryResultElementFactory;
this.globalObjectService = globalObjectService;
}
async showSearchResults(categories) {
const existingCategories = await this.categoryService.getCategories();
const categoriesToRender = categories.map(category => {
const exists = existingCategories.includes(category.Title);
return {
name: category.Title,
exists: exists
};
});
this._renderCategories(categoriesToRender);
}
_renderCategories(categories) {
const documentObject = this.globalObjectService.getDocument();
const resultsDiv = documentObject.getElementById('hw-results-container');
resultsDiv.innerHTML = '';
this._addResultsTitle(resultsDiv);
categories.forEach(category => {
const resultElement = this.categoryResultElementFactory.createHtmlElement(category);
resultsDiv.appendChild(resultElement);
});
}
_addResultsTitle(resultsDiv) {
const resultsHeader = this.elementFactory.createDiv('hw-results-header hw-category-row');
const headerText = this.elementFactory.createSpan('hw-category-cell');
headerText.innerText = 'Categories Found';
resultsHeader.appendChild(headerText);
resultsDiv.appendChild(resultsHeader);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
export class SearchHandler {
constructor(categorySearchService, resultsPresenter) {
this.categorySearchService = categorySearchService;
this.resultsPresenter = resultsPresenter;
}
async executeSearch(categoryPrefix) {
const result = await this.categorySearchService.search(categoryPrefix);
await this.resultsPresenter.showSearchResults(result);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { SearchHandler } from './searchHandler.js';
import { WikiCategorySearchService } from './services/wikiCategorySearchService.js';
import { SearchResultsPresenter } from './searchResultsPresenter.js';
import { CategoryService } from './services/categoryService.js';
import { ApiFetchClient } from './services/apiFetchClient.js';
import { CategoryResultElementFactory } from './categoryResultElementFactory.js';
import { ElementFactory } from './elementFactory.js';
import { GlobalObjectService } from './services/globalObjectService.js';
let _searchHandler;
function startApplication() {
console.info('HelloWikipedia Categories application started');
const searchButton = _getSearchButton();
searchButton.addEventListener('click', _onSearchButtonClicked);
searchButton.setAttribute('disabled', '');
_getSearchInput().addEventListener('input', _onSearchTextChanged);
}
function _getSearchHandler() {
if (_searchHandler) {
return _searchHandler;
}
const globalObjectService = new GlobalObjectService();
const fetchClient = new ApiFetchClient(globalObjectService);
const categoryService = new CategoryService(fetchClient, globalObjectService);
const elementFactory = new ElementFactory(globalObjectService);
const categoryResultElementFactory = new CategoryResultElementFactory(elementFactory, categoryName => categoryService.create(categoryName));
const resultsPresenter = new SearchResultsPresenter(categoryService, elementFactory, categoryResultElementFactory, globalObjectService);
const categorySearchService = new WikiCategorySearchService(fetchClient);
_searchHandler = new SearchHandler(categorySearchService, resultsPresenter);
return _searchHandler;
}
function _onSearchButtonClicked() {
const searchHandler = _getSearchHandler();
searchHandler.executeSearch(_getSearchInput().value);
}
function _onSearchTextChanged() {
const searchButton = _getSearchButton();
if (_getSearchInput().value) {
searchButton.removeAttribute('disabled');
} else {
searchButton.setAttribute('disabled', '');
}
}
function _getSearchButton() {
return document.getElementById('hw-category-search-button');
}
function _getSearchInput() {
return document.getElementById('hw-search-input');
}
startApplication();
After deploying your updated custom page, you can begin writing tests for it. In this step, you use a JavaScript testing framework called Jasmine. Review the Getting Started page on the Jasmine website for more information.
Use the following steps to add tests to the project:
1
2
3
4
5
6
7
8
// Run following command to initialize npm. This command creates package.json file in your project.
npm init -y
// Then run following command to install jasmine package. This command adds jasmine dependency to package.json.
npm install jasmine --save-dev
// Then run following command to initialize jasmine. This command adds jasmine configuration file.
npx jasmine init
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "hellowikipedia-custompage",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "jasmine"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jasmine": "^3.6.1"
}
}
1
2
3
4
5
6
7
8
9
10
11
{
"spec_dir": "src/tests",
"spec_files": [
"**/*[sS]pec.js"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import { CategoryService } from '../scripts/services/categoryService.js';
import { createWindowMock, createGlobalObjectServiceMock, createApiFetchClientMock } from './utils/mockFactory';
describe('CategoryService', () => {
const workspaceId = 12345;
const topWindowObject = createWindowMock({ href: `https://localhost/endpoint?AppID=${workspaceId}` });
const globalObjectService = createGlobalObjectServiceMock({ topWindowObject });
const appConstants = {
articleCategoryObjectTypeGuid: '6B20F149-1B17-4E9C-8403-439E98E8BFD2',
articleCategoryNameFieldGuid: '16D8A362-2923-45B7-8444-7339C57B3AF0',
overwriteArticleTextFieldGuid: '042E0329-1467-4993-8188-66615E103DE3',
automaticUpdatesEnabledFieldGuid: 'F365DE2E-A641-428F-9188-A3970A7C308F'
};
describe('getCategories', () => {
it('should return array of categories for workspace', async () => {
// Arrange
const fetchClient = createApiFetchClientMock({
postResult: {
'Objects': [
{
'Values': [
'Movie'
]
},
{
'Values': [
'Sport'
]
}
]
}
});
const sut = new CategoryService(fetchClient, globalObjectService);
// Act
const categories = await sut.getCategories();
// Assert
expect(categories).toEqual(['Movie', 'Sport']);
expect(fetchClient.post).toHaveBeenCalledWith('Relativity.Objects/workspace/12345/object/queryslim', {
request: {
ObjectType: {
Guid: appConstants.articleCategoryObjectTypeGuid
},
Condition: '',
Fields: [
{
Guid: appConstants.articleCategoryNameFieldGuid,
},
],
},
start: 1,
length: 99999,
});
});
});
describe('create', () => {
it('should create article category', async () => {
// Arrange
const categoryName = 'sport';
const fetchClient = createApiFetchClientMock({});
const sut = new CategoryService(fetchClient, globalObjectService);
// Act
await sut.create(categoryName);
// Assert
expect(fetchClient.post).toHaveBeenCalledWith('Relativity.Objects/workspace/12345/object/create', {
request: {
ObjectType: {
Guid: appConstants.articleCategoryObjectTypeGuid
},
FieldValues: [
{
Field: {
Guid: appConstants.articleCategoryNameFieldGuid
},
Value: categoryName
},
{
Field: {
Guid: appConstants.overwriteArticleTextFieldGuid
},
Value: false
},
{
Field: {
Guid: appConstants.automaticUpdatesEnabledFieldGuid
},
Value: true
}
]
}
});
});
});
});
Add the following code for the documentFake.js class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { HtmlElementFake } from './htmlElementFake';
export class DocumentFake {
constructor() {
this.children = [];
}
createElement(tagName) {
return new HtmlElementFake(tagName);
}
appendChild(newChild) {
newChild.parentNode = this;
this.children.push(newChild);
}
getElementById(id) {
return this.children.find(x => x.id === id);
}
}
Add the following code for the htmlElementFake.js class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export class HtmlElementFake {
constructor(tagName) {
this.tagName = tagName;
this.id = (void 0);
this.className = (void 0);
this.type = (void 0);
this.innerText = (void 0);
this.parentNode = (void 0);
this.children = [];
this.eventActions = {};
}
appendChild(newChild) {
newChild.parentNode = this;
this.children.push(newChild);
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index > -1) {
this.children.splice(index, 1);
child.parentNode = null;
}
}
setAttribute(name, value) {
this[name] = value;
}
addEventListener(eventName, eventAction) {
this.eventActions[eventName] = eventAction;
}
raiseEvent(eventName) {
this.eventActions[eventName]();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import { HtmlElementFake } from '../fakes/htmlElementFake';
import { DocumentFake } from '../fakes/documentFake';
export function createGlobalObjectServiceMock(mockParams) {
return {
getTopWindow: function () {
return mockParams.topWindowObject;
},
getWindow: function () {
return mockParams.windowObject;
},
getDocument: function () {
return mockParams.documentObject;
}
};
}
export function createWindowMock(mockParams) {
return {
location: {
href: mockParams.href
},
fetch: mockParams.fetch,
GetKeplerApplicationPath: jasmine.createSpy().and.returnValue(mockParams.keplerApplicationPath)
};
}
export function createDocumentMock() {
return new DocumentFake();
}
export function createFetchMock(fetchResponses) {
return jasmine.createSpy().and.callFake(function (input) {
const response = fetchResponses[input];
return Promise.resolve({
ok: response.ok,
json: function () {
return Promise.resolve(response.data);
},
statusText: response.statusText
});
});
}
export function createApiFetchClientMock(mockParams) {
return {
get: jasmine.createSpy().and.returnValue(Promise.resolve(mockParams.getResult)),
post: jasmine.createSpy().and.returnValue(Promise.resolve(mockParams.postResult))
};
}
export function createCategorySearchServiceMock(mockParams) {
return {
search: jasmine.createSpy().and.returnValue(Promise.resolve(mockParams.searchResult))
};
}
export function createSearchResultsPresenterMock() {
return {
showSearchResults: jasmine.createSpy()
};
}
export function createElementFactoryMock() {
return {
createDiv: function (className) {
const div = new HtmlElementFake('div');
div.className = className;
return div;
},
createSpan: function (className) {
const span = new HtmlElementFake('span');
span.className = className;
return span;
},
createButton: function (buttonText, className) {
const button = new HtmlElementFake('button');
button.className = className;
button.innerText = buttonText;
button.type = 'button';
return button;
}
};
}
1
npm install @babel/core @babel/register @babel/preset-env --save-dev
1
2
3
4
5
6
...
"helpers": [
"../../node_modules/regenerator-runtime/runtime.js",
...
],
...
1
2
3
4
5
{
"presets": [
"@babel/preset-env"
]
}
1
npm run test
This step illustrates how to make your custom page ready for deployment in a production environment. Use these guidelines when publishing a custom page:
Use the following steps to prepare the page for a production environment:
1
npm install webpack webpack-cli --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html>
<head>
<title>HelloWikipedia Categories</title>
</head>
<body>
<div id="hw-container">
<div id="hw-search-container">
<span id="hw-search-label">Search Wikipedia for article categories</span>
<div id="hw-search">
<input id="hw-search-input" placeholder="Search for category" />
<button id="hw-category-search-button" class="hw-button" type="button">Search</button>
</div>
</div>
<div id="hw-results-container"></div>
</div>
<script src="bundle.js"></script>
</body>
</html>
1
npm install style-loader css-loader --save-dev
1
2
...
import '../styles/style.css';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const path = require('path');
module.exports = {
entry: [
path.resolve(__dirname, "src/scripts/main.js"),
],
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
],
}
],
},
};
1
2
3
4
"scripts": {
"test": "jasmine",
"build": "webpack"
}
1
npm run build
1
npm install babel-loader core-js whatwg-fetch --save-dev
1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": 3
}
]
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const path = require('path');
module.exports = {
entry: [
"core-js/stable",
"regenerator-runtime/runtime",
"whatwg-fetch",
path.resolve(__dirname, "src/scripts/main.js"),
],
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
],
},
{
test: /\.m?js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}
],
},
};
When deploying your bundled custom page, you only need to add the contents in the dist directory to the HelloWikipedia.CustomPage.zip file. No other files or directories are required.
On this page
Why was this not helpful?
Check one that applies.
Thank you for your feedback.
Want to tell us more?
Great!
Additional Resources |
|||
DevHelp Community | GitHub | Release Notes | NuGet |