diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index dab0ad8f..06511018 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -41,6 +41,7 @@ jobs: `${process.env.GITHUB_WORKSPACE}/ContextMenu/ContextMenu`, `${process.env.GITHUB_WORKSPACE}/DetailsList/DetailsList`, `${process.env.GITHUB_WORKSPACE}/Elevation/Elevation`, + `${process.env.GITHUB_WORKSPACE}/Facepile/Facepile`, `${process.env.GITHUB_WORKSPACE}/Icon/Icon`, `${process.env.GITHUB_WORKSPACE}/KeyboardShortcuts/KeyboardShortcuts`, `${process.env.GITHUB_WORKSPACE}/Nav/Nav`, @@ -71,6 +72,8 @@ jobs: working-directory: "./DetailsList" - run: npm ci working-directory: "./Elevation" + - run: npm ci + working-directory: "./Facepile" - run: npm ci working-directory: "./Icon" - run: npm ci diff --git a/.github/workflows/pr_validate_all.yml b/.github/workflows/pr_validate_all.yml index 9d35acf8..6a4d0b5c 100644 --- a/.github/workflows/pr_validate_all.yml +++ b/.github/workflows/pr_validate_all.yml @@ -19,6 +19,7 @@ jobs: - "./ContextMenu" - "./DetailsList" - "./Elevation" + - "./Facepile" - "./Icon" - "./KeyboardShortcuts" - "./Nav" diff --git a/Calendar/.vscode/launch.json b/Calendar/.vscode/launch.json new file mode 100644 index 00000000..7efd4ad0 --- /dev/null +++ b/Calendar/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests", + "request": "launch", + "args": ["${fileBasename}", "--runInBand", "--code-coverage=false" ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "smartStep": true, + "program": "${workspaceFolder}/node_modules/jest/bin/jest", + "skipFiles": ["node_modules/**/*.js", "/**/*.js", "async_hooks.js", "inspector_async_hook.js"] + } + ] +} diff --git a/Calendar/.vscode/settings.json b/Calendar/.vscode/settings.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/Calendar/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/Calendar/config/tests.js b/Calendar/config/tests.js index 7e43176e..f0f92962 100644 --- a/Calendar/config/tests.js +++ b/Calendar/config/tests.js @@ -7,5 +7,8 @@ const Adapter = require('enzyme-adapter-react-16'); // Initialize icons. initializeIcons(undefined, { disableWarnings: true }); +// Ensure the test snapshots are consistent when using today's date +jest.useFakeTimers('modern').setSystemTime(new Date(2022, 8, 1, 12, 0, 0)); + // Configure enzyme. configure({ adapter: new Adapter() }); diff --git a/Facepile/.eslintrc.json b/Facepile/.eslintrc.json new file mode 100644 index 00000000..0471f688 --- /dev/null +++ b/Facepile/.eslintrc.json @@ -0,0 +1,55 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "prettier", + "plugin:sonarjs/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "react", + "react-hooks", + "@typescript-eslint", + "prettier", + "sonarjs" + ], + "settings": { + "react": { + "pragma": "React", + "version": "detect" + } + }, + "ignorePatterns": ["**/generated/*.ts"], + "rules": { + "eqeqeq": [2, "smart"], + "prettier/prettier": "error", + "arrow-body-style": "off", + "prefer-arrow-callback": "off", + "linebreak-style": [ + "error", + "windows" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ] + } +} diff --git a/Facepile/.gitignore b/Facepile/.gitignore new file mode 100644 index 00000000..cfc4228b --- /dev/null +++ b/Facepile/.gitignore @@ -0,0 +1,17 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# generated directory +**/generated + +# output directory +/out + +#coverage directory +/coverage + +# msbuild output directories +/bin +/obj \ No newline at end of file diff --git a/Facepile/.prettierrc.json b/Facepile/.prettierrc.json new file mode 100644 index 00000000..7c10c705 --- /dev/null +++ b/Facepile/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 4, + "endOfLine":"auto" + } \ No newline at end of file diff --git a/Facepile/Facepile.pcfproj b/Facepile/Facepile.pcfproj new file mode 100644 index 00000000..fa911196 --- /dev/null +++ b/Facepile/Facepile.pcfproj @@ -0,0 +1,47 @@ + + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + Facepile + eb11ae1f-1080-4a88-84d3-3367b8155c39 + $(MSBuildThisFileDirectory)out\controls + production + + + + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Facepile/Facepile/ContextExtended.ts b/Facepile/Facepile/ContextExtended.ts new file mode 100644 index 00000000..e77c3613 --- /dev/null +++ b/Facepile/Facepile/ContextExtended.ts @@ -0,0 +1,8 @@ +// This is undocumented - but needed since canvas apps sets non-zero tabindexes +// so we must use the tabindex provided by the context for accessibility purposes +export interface ContextEx { + accessibility: { + assignedTabIndex: number; + assignedTooltip?: string; + }; +} diff --git a/Facepile/Facepile/ControlManifest.Input.xml b/Facepile/Facepile/ControlManifest.Input.xml new file mode 100644 index 00000000..04c73135 --- /dev/null +++ b/Facepile/Facepile/ControlManifest.Input.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + Size8 + Size24 + Size32 + Size40 + Size48 + size56 + + + none + descriptive + downArrow + more + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Facepile/Facepile/ManifestConstants.ts b/Facepile/Facepile/ManifestConstants.ts new file mode 100644 index 00000000..9941c88b --- /dev/null +++ b/Facepile/Facepile/ManifestConstants.ts @@ -0,0 +1,31 @@ +export const enum ManifestPropertyNames { + dataset = 'dataset', +} + +export const enum ItemColumns { + DisplayName = 'ItemPersonaName', + Key = 'ItemPersonaKey', + ImageUrl = 'ItemPersonaImageUrl', + Image = 'ItemPersonaImage', + ImageInfo = 'ItemPersonaImageInfo', + Role = 'ItemPersonaRole', + ImageType = 'ItemImageType', + Presence = 'ItemPersonaPresence', + Clickable = 'ItemPersonaClickable', + IsImage = 'IsImage', +} + +export const enum InputEvents { + SetFocus = 'SetFocus', +} + +export const enum InputProperties { + InputEvent = 'InputEvent', + SelectedKey = 'SelectedKey', +} + +export const enum OutputEvents { + PersonaEvent = 'PersonaEvent', + OverFlowButtonEvent = 'OverFlowButtonEvent', + AddButtonEvent = 'AddButtonEvent', +} diff --git a/Facepile/Facepile/__mocks__/mock-context.ts b/Facepile/Facepile/__mocks__/mock-context.ts new file mode 100644 index 00000000..b18aeab1 --- /dev/null +++ b/Facepile/Facepile/__mocks__/mock-context.ts @@ -0,0 +1,92 @@ +/* istanbul ignore file */ + +export class MockContext implements ComponentFramework.Context { + constructor(parameters: T) { + this.parameters = parameters; + this.mode = { + allocatedHeight: -1, + allocatedWidth: -1, + isControlDisabled: false, + isVisible: true, + label: '', + setControlState: jest.fn(), + setFullScreen: jest.fn(), + trackContainerResize: jest.fn(), + }; + this.client = { + disableScroll: false, + getClient: jest.fn(), + getFormFactor: jest.fn(), + isOffline: jest.fn(), + }; + + // Canvas apps currently assigns a positive tab-index + // so we must use this property to assign a positive tab-index also + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any).accessibility = { assignedTabIndex: 0 }; + } + client: ComponentFramework.Client; + device: ComponentFramework.Device; + factory: ComponentFramework.Factory; + formatting: ComponentFramework.Formatting; + mode: ComponentFramework.Mode; + navigation: ComponentFramework.Navigation; + resources: ComponentFramework.Resources; + userSettings: ComponentFramework.UserSettings; + utils: ComponentFramework.Utility; + webAPI: ComponentFramework.WebApi; + parameters: T; + updatedProperties: string[] = []; +} + +export class MockState implements ComponentFramework.Dictionary {} + +export class MockStringProperty implements ComponentFramework.PropertyTypes.StringProperty { + constructor(raw?: string | null, formatted?: string | undefined) { + this.raw = raw ?? null; + this.formatted = formatted; + } + raw: string | null; + attributes?: ComponentFramework.PropertyHelper.FieldPropertyMetadata.StringMetadata | undefined; + error: boolean; + errorMessage: string; + formatted?: string | undefined; + security?: ComponentFramework.PropertyHelper.SecurityValues | undefined; + type: string; +} + +export class MockWholeNumberProperty implements ComponentFramework.PropertyTypes.WholeNumberProperty { + constructor(raw?: number | null, formatted?: string | undefined) { + this.raw = raw ?? null; + this.formatted = formatted; + } + attributes?: ComponentFramework.PropertyHelper.FieldPropertyMetadata.WholeNumberMetadata | undefined; + raw: number | null; + error: boolean; + errorMessage: string; + formatted?: string | undefined; + security?: ComponentFramework.PropertyHelper.SecurityValues | undefined; + type: string; +} + +export class MockEnumProperty implements ComponentFramework.PropertyTypes.EnumProperty { + constructor(raw?: T, type?: string) { + if (raw) this.raw = raw; + if (type) this.type = type; + } + type: string; + raw: T; +} + +export class MockTwoOptionsProperty implements ComponentFramework.PropertyTypes.TwoOptionsProperty { + constructor(raw?: boolean) { + if (raw) this.raw = raw; + } + raw: boolean; + attributes?: ComponentFramework.PropertyHelper.FieldPropertyMetadata.TwoOptionMetadata | undefined; + error: boolean; + errorMessage: string; + formatted?: string | undefined; + security?: ComponentFramework.PropertyHelper.SecurityValues | undefined; + type: string; +} diff --git a/Facepile/Facepile/__mocks__/mock-datasets.ts b/Facepile/Facepile/__mocks__/mock-datasets.ts new file mode 100644 index 00000000..1c9d1b0c --- /dev/null +++ b/Facepile/Facepile/__mocks__/mock-datasets.ts @@ -0,0 +1,108 @@ +/* istanbul ignore file */ + +export class MockDataSet implements ComponentFramework.PropertyTypes.DataSet { + private rows: MockEntityRecord[] = []; + constructor(rows: MockEntityRecord[]) { + this.rows = rows; + this.records = {}; + rows.forEach((r) => (this.records[r.id] = r)); + this.sortedRecordIds = rows.map((r) => r.id); + this.paging = { + setPageSize: jest.fn(), + totalResultCount: 0, + firstPageNumber: 0, + hasNextPage: false, + hasPreviousPage: false, + lastPageNumber: 0, + loadExactPage: jest.fn(), + loadNextPage: jest.fn(), + loadPreviousPage: jest.fn(), + pageSize: 0, + reset: jest.fn(), + }; + } + addColumn = jest.fn(); + columns: ComponentFramework.PropertyHelper.DataSetApi.Column[] = []; + error: boolean; + errorMessage: string; + filtering: ComponentFramework.PropertyHelper.DataSetApi.Filtering; + linking: ComponentFramework.PropertyHelper.DataSetApi.Linking; + loading: boolean; + paging: ComponentFramework.PropertyHelper.DataSetApi.Paging; + records: { + [id: string]: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord; + }; + sortedRecordIds: string[]; + sorting: ComponentFramework.PropertyHelper.DataSetApi.SortStatus[]; + clearSelectedRecordIds = jest.fn(); + getSelectedRecordIds = jest.fn(); + getTargetEntityType = jest.fn(); + getTitle = jest.fn(); + getViewId = jest.fn(); + openDatasetItem = jest.fn(); + refresh = jest.fn(); + setSelectedRecordIds = jest.fn(); +} + +export class MockColumn implements ComponentFramework.PropertyHelper.DataSetApi.Column { + name: string; + displayName: string; + dataType!: string; + alias!: string; + order!: number; + visualSizeFactor!: number; + isHidden?: boolean | undefined; + isPrimary?: boolean | undefined; + disableSorting?: boolean | undefined; + constructor(name: string, displayName: string) { + this.name = name; + this.displayName = displayName; + } +} + +type valueType = + | string + | number + | boolean + | Date + | number[] + | ComponentFramework.EntityReference + | ComponentFramework.EntityReference[] + | ComponentFramework.LookupValue + | ComponentFramework.LookupValue[]; + +export class MockEntityRecord implements ComponentFramework.PropertyHelper.DataSetApi.EntityRecord { + values: Record; + id: string; + constructor(id: string, values: Record) { + this.values = values; + this.id = id; + } + getFormattedValue(columnName: string): string { + return this.values[columnName] as string; + } + getRecordId(): string { + return this.id; + } + getValue(columnName: string): valueType { + return this.values[columnName]; + } + getNamedReference = jest.fn(); +} + +export function getData(records: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord[]): { + sortedRecordIds: string[]; + records: Record; +} { + const sortedRecordIds: string[] = []; + const recordsOut: Record = {}; + + for (const r of records) { + sortedRecordIds.push(r.getRecordId()); + recordsOut[r.getRecordId()] = r; + } + return { + sortedRecordIds: sortedRecordIds, + records: recordsOut, + }; +} diff --git a/Facepile/Facepile/__mocks__/mock-parameters.ts b/Facepile/Facepile/__mocks__/mock-parameters.ts new file mode 100644 index 00000000..494ba972 --- /dev/null +++ b/Facepile/Facepile/__mocks__/mock-parameters.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ + +import { IInputs } from '../generated/ManifestTypes'; +import { MockEnumProperty, MockStringProperty, MockTwoOptionsProperty, MockWholeNumberProperty } from './mock-context'; +import { MockDataSet } from './mock-datasets'; + +export function getMockParameters(): IInputs { + return { + AccessibilityLabel: new MockStringProperty(), + InputEvent: new MockStringProperty(), + Theme: new MockStringProperty(), + items: new MockDataSet([]), + MaxDisplayablePersonas: new MockWholeNumberProperty(), + ImageShouldFadeIn: new MockTwoOptionsProperty(), + PersonaSize: new MockEnumProperty(), + OverflowButtonType: new MockEnumProperty(), + ShowAddButton: new MockTwoOptionsProperty(), + OverflowButtonAriaLabel: new MockStringProperty(), + AddbuttonAriaLabel: new MockStringProperty(), + }; +} diff --git a/Facepile/Facepile/_test_/__snapshots__/datasetmapping.test.ts.snap b/Facepile/Facepile/_test_/__snapshots__/datasetmapping.test.ts.snap new file mode 100644 index 00000000..7474b2b6 --- /dev/null +++ b/Facepile/Facepile/_test_/__snapshots__/datasetmapping.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatasetMapping returns correct props 1`] = ` +Array [ + Object { + "id": "1", + "imageUrl": "", + "key": "item2", + "onClick": [MockFunction], + "personaName": "Megan Bowen", + "presence": "none", + }, + Object { + "id": "1", + "imageUrl": "", + "key": "item2_2", + "onClick": [MockFunction], + "personaName": "Megan Bowen", + "presence": "none", + }, +] +`; diff --git a/Facepile/Facepile/_test_/__snapshots__/facepile-lifecycle.test.ts.snap b/Facepile/Facepile/_test_/__snapshots__/facepile-lifecycle.test.ts.snap new file mode 100644 index 00000000..3c300c8e --- /dev/null +++ b/Facepile/Facepile/_test_/__snapshots__/facepile-lifecycle.test.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FacePile renders 1`] = ` + +`; + +exports[`FacePile renders dummy items when no items configured 1`] = ` + +`; + +exports[`FacePile theme 1`] = `
`; diff --git a/Facepile/Facepile/_test_/datasetmapping.test.ts b/Facepile/Facepile/_test_/datasetmapping.test.ts new file mode 100644 index 00000000..7ddb41c8 --- /dev/null +++ b/Facepile/Facepile/_test_/datasetmapping.test.ts @@ -0,0 +1,33 @@ +import { getFacepilePersonas, getitemFromDataset } from '../components/DatasetMapping'; +import { ItemColumns } from '../ManifestConstants'; +import { MockDataSet, MockEntityRecord } from '../__mocks__/mock-datasets'; + +describe('DatasetMapping', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns correct props', () => { + const items = [ + new MockEntityRecord('1', { + [ItemColumns.Key]: 'item1', + [ItemColumns.DisplayName]: 'Diego Siciliani', + [ItemColumns.ImageUrl]: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-male.png', + [ItemColumns.Clickable]: true, + [ItemColumns.Presence]: 'away', + }), + new MockEntityRecord('1', { + [ItemColumns.Key]: 'item2', + [ItemColumns.DisplayName]: 'Megan Bowen', + [ItemColumns.ImageUrl]: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-female.png', + [ItemColumns.Clickable]: true, + [ItemColumns.Presence]: 'none', + }), + ]; + + const onClickEvent = jest.fn(); + const actions = getitemFromDataset(new MockDataSet(items)); + const props = getFacepilePersonas(actions, onClickEvent); + expect(props).toMatchSnapshot(); + }); +}); diff --git a/Facepile/Facepile/_test_/facepile-lifecycle.test.ts b/Facepile/Facepile/_test_/facepile-lifecycle.test.ts new file mode 100644 index 00000000..f2abebf6 --- /dev/null +++ b/Facepile/Facepile/_test_/facepile-lifecycle.test.ts @@ -0,0 +1,211 @@ +import { Facepile } from '..'; +import { IInputs } from '../generated/ManifestTypes'; +import { ItemColumns, OutputEvents } from '../ManifestConstants'; +import { MockContext, MockState } from '../__mocks__/mock-context'; +import { MockDataSet, MockEntityRecord } from '../__mocks__/mock-datasets'; +import { getMockParameters } from '../__mocks__/mock-parameters'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import { getPersonaPresence, getPersonaSize } from '../components/Helper'; +import { PersonaPresence, PersonaSize } from '@fluentui/react'; + +// Since requestAnimationFrame does not exist in the test DOM, mock it +window.requestAnimationFrame = jest.fn().mockImplementation((callback) => { + callback(); +}); + +jest.useFakeTimers(); + +describe('FacePile', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => { + for (let i = 0; i < document.body.children.length; i++) { + if (document.body.children[i].tagName === 'DIV') { + document.body.removeChild(document.body.children[i]); + i--; + } + } + }); + + it('renders', () => { + const { component, context, notifyOutputChanged } = createComponent(); + component.init(context, notifyOutputChanged); + const element = component.updateView(context); + expect(element).toMatchSnapshot(); + }); + + it('renders dummy items when no items configured', () => { + const { component, context, notifyOutputChanged } = createComponent(); + // Simulate there being no items bound - which causes an error on the parameters + context.parameters.items.error = true; + component.init(context, notifyOutputChanged); + const element = component.updateView(context); + expect(element).toMatchSnapshot(); + }); + + it('check getPersonaPresence', () => { + let presence: string | undefined = 'none'; + let mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.none); + presence = 'away'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.away); + presence = 'blocked'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.blocked); + presence = 'busy'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.busy); + presence = 'dnd'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.dnd); + presence = 'offline'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.offline); + presence = 'online'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.online); + presence = 'focused'; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.none); + presence = ''; + mockRecord = getMockDataSet(presence); + expect(getPersonaPresence(mockRecord.records['1'])).toBe(PersonaPresence.none); + }); + + it('check getPersonaSize', () => { + expect(getPersonaSize('size8')).toBe(PersonaSize.size8); + expect(getPersonaSize('size24')).toBe(PersonaSize.size24); + expect(getPersonaSize('size32')).toBe(PersonaSize.size32); + expect(getPersonaSize('size40')).toBe(PersonaSize.size40); + expect(getPersonaSize('size48')).toBe(PersonaSize.size48); + expect(getPersonaSize('size56')).toBe(PersonaSize.size56); + expect(getPersonaSize('')).toBe(PersonaSize.size32); + }); + + it('check persona onclick event', () => { + const { component, context, notifyOutputChanged } = createComponent(); + + component.init(context, notifyOutputChanged); + const facepileComponent = component.updateView(context); + + const firstCommandReference = { + id: { guid: '1' }, + name: '1', + } as ComponentFramework.EntityReference; + + context.parameters.items.records['1'].getNamedReference = jest.fn().mockReturnValueOnce(firstCommandReference); + const facepile = mount(facepileComponent); + const facepileNode = facepile.find('.ms-Facepile-itemButton').first(); + expect(facepileNode.length).toEqual(1); + + facepileNode.simulate('click'); + expect(context.parameters.items.openDatasetItem).toBeCalledTimes(1); + expect(context.parameters.items.openDatasetItem).toBeCalledWith(firstCommandReference); + const outputs = component.getOutputs(); + expect(outputs.EventName).toEqual(OutputEvents.PersonaEvent); + }); + + it('check output events', () => { + const { component, context, notifyOutputChanged } = createComponent(); + component.init(context, notifyOutputChanged); + context.parameters.ShowAddButton.raw = true; + context.parameters.MaxDisplayablePersonas.raw = 1; + const facepileComponent = component.updateView(context); + + const firstCommandReference = { + id: { guid: '1' }, + name: '1', + } as ComponentFramework.EntityReference; + + context.parameters.items.records['1'].getNamedReference = jest.fn().mockReturnValueOnce(firstCommandReference); + const facepile = mount(facepileComponent); + const addbtnElement = facepile.find('.ms-Facepile-addButton').first(); + expect(addbtnElement.length).toEqual(1); + + addbtnElement.simulate('click'); + let outputs = component.getOutputs(); + expect(outputs.EventName).toEqual(OutputEvents.AddButtonEvent); + + const overflowElement = facepile.find('.ms-Facepile-descriptiveOverflowButton').first(); + expect(overflowElement.length).toEqual(1); + overflowElement.simulate('click'); + outputs = component.getOutputs(); + expect(outputs.EventName).toEqual(OutputEvents.OverFlowButtonEvent); + }); + + it('check overflowbutton behaviour', () => { + const { component, context, notifyOutputChanged } = createComponent(); + component.init(context, notifyOutputChanged); + context.parameters.ShowAddButton.raw = true; + context.parameters.MaxDisplayablePersonas.raw = 1; + let facepileComponent = component.updateView(context); + let facepile = mount(facepileComponent); + let overflowElement = facepile.find('.ms-Facepile-descriptiveOverflowButton').first(); + expect(overflowElement.length).toEqual(1); + + context.parameters.OverflowButtonType.raw = 'more'; + facepileComponent = component.updateView(context); + facepile = mount(facepileComponent); + overflowElement = facepile.find('.ms-Facepile-overflowButton').first(); + expect(overflowElement.length).toEqual(1); + + context.parameters.OverflowButtonType.raw = 'none'; + facepileComponent = component.updateView(context); + facepile = mount(facepileComponent); + overflowElement = facepile.find('.ms-Facepile-overflowButton').first(); + expect(overflowElement.length).toEqual(0); + }); + + it('theme', async () => { + const { component, context, container, notifyOutputChanged } = createComponent(); + context.parameters.Theme.raw = JSON.stringify({ + palette: { + themePrimary: '#0078d4', + }, + }); + act(() => { + component.init(context, notifyOutputChanged); + component.updateView(context); + }); + + expect(container).toMatchSnapshot(); + }); +}); + +function createComponent() { + const component = new Facepile(); + const notifyOutputChanged = jest.fn(); + const context = new MockContext(getMockParameters()); + context.parameters.PersonaSize.raw = 'Size8'; + context.parameters.OverflowButtonType.raw = 'descriptive'; + context.parameters.ImageShouldFadeIn.raw = true; + context.parameters.items = getMockDataSet('away'); + const state = new MockState(); + const container = document.createElement('div'); + document.body.appendChild(container); + return { component, context, container, notifyOutputChanged, state }; +} + +function getMockDataSet(presence: string) { + return new MockDataSet([ + new MockEntityRecord('1', { + [ItemColumns.Key]: 'item1', + [ItemColumns.DisplayName]: 'Diego Siciliani', + [ItemColumns.ImageUrl]: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-male.png', + [ItemColumns.Clickable]: true, + [ItemColumns.Presence]: presence, + [ItemColumns.IsImage]: false, + }), + new MockEntityRecord('2', { + [ItemColumns.Key]: 'item2', + [ItemColumns.DisplayName]: 'Megan Bowen', + [ItemColumns.ImageUrl]: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-female.png', + [ItemColumns.Clickable]: true, + [ItemColumns.Presence]: presence, + [ItemColumns.IsImage]: false, + }), + ]); +} diff --git a/Facepile/Facepile/components/CanvasFacepile.tsx b/Facepile/Facepile/components/CanvasFacepile.tsx new file mode 100644 index 00000000..f8e32771 --- /dev/null +++ b/Facepile/Facepile/components/CanvasFacepile.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { Facepile as CustomFacepile } from '../fluenui-fork/Facepile/Facepile'; +import { IFacepilePersona, ThemeProvider, createTheme, IPartialTheme } from '@fluentui/react'; +import { IFacepileprops } from './Component.types'; +import { getFacepilePersonas } from './DatasetMapping'; +import { getPersonaPresence } from './Helper'; +import { useAsync } from '@fluentui/react-hooks'; +import { OutputEvents } from '../ManifestConstants'; + +export const CanvasFacepile = React.memo((props: IFacepileprops) => { + const { + width, + height, + imagesFadeIn, + displayedPersonas, + onSelected, + personaSize, + setFocus, + themeJSON, + ariaLabel, + items, + overflowButtonType, + showAddButton, + overflowButtonAriaLabel, + addbuttonAriaLabel, + tabIndex, + } = props; + + const theme = React.useMemo(() => { + try { + return themeJSON ? createTheme(JSON.parse(themeJSON) as IPartialTheme) : undefined; + } catch (ex) { + /* istanbul ignore next */ + console.error('Cannot parse theme', ex); + } + }, [themeJSON]); + + const getPersonaProps = React.useCallback( + (persona: IFacepilePersona) => ({ + imageShouldFadeIn: imagesFadeIn, + ...(typeof persona.data !== 'undefined' && { presence: getPersonaPresence(persona.data) }), + }), + [imagesFadeIn], + ); + + const overflowButtonProps = { + ariaLabel: overflowButtonAriaLabel, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onClick: (ev: React.MouseEvent) => onSelected(OutputEvents.OverFlowButtonEvent), + }; + + const addButtonProps = { + ariaLabel: addbuttonAriaLabel, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onClick: (ev: React.MouseEvent) => onSelected(OutputEvents.AddButtonEvent), + }; + + // Provide an explicit size so that the ResizeGroup measurements are correct + const rootStyle = React.useMemo(() => { + return { + display: 'block', + position: 'absolute', + width: width, + height: height, + } as React.CSSProperties; + }, [width, height]); + + const onClick = React.useCallback( + (ev?: unknown, item?: IFacepilePersona) => { + const selectedItem = item && items.find((i: IFacepilePersona) => i.id === item?.id); + if (selectedItem) onSelected(OutputEvents.PersonaEvent, selectedItem); + }, + [items, onSelected], + ); + + const rootRef = React.useRef(null); + const async = useAsync(); + React.useEffect(() => { + if (setFocus && setFocus !== '' && rootRef) { + async.requestAnimationFrame(() => { + // We can't call focus() on the imperative componentRef because of a bug in ResizeGroup + // that causes the componentRef.current to be nulled + // See https://github.com/microsoft/fluentui/issues/22844 + const buttons = (rootRef.current as HTMLElement).getElementsByTagName('button'); + if (buttons && buttons.length > 0) { + buttons[0].focus(); + } + }); + } + }, [setFocus, rootRef, async]); + + const facepilePersonas: IFacepilePersona[] = getFacepilePersonas(items, onClick); + + return ( + + + + ); +}); +CanvasFacepile.displayName = 'CanvasFacepile'; diff --git a/Facepile/Facepile/components/Component.types.ts b/Facepile/Facepile/components/Component.types.ts new file mode 100644 index 00000000..49113cc5 --- /dev/null +++ b/Facepile/Facepile/components/Component.types.ts @@ -0,0 +1,36 @@ +import { PersonaSize, IFacepilePersona, OverflowButtonType } from '@fluentui/react'; + +export interface IFacepileprops { + width?: number; + height?: number; + text?: string; + textColor?: string; + imagesFadeIn?: boolean; + displayedPersonas?: number; + personaSize: PersonaSize; + items: ICustomFacepile[]; + disabled?: boolean; + tabIndex?: number; + ariaLabel?: string; + themeJSON?: string; + setFocus?: string; + overflowButtonType?: OverflowButtonType; + onSelected: (eventName: string, item?: IFacepilePersona) => void; + showAddButton: boolean; + overflowButtonAriaLabel: string; + addbuttonAriaLabel: string; +} + +export interface ICustomFacepile { + id: string; + key: string; + personaName: string; + imageUrl: string; + presence: string; + imageInitials?: string; + initialsColor?: number; + clickable: boolean; + onClick?: (ev: unknown, persona?: IFacepilePersona) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any; +} diff --git a/Facepile/Facepile/components/DatasetMapping.ts b/Facepile/Facepile/components/DatasetMapping.ts new file mode 100644 index 00000000..7486d3fc --- /dev/null +++ b/Facepile/Facepile/components/DatasetMapping.ts @@ -0,0 +1,72 @@ +import { IFacepilePersona } from '@fluentui/react'; +import { ItemColumns } from '../ManifestConstants'; +import { ICustomFacepile } from './Component.types'; +import { undefinedIfNullish, getImageUrl, getUrlfromImage } from './Helper'; + +export function getitemFromDataset(dataset: ComponentFramework.PropertyTypes.DataSet): ICustomFacepile[] { + if (dataset.error || dataset.paging.totalResultCount === undefined) { + // Dataset is not defined so return dummy items + return getDummyAction(); + } + const keyIndex: Record = {}; + return dataset.sortedRecordIds.map((id) => { + const record = dataset.records[id]; + // Prevent duplicate keys by appending the duplicate index + let key = record.getValue(ItemColumns.Key) as string; + + if (keyIndex[key] !== undefined) { + keyIndex[key]++; + key += `_${keyIndex[key]}`; + } else keyIndex[key] = 1; + return { + id: record.getRecordId(), + key: key, + personaName: record.getFormattedValue(ItemColumns.DisplayName), + imageUrl: undefinedIfNullish(record.getValue(ItemColumns.IsImage) as boolean) + ? getUrlfromImage(record) + : getImageUrl(record), + data: record, + presence: record.getValue(ItemColumns.Presence) as string, + clickable: (record.getValue(ItemColumns.Clickable) as boolean) ?? false, + } as ICustomFacepile; + }); +} + +export function getFacepilePersonas( + items: ICustomFacepile[], + onClick: (ev?: unknown, item?: IFacepilePersona) => void, +): ICustomFacepile[] { + return items.map((item: ICustomFacepile) => ({ + id: item.id, + key: item.key, + personaName: item.personaName, + presence: item.presence, + imageUrl: item.imageUrl, + ...(item.clickable && { onClick: onClick }), + })) as ICustomFacepile[]; +} + +function getDummyAction(): ICustomFacepile[] { + return [ + { + id: '1', + key: '1', + personaName: 'Megan Bowen', + imageUrl: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-female.png', + imageInitials: 'Item ' + 1, + initialsColor: 1, + presence: 'none', + }, + { + id: '2', + key: '2', + personaName: 'Diego Siciliano', + imageUrl: + 'https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-male.png', + imageInitials: 'Item ' + 1, + initialsColor: 1, + presence: 'none', + }, + ] as ICustomFacepile[]; +} diff --git a/Facepile/Facepile/components/Helper.ts b/Facepile/Facepile/components/Helper.ts new file mode 100644 index 00000000..ad058c83 --- /dev/null +++ b/Facepile/Facepile/components/Helper.ts @@ -0,0 +1,79 @@ +import { ItemColumns } from '../ManifestConstants'; +import { PersonaSize, OverflowButtonType, PersonaPresence } from '@fluentui/react'; + +export function getUrlfromImage(record: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const imageData = undefinedIfNullish(record.getValue(ItemColumns.Image) as any) ?? ''; + return imageData ? `data:image/jpeg;base64, ${imageData.fileContent}` : imageData; +} + +export function getImageUrl(record: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord): string { + return (record.getValue(ItemColumns.ImageInfo) as string) ?? ''; +} + +export function undefinedIfNullish(value: T): T | undefined { + return defaultIfNullish(value, undefined); +} +function defaultIfNullish(value: T, defaultValue: T) { + return (value as T) ? value : defaultValue; +} + +export function getPersonaPresence(record: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord): PersonaPresence { + const personaPresence = undefinedIfNullish(record.getValue(ItemColumns.Presence)); + if (personaPresence) { + switch ((personaPresence as string).toLowerCase()) { + case 'away': + return PersonaPresence.away; + case 'blocked': + return PersonaPresence.blocked; + case 'busy': + return PersonaPresence.busy; + case 'dnd': + return PersonaPresence.dnd; + case 'none': + return PersonaPresence.none; + case 'offline': + return PersonaPresence.offline; + case 'online': + return PersonaPresence.online; + default: + return PersonaPresence.none; + } + } else { + return PersonaPresence.none; + } +} + +export function getPersonaSize(sizeSelected: string): PersonaSize { + switch (sizeSelected.toLowerCase()) { + case 'size8': + return PersonaSize.size8; + case 'size24': + return PersonaSize.size24; + case 'size32': + return PersonaSize.size32; + case 'size40': + return PersonaSize.size40; + case 'size48': + return PersonaSize.size48; + case 'size56': + return PersonaSize.size56; + default: + return PersonaSize.size32; + } +} + +export function getOverflowButtonType(btnTypeSelected: string): OverflowButtonType { + switch (btnTypeSelected.toLowerCase()) { + case 'none': + return OverflowButtonType.none; + case 'descriptive': + return OverflowButtonType.descriptive; + case 'downarrow': + return OverflowButtonType.downArrow; + case 'more': + return OverflowButtonType.more; + default: + return OverflowButtonType.descriptive; + } +} diff --git a/Facepile/Facepile/fluenui-fork/BaseButton.styles.ts b/Facepile/Facepile/fluenui-fork/BaseButton.styles.ts new file mode 100644 index 00000000..e9bb2529 --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/BaseButton.styles.ts @@ -0,0 +1,131 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import { memoizeFunction } from '@fluentui/react'; +import { HighContrastSelector, getFocusStyle, hiddenContentStyle } from '@fluentui/react'; +import type { IButtonStyles } from '@fluentui/react'; +import type { ITheme, IRawStyle } from '@fluentui/react'; + +const noOutline: IRawStyle = { + outline: 0, +}; + +const iconStyle = (fontSize?: string | number): IRawStyle => { + return { + fontSize: fontSize, + margin: '0 4px', + height: '16px', + lineHeight: '16px', + textAlign: 'center', + flexShrink: 0, + }; +}; + +/** + * Gets the base button styles. Note: because it is a base class to be used with the `mergeRules` + * helper, it should have values for all class names in the interface. This let `mergeRules` optimize + * mixing class names together. + */ +export const getStyles = memoizeFunction( + (theme: ITheme): IButtonStyles => { + const { semanticColors, effects, fonts } = theme; + + const border = semanticColors.buttonBorder; + const disabledBackground = semanticColors.disabledBackground; + const disabledText = semanticColors.disabledText; + const buttonHighContrastFocus = { + left: -2, + top: -2, + bottom: -2, + right: -2, + outlineColor: 'ButtonText', + }; + + return { + root: [ + getFocusStyle(theme, { inset: 1, highContrastStyle: buttonHighContrastFocus, borderColor: 'transparent' }), + theme.fonts.medium, + { + boxSizing: 'border-box', + border: '1px solid ' + border, + userSelect: 'none', + display: 'inline-block', + textDecoration: 'none', + textAlign: 'center', + cursor: 'pointer', + padding: '0 16px', + borderRadius: effects.roundedCorner2, + + selectors: { + // IE11 workaround for preventing shift of child elements of a button when active. + ':active > span': { + position: 'relative', + left: 0, + top: 0, + }, + }, + }, + ], + + rootDisabled: [ + getFocusStyle(theme, { inset: 1, highContrastStyle: buttonHighContrastFocus, borderColor: 'transparent' }), + { + backgroundColor: disabledBackground, + borderColor: disabledBackground, + color: disabledText, + cursor: 'default', + selectors: { + ':hover': noOutline, + ':focus': noOutline, + }, + }, + ], + + iconDisabled: { + color: disabledText, + selectors: { + [HighContrastSelector]: { + color: 'GrayText', + }, + }, + }, + + menuIconDisabled: { + color: disabledText, + selectors: { + [HighContrastSelector]: { + color: 'GrayText', + }, + }, + }, + + flexContainer: { + display: 'flex', + height: '100%', + flexWrap: 'nowrap', + justifyContent: 'center', + alignItems: 'center', + }, + description: { + display: 'block', + }, + + textContainer: { + flexGrow: 1, + display: 'block', + }, + + icon: iconStyle(fonts.mediumPlus.fontSize), + + menuIcon: iconStyle(fonts.small.fontSize), + + label: { + margin: '0 4px', + lineHeight: '100%', + display: 'block', + }, + + screenReaderText: hiddenContentStyle, + }; + }, +); diff --git a/Facepile/Facepile/fluenui-fork/Facepile/Facepile.base.tsx b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.base.tsx new file mode 100644 index 00000000..b5665f04 --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.base.tsx @@ -0,0 +1,340 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ + +/* +CanvasApp TabIndex issue +------------------------ +This custom version of Facepile.Base is required to add the tabindex to the facepile buttons of the component +because at this time canvas apps adds a positive tabindex to all elements rather than relying on +dom ordering. +*/ +import * as React from 'react'; +import { buttonProperties, classNamesFunction, getId, getNativeProps, initializeComponentRef } from '@fluentui/react'; +import { OverflowButtonType } from '@fluentui/react'; +import { FacepileButton } from './FacepileButton'; +import { Icon } from '@fluentui/react'; +import { Persona } from '@fluentui/react'; +import { PersonaCoin, PersonaSize, PersonaInitialsColor, Button, BaseButton } from '@fluentui/react'; +import type { IFacepileProps, IFacepilePersona, IFacepileStyleProps, IFacepileStyles } from './Facepile.types'; +import type { IPersonaStyles } from '@fluentui/react'; +import type { IButtonProps } from '@fluentui/react'; + +const getClassNames = classNamesFunction(); + +/** + * FacePile with no default styles. + * [Use the `styles` API to add your own styles.](https://github.com/microsoft/fluentui/wiki/Component-Styling) + */ +export class FacepileBase extends React.Component { + public static defaultProps: IFacepileProps = { + maxDisplayablePersonas: 5, + personas: [], + overflowPersonas: [], + personaSize: PersonaSize.size32, + }; + + private _ariaDescriptionId: string; + + private _classNames = getClassNames(this.props.styles, { + theme: this.props.theme!, + className: this.props.className, + }); + + constructor(props: IFacepileProps) { + super(props); + + initializeComponentRef(this); + this._ariaDescriptionId = getId(); + } + + public render(): JSX.Element { + let { overflowButtonProps } = this.props; + const { + chevronButtonProps, // eslint-disable-line deprecation/deprecation + maxDisplayablePersonas, + personas, + overflowPersonas, + showAddButton, + ariaLabel, + showTooltip = true, + //Issue 1: Including tabindex + tabIndex + } = this.props; + + const { _classNames } = this; + + // Add a check to make sure maxDisplayalePersonas is defined to cover the edge case of it being 0. + const numPersonasToShow: number = + typeof maxDisplayablePersonas === 'number' ? Math.min(personas.length, maxDisplayablePersonas) : personas.length; + + // Added for deprecating chevronButtonProps. Can remove after v1.0 + if (chevronButtonProps && !overflowButtonProps) { + overflowButtonProps = chevronButtonProps; + } + + const hasOverflowPersonas = overflowPersonas && overflowPersonas.length > 0; + const personasPrimary: IFacepilePersona[] = hasOverflowPersonas ? personas : personas.slice(0, numPersonasToShow); + const personasOverflow: IFacepilePersona[] = + (hasOverflowPersonas ? overflowPersonas : personas.slice(numPersonasToShow)) || []; + + return ( + //Issue 1: Passing tabIndex received as parameter to onRenderAriaDescription,_getAddNewElement & _onRenderVisiblePersonas +
+ {this.onRenderAriaDescription()} +
+ {showAddButton ? this._getAddNewElement(tabIndex!) : null} +
    + {this._onRenderVisiblePersonas( + personasPrimary, + personasOverflow.length === 0 && personas.length === 1, + showTooltip, + tabIndex! + )} +
+ {overflowButtonProps ? this._getOverflowElement(personasOverflow, tabIndex!) : null} +
+
+ ); + } + + protected onRenderAriaDescription() { + const { ariaDescription } = this.props; + + const { _classNames } = this; + + // If ariaDescription is given, descriptionId will be assigned to ariaDescriptionSpan, + // otherwise it will be assigned to descriptionSpan. + return ( + ariaDescription && ( + + {ariaDescription} + + ) + ); + } + + private _onRenderVisiblePersonas( + personas: IFacepilePersona[], + singlePersona: boolean, + showTooltip: boolean, + tabIndex: number + ): JSX.Element[] { + const { + onRenderPersona = this._getPersonaControl, + onRenderPersonaCoin = this._getPersonaCoinControl, + onRenderPersonaWrapper, + } = this.props; + return personas.map((persona: IFacepilePersona, index: number) => { + const personaControl: JSX.Element | null = singlePersona + ? onRenderPersona(persona) : onRenderPersonaCoin(persona); + //? onRenderPersona(persona, this._getPersonaControl) + //: onRenderPersonaCoin(persona, this._getPersonaCoinControl); + const defaultPersonaRender = persona.onClick + ? () => this._getElementWithOnClickEvent(personaControl, persona, showTooltip, index, tabIndex) + : () => this._getElementWithoutOnClickEvent(personaControl, persona, showTooltip, index); + + return ( +
  • + {onRenderPersonaWrapper ? onRenderPersonaWrapper(persona, defaultPersonaRender) : defaultPersonaRender()} +
  • + ); + }); + } + + private _getPersonaControl = (persona: IFacepilePersona): JSX.Element | null => { + const { getPersonaProps, personaSize } = this.props; + const personaStyles: Partial = { + details: { + flex: '1 0 auto', + }, + }; + + return ( + + ); + }; + + private _getPersonaCoinControl = (persona: IFacepilePersona): JSX.Element | null => { + const { getPersonaProps, personaSize } = this.props; + return ( + + ); + }; + + private _getElementWithOnClickEvent( + personaControl: JSX.Element | null, + persona: IFacepilePersona, + showTooltip: boolean, + index: number, + tabIndex: number + ): JSX.Element { + const { keytipProps } = persona; + return ( + + {personaControl} + + ); + } + + private _getElementWithoutOnClickEvent( + personaControl: JSX.Element | null, + persona: IFacepilePersona, + showTooltip: boolean, + index: number, + ): JSX.Element { + return ( +
    + {personaControl} +
    + ); + } + + private _getElementProps( + persona: IFacepilePersona, + showTooltip: boolean, + index: number, + ): { key: React.Key;['data-is-focusable']: boolean } & React.HTMLAttributes { + const { _classNames } = this; + + return { + key: (persona.imageUrl ? 'i' : '') + index, + 'data-is-focusable': true, + className: _classNames.itemButton, + title: showTooltip ? persona.personaName : undefined, + onMouseMove: this._onPersonaMouseMove.bind(this, persona), + onMouseOut: this._onPersonaMouseOut.bind(this, persona), + }; + } + + private _getOverflowElement(personasOverflow: IFacepilePersona[], tabindex: number): JSX.Element | null { + switch (this.props.overflowButtonType) { + case OverflowButtonType.descriptive: + return this._getDescriptiveOverflowElement(personasOverflow, tabindex); + case OverflowButtonType.downArrow: + return this._getIconElement('ChevronDown', tabindex); + case OverflowButtonType.more: + return this._getIconElement('More', tabindex); + default: + return null; + } + } + + private _getDescriptiveOverflowElement(personasOverflow: IFacepilePersona[], tabIndex: number): JSX.Element | null { + const { personaSize } = this.props; + if (!personasOverflow || personasOverflow.length < 1) { + return null; + } + + const personaNames: string = personasOverflow.map((p: IFacepilePersona) => p.personaName).join(', '); + const overflowButtonProps: IButtonProps = { ...{ title: personaNames }, ...this.props.overflowButtonProps }; + const numPersonasNotPictured: number = Math.max(personasOverflow.length, 0); + + const { _classNames } = this; + + return ( + + + + ); + } + + private _getIconElement(icon: string, tabIndex: number): JSX.Element { + const { overflowButtonProps, personaSize } = this.props; + const overflowInitialsIcon = true; + + const { _classNames } = this; + + return ( + + + + ); + } + private _getAddNewElement(tabIndex: number): JSX.Element { + const { addButtonProps, personaSize } = this.props; + + const { _classNames } = this; + + return ( + + + + ); + } + + private _onPersonaClick(persona: IFacepilePersona, ev?: React.MouseEvent): void { + persona.onClick!(ev, persona); + ev!.preventDefault(); + ev!.stopPropagation(); + } + + private _onPersonaMouseMove(persona: IFacepilePersona, ev?: React.MouseEvent): void { + if (persona.onMouseMove) { + persona.onMouseMove(ev, persona); + } + } + + private _onPersonaMouseOut(persona: IFacepilePersona, ev?: React.MouseEvent): void { + if (persona.onMouseOut) { + persona.onMouseOut(ev, persona); + } + } + + private _renderInitials(iconName: string, overflowButton?: boolean): () => JSX.Element { + const { _classNames } = this; + + return (): JSX.Element => { + return ; + }; + } + + private _renderInitialsNotPictured(numPersonasNotPictured: number): () => JSX.Element { + const { _classNames } = this; + + return (): JSX.Element => { + return ( + + {numPersonasNotPictured < 100 ? '+' + numPersonasNotPictured : '99+'} + + ); + }; + } +} diff --git a/Facepile/Facepile/fluenui-fork/Facepile/Facepile.styles.ts b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.styles.ts new file mode 100644 index 00000000..28dc28fb --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.styles.ts @@ -0,0 +1,143 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import { hiddenContentStyle, HighContrastSelector, getFocusStyle, getGlobalClassNames } from '@fluentui/react'; +import type { IFacepileStyleProps, IFacepileStyles } from '@fluentui/react'; +import type { IStyle } from '@fluentui/react'; + +const GlobalClassNames = { + root: 'ms-Facepile', + addButton: 'ms-Facepile-addButton ms-Facepile-itemButton', + descriptiveOverflowButton: 'ms-Facepile-descriptiveOverflowButton ms-Facepile-itemButton', + itemButton: 'ms-Facepile-itemButton ms-Facepile-person', + itemContainer: 'ms-Facepile-itemContainer', + members: 'ms-Facepile-members', + member: 'ms-Facepile-member', + overflowButton: 'ms-Facepile-overflowButton ms-Facepile-itemButton', +}; + +export const styles = (props: IFacepileStyleProps): IFacepileStyles => { + const { className, theme, spacingAroundItemButton = 2 } = props; + + const { palette, fonts } = theme; + const classNames = getGlobalClassNames(GlobalClassNames, theme); + + const ItemButtonStyles: IStyle = { + textAlign: 'center', + padding: 0, + borderRadius: '50%', + verticalAlign: 'top', + display: 'inline', + backgroundColor: 'transparent', + border: 'none', + selectors: { + '&::-moz-focus-inner': { + padding: 0, + border: 0, + }, + }, + }; + + return { + root: [ + classNames.root, + theme.fonts.medium, + { + width: 'auto', + }, + className, + ], + + addButton: [ + classNames.addButton, + getFocusStyle(theme, { inset: -1 }), + ItemButtonStyles, + { + fontSize: fonts.medium.fontSize, + color: palette.white, + backgroundColor: palette.themePrimary, + marginRight: spacingAroundItemButton * 2 + 'px', + selectors: { + '&:hover': { + backgroundColor: palette.themeDark, + }, + '&:focus': { + backgroundColor: palette.themeDark, + }, + '&:active': { + backgroundColor: palette.themeDarker, + }, + '&:disabled': { + backgroundColor: palette.neutralTertiaryAlt, + }, + }, + }, + ], + + descriptiveOverflowButton: [ + classNames.descriptiveOverflowButton, + getFocusStyle(theme, { inset: -1 }), + ItemButtonStyles, + { + fontSize: fonts.small.fontSize, + color: palette.neutralSecondary, + backgroundColor: palette.neutralLighter, + marginLeft: `${spacingAroundItemButton * 2}px`, + }, + ], + + itemButton: [classNames.itemButton, ItemButtonStyles], + + itemContainer: [ + classNames.itemContainer, + { + display: 'flex', + }, + ], + + members: [ + classNames.members, + { + display: 'flex', + overflow: 'hidden', + listStyleType: 'none', + padding: 0, + margin: `-${spacingAroundItemButton}px`, + }, + ], + + member: [ + classNames.member, + { + display: 'inline-flex', + flex: '0 0 auto', + margin: `${spacingAroundItemButton}px`, + }, + ], + + overflowButton: [ + classNames.overflowButton, + getFocusStyle(theme, { inset: -1 }), + ItemButtonStyles, + { + fontSize: fonts.medium.fontSize, + color: palette.neutralSecondary, + backgroundColor: palette.neutralLighter, + marginLeft: `${spacingAroundItemButton * 2}px`, + }, + ], + + overflowInitialsIcon: [ + { + color: palette.neutralPrimary, + selectors: { + [HighContrastSelector]: { + color: 'WindowText', + }, + }, + }, + ], + + screenReaderOnly: hiddenContentStyle, + }; +}; diff --git a/Facepile/Facepile/fluenui-fork/Facepile/Facepile.tsx b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.tsx new file mode 100644 index 00000000..1110a0f9 --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.tsx @@ -0,0 +1,19 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import * as React from 'react'; +import { styled } from '@fluentui/react'; +import { FacepileBase } from './Facepile.base'; +import { styles } from './Facepile.styles'; +import type { IFacepileProps, IFacepileStyleProps, IFacepileStyles } from './Facepile.types'; + +/** + * The Facepile shows a list of faces or initials in a horizontal lockup. Each circle represents a person. + */ +export const Facepile: React.FunctionComponent = styled< + IFacepileProps, + IFacepileStyleProps, + IFacepileStyles +>(FacepileBase, styles, undefined, { + scope: 'Facepile', +}); diff --git a/Facepile/Facepile/fluenui-fork/Facepile/Facepile.types.ts b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.types.ts new file mode 100644 index 00000000..13a857aa --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/Facepile.types.ts @@ -0,0 +1,224 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import * as React from 'react'; +import { FacepileBase } from '@fluentui/react'; +import { PersonaInitialsColor, PersonaSize } from '@fluentui/react'; +import type { IStyle, ITheme } from '@fluentui/react'; +import type { IRefObject, IRenderFunction, IStyleFunctionOrObject } from '@fluentui/react'; +import type { IButtonProps } from '@fluentui/react'; +import type { IPersonaSharedProps } from '@fluentui/react'; +import type { IKeytipProps, Button, BaseButton } from '@fluentui/react'; + +/** + * {@docCategory Facepile} + */ +export interface IFacepile { } + +/** + * {@docCategory Facepile} + */ +export interface IFacepileProps extends React.ClassAttributes { + /** + * Optional callback to access the IFacepile interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: IRefObject; + + /** + * Whether the default tooltip (the persona name) is shown using the `title` prop. + * Set this to false if you'd like to display a custom tooltip, for example using a custom renderer and TooltipHost + * @defaultvalue true + */ + showTooltip?: boolean; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: IStyleFunctionOrObject; + + /** + * Theme provided by High-Order Component. + */ + theme?: ITheme; + + /** + * Additional css class to apply to the Facepile + * @defaultvalue undefined + */ + className?: string; + + /** + * Array of IPersonaProps that define each Persona. + */ + personas: IFacepilePersona[]; + + /** + * Personas to place in the overflow + */ + overflowPersonas?: IFacepilePersona[]; + + /** Maximum number of personas to show */ + maxDisplayablePersonas?: number; + + /** Size to display the personas */ + personaSize?: PersonaSize; + + /** ARIA label for persona list */ + ariaDescription?: string; + + /** + * Defines the aria label that the screen readers use when focus goes on a list of personas. + */ + ariaLabel?: string; + + /** Show add person button */ + showAddButton?: boolean; + + /** Button properties for the add face button */ + addButtonProps?: IButtonProps; + + /** + * Deprecated at v0.70, use `overflowButtonProps` instead. + * @deprecated Use `overflowButtonProps` instead. + */ + chevronButtonProps?: IButtonProps; + + /** Properties for the overflow icon */ + overflowButtonProps?: IButtonProps; + + /** Type of overflow icon to use */ + overflowButtonType?: OverflowButtonType; + + /** Optional custom renderer for the persona, gets called when there is one persona in personas array*/ + onRenderPersona?: IRenderFunction; + + /** Optional custom renderer for the persona coins, gets called when there are multiple persona in personas array*/ + onRenderPersonaCoin?: IRenderFunction; + + /** Optional custom renderer for the FacepileButton that renders each clickable Persona */ + onRenderPersonaWrapper?: IRenderFunction; + + /** Method to access properties on the underlying Persona control */ + getPersonaProps?: (persona: IFacepilePersona) => IPersonaSharedProps; + + /** + * Issue 1 : Adding additional prop to achieve positive tab index + * @defaultvalue 0 + */ + tabIndex?: number; +} + +/** + * {@docCategory Facepile} + */ +export interface IFacepilePersona extends React.ButtonHTMLAttributes { + /** + * Name of the person. + */ + personaName?: string; + + /** + * Url to the image to use, should be a square aspect ratio and big enough to fit in the image area. + */ + imageUrl?: string; + + /** + * The user's initials to display in the image area when there is no image. + * @defaultvalue Derived from `personaName` + */ + imageInitials?: string; + + /** + * Whether initials are calculated for phone numbers and number sequences. + * Example: Set property to true to get initials for project names consisting of numbers only. + * @defaultvalue false + */ + allowPhoneInitials?: boolean; + + /** + * The background color when the user's initials are displayed. + * @defaultvalue Derived from `personaName` + */ + initialsColor?: PersonaInitialsColor; + + /** + * If provided, persona will be rendered with cursor:pointer and the handler will be + * called on click. + */ + onClick?: (ev?: React.MouseEvent, persona?: IFacepilePersona) => void | undefined; + + /** + * If provided, the handler will be called on mouse move. + */ + onMouseMove?: (ev?: React.MouseEvent, persona?: IFacepilePersona) => void; + + /** + * If provided, the handler will be called when mouse moves out of the component. + */ + onMouseOut?: (ev?: React.MouseEvent, persona?: IFacepilePersona) => void; + + /** + * Extra data - not used directly but can be handy for passing additional data to custom event + * handlers. + */ + data?: any; + + /** + * Optional keytip for this button that is only added when 'onClick' is defined for the persona + */ + keytipProps?: IKeytipProps; +} + +/** + * {@docCategory Facepile} + */ +export enum OverflowButtonType { + /** No overflow */ + none = 0, + /** +1 overflow icon */ + descriptive = 1, + /** More overflow icon */ + more = 2, + /** Chevron overflow icon */ + downArrow = 3, +} + +/** + * {@docCategory Facepile} + */ +export interface IFacepileStyleProps { + /** + * Theme provided by High-Order Component. + */ + theme: ITheme; + + /** + * Accept custom classNames + */ + className?: string; + + /** + * Pixel value for spacing around button. Number value set in pixels + */ + spacingAroundItemButton?: number; +} + +/** + * {@docCategory Facepile} + */ +export interface IFacepileStyles { + /** + * Style for the root element. + */ + root: IStyle; + addButton: IStyle; + descriptiveOverflowButton: IStyle; + itemContainer: IStyle; + itemButton: IStyle; + members: IStyle; + member: IStyle; + overflowButton: IStyle; + overflowInitialsIcon: IStyle; + screenReaderOnly: IStyle; +} diff --git a/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.styles.ts b/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.styles.ts new file mode 100644 index 00000000..1b8a9d5e --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.styles.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import { concatStyleSets } from '@fluentui/react'; +import { memoizeFunction } from '@fluentui/react'; +import { getStyles as getBaseButtonStyles } from '../BaseButton.styles' +import type { ITheme } from '@fluentui/react'; +import type { IButtonStyles } from '@fluentui/react'; + +export const getStyles = memoizeFunction( + (theme: ITheme, className?: string, customStyles?: IButtonStyles): IButtonStyles => { + const baseButtonStyles: IButtonStyles = getBaseButtonStyles(theme); + + const customButtonStyles = concatStyleSets(baseButtonStyles, customStyles)!; + + return { + ...customButtonStyles, + root: [baseButtonStyles.root, className, theme.fonts.medium, customStyles && customStyles.root], + } as IButtonStyles; + }, +); diff --git a/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.tsx b/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.tsx new file mode 100644 index 00000000..92639790 --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Facepile/FacepileButton.tsx @@ -0,0 +1,25 @@ +/* istanbul ignore file */ +/* eslint-disable */ +/* eslint-disable prettier/prettier */ +import * as React from 'react'; +import { BaseButton, IButtonProps, customizable, nullRender } from '@fluentui/react'; +import { getStyles } from './FacepileButton.styles'; + + +@customizable('FacepileButton', ['theme', 'styles'], true) +export class FacepileButton extends React.Component { + public render(): JSX.Element { + const { className, styles, ...rest } = this.props; + + const customStyles = getStyles(this.props.theme!, className, styles); + + return ( + + ); + } +} diff --git a/Facepile/Facepile/fluenui-fork/Readme.md b/Facepile/Facepile/fluenui-fork/Readme.md new file mode 100644 index 00000000..9ef46df4 --- /dev/null +++ b/Facepile/Facepile/fluenui-fork/Readme.md @@ -0,0 +1,17 @@ +# Fluent UI Fork + +This folder contains forked components of the fluentui library. + +## Why custom versions? + +The following issues are addressed with this custom set of components: + +### ISSUE 1: Positive tabindex + +Canvas Apps set positive tabindexes for it's components, making it important to use positive tabindexes for fluent ui components otherwise the tab order will be inconsistent - DOM order vs explicit positive tabindex. + +The Facepile component do not provide a facility to provide custom implementation of tabIndex, so it is forked to add additional prop(tabIndex) that allows setting correct tabindexes. + +Below are the list of files altered to achieve/assign custom tabindex. +- Facepile.base.tsx +- Facepile.types.ts \ No newline at end of file diff --git a/Facepile/Facepile/index.ts b/Facepile/Facepile/index.ts new file mode 100644 index 00000000..01ec839f --- /dev/null +++ b/Facepile/Facepile/index.ts @@ -0,0 +1,97 @@ +import { IInputs, IOutputs } from './generated/ManifestTypes'; +import { CanvasFacepile } from './components/CanvasFacepile'; +import { IFacepileprops, ICustomFacepile } from './components/Component.types'; +import { getitemFromDataset } from './components/DatasetMapping'; +import { getPersonaSize, getOverflowButtonType } from './components/Helper'; +import { ContextEx } from './ContextExtended'; +import * as React from 'react'; +import { IFacepilePersona } from '@fluentui/react'; +import { InputEvents, ManifestPropertyNames } from './ManifestConstants'; + +export class Facepile implements ComponentFramework.ReactControl { + notifyOutputChanged: () => void; + onClick: (ev: unknown, persona: IFacepilePersona) => void; + context: ComponentFramework.Context; + eventName: string | undefined = undefined; + items: ICustomFacepile[]; + inputEvent?: string | null; + focusKey = ''; + /** + * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here. + * Data-set values are not initialized here, use updateView. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions. + * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously. + * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface. + */ + public init(context: ComponentFramework.Context, notifyOutputChanged: () => void): void { + this.context = context; + this.notifyOutputChanged = notifyOutputChanged; + this.context.mode.trackContainerResize(true); + } + + /** + * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions + * @returns ReactElement root react element for the control + */ + public updateView(context: ComponentFramework.Context): React.ReactElement { + const allocatedWidth = parseInt(context.mode.allocatedWidth as unknown as string); + const allocatedHeight = parseInt(context.mode.allocatedHeight as unknown as string); + const tabIndex = (context as unknown as ContextEx).accessibility?.assignedTabIndex ?? undefined; + const ariaLabel = context.parameters?.AccessibilityLabel.raw ?? ''; + const dataset = context.parameters.items; + const datasetChanged = context.updatedProperties.indexOf(ManifestPropertyNames.dataset) > -1 || !this.items; + const inputEvent = this.context.parameters.InputEvent.raw; + const eventChanged = inputEvent && this.inputEvent !== inputEvent; + if (eventChanged && inputEvent.startsWith(InputEvents.SetFocus)) { + // Simulate SetFocus until this is unlocked by the platform + this.focusKey = inputEvent; + } + + if (datasetChanged) { + this.items = getitemFromDataset(dataset); + } + + const props: IFacepileprops = { + height: allocatedHeight, + width: allocatedWidth, + tabIndex: tabIndex, + items: this.items, + setFocus: this.focusKey, + displayedPersonas: context.parameters.MaxDisplayablePersonas.raw ?? 5, + personaSize: getPersonaSize(context.parameters.PersonaSize.raw), + overflowButtonType: getOverflowButtonType(context.parameters.OverflowButtonType.raw), + ariaLabel: ariaLabel, + imagesFadeIn: context.parameters.ImageShouldFadeIn.raw, + onSelected: this.onSelect, + showAddButton: context.parameters.ShowAddButton.raw, + overflowButtonAriaLabel: context.parameters.OverflowButtonAriaLabel.raw ?? '', + addbuttonAriaLabel: context.parameters.AddbuttonAriaLabel.raw ?? '', + }; + return React.createElement(CanvasFacepile, props); + } + + /** + * It is called by the framework prior to a control receiving new data. + * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output” + */ + public getOutputs(): IOutputs { + return { EventName: this.eventName }; + } + + onSelect = (eventName: string, itemPersona?: IFacepilePersona): void => { + if (itemPersona && itemPersona.data) { + this.context.parameters.items.openDatasetItem(itemPersona.data.getNamedReference()); + } + this.eventName = eventName; + this.notifyOutputChanged(); + }; + + /** + * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup. + * i.e. cancelling any pending remote calls, removing listeners, etc. + */ + public destroy(): void { + // Add code to cleanup control if necessary + } +} diff --git a/Facepile/Facepile/strings/Facepile.1033.resx b/Facepile/Facepile/strings/Facepile.1033.resx new file mode 100644 index 00000000..2b5bc156 --- /dev/null +++ b/Facepile/Facepile/strings/Facepile.1033.resx @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Fluent Facepile (%VERSION%) Preview + + + Accessibility label + + + + Theme + + + Input event + + + Max Displayable Personas + + + Event Name + + + OverflowButton Type + + + Facepile component + + + Image Should FadeIn + + + Persona Name + + + Persona Key + + + Persona Image + + + PersonaI mageInfo + + + Persona Presence + + + Is Image + + + Persona Clickable + + + Show Add Button + + + Add button Aria Label + + + Overflow Button Aria Label + + diff --git a/Facepile/README.md b/Facepile/README.md new file mode 100644 index 00000000..4d090043 --- /dev/null +++ b/Facepile/README.md @@ -0,0 +1,239 @@ +# Facepile + +This code component provides a wrapper around the [Fluent UI Facepile](https://developer.microsoft.com/en-us/fluentui#/controls/web/facepile) control for use in canvas & custom pages. + +| Canvas apps | Custom pages | Model-driven apps | Portals | +| ----------- | ------------ | ----------------- | ------- | +| ✅ | ✅ | ⬜ | ⬜ | + +## Configuration + +The control accepts the following properties: + +- **Items** - The action items to render + - **ItemPersonaName** - Display Name of the Persona. + - **ItemPersonaKey** - The key identify the specific Item. The key must be unique. + - **ItemPersonaImage** - The Image Column of Dataverse table which contains Persona Image(Profile Picture). + - **ItemPersonaImageInfo** - Url or Base64 Content of Persona Image(Profile Picture). + - **ItemPersonaPresence** - Optional - To defined the Presence of the persona. + - **IsImage** - Whether the persona image(ItemPersonaImage) is a Image Column of Dataverse table. This allows the component to render the image based on the type(Url or Image). True incase Image needs to refered from Dataverse table & false, incase, its a Url or Base64 to be referred from ItemPersonaImageInfo property. + - **ItemPersonaClickable** - Whether or not the persona should be clickable. + +- **PersonaSize** - Size of the persona to appear on screen. + +- **OverflowButtonType** - To choose which type of Overflow button to appear and whether to appear or not. + +- **MaxDisplayablePersonas** - Maximum number of Persona to appear of the Facepile. Five is the default and recommended number. + +- **ImageShouldFadeIn** - Whether the image should have a Fade In effect while appearing. + +- **ShowAddButton** - Whether Add Button should appear in Facepile component. + +- **OverflowButtonLabel** - Aria label for Overflow button + +- **AddbuttonAriaLabel** - Aria label for Add button + +### Usage + +Following are the exmples on how to use the Facepile component. + +Note that facepile completely support other sources from where the input collection can be retrieved. + +## With Office365Users Connector + +To generate the input collection using Office365Users connector, to pass as items property, refer the below sample code. + +```Power Fx +ClearCollect( + UserPersonas, + AddColumns( + // Get first 10 users who have email ID - optional + FirstN( + Filter( + Office365Users.SearchUser(), + Mail <> Blank() + ), + 10 + ), + "ItemPersonaKey", + Mail, + "ItemPersonaName", + DisplayName, + "IsImage", + false, + "ItemPersonaImageInfo", + //Get base64 image data + Substitute( + JSON( + Office365Users.UserPhotoV2(Id), + JSONFormat.IncludeBinaryData + ), + """", + "" + ), + "ItemPersonaPresence", + "Away", + "ItemPersonaClickable", + true + ) +); +``` + +## With Dataverse table + +Consider Standard User table with following schema + +|Column Name (Internal Name)|Property to Map|Data Type|Managed|Comments| +|--|--|--|--|--| +|Full Name (fullname)|ItemPersonaName|Text|Yes|| +|Primary Email (internalemailaddress)|ItemPersonaKey|Text|Yes|| +|User Image (cat_userimage)|ItemPersonaImage|Image |No|| +|Is Image (cat_isimage)|IsImage|Formula(Yes/No)|No|Formula column ```If(IsBlank(cat_UserImageId),false,true)``` | +|ItemPersonaClickable (cat_isClickable)|ItemPersonaClickable|Yes/No|No|| + +Add Users or specific users(using below code) from table to the items collection of facepile. + +```Power Fx +// To pick specific members from User table - optional +Filter(Users,'Full Name' = "Megan Bowen" || 'Full Name' = "Diego Siciliani" ) + +``` + +## Capture Output event + +Inorder to interact with Facepile and continue with rest of the operation, refer the below sample code which needs to be placed in OnChange event. + +```Power Fx +Switch( + Self.EventName, + "PersonaEvent", + //Pick one depending upon the data source + //Incase of Office365Users + Notify(Self.Selected.ItemPersonaKey); + //Incase of Dataverse table + Notify(Self.Selected.'Primary Email'), + "AddButtonEvent", + //Define logic to Add Users + Notify("Add Users"), + "OverFlowButtonEvent", + //Define logic to Show more information + Notify("Show More Information") +) +``` + +### Style Properties + +- **Theme** - Accepts a JSON string that is generated using [Fluent UI Theme Designer (windows.net)](https://fabricweb.z5.web.core.windows.net/pr-deploy-site/refs/heads/master/theming-designer/). Leaving this blank will use the default theme defined by Power Apps. +- **AccessibilityLabel** - Screen reader aria-label + +### Event Properties + +- **InputEvent** - An event to send to the control. E.g. `SetFocus`. See below. + +### Setting Focus on the control + +When a new dialog is shown, and the default focus should be on the control, an explicit set focus will be needed. + +To make calls to the input event, you can set a context variable that is bound to the Input Event property to a string that starts with `SetFocus` and followed by a random element to ensure that the app detects it as a change. + +E.g. + +```vb +UpdateContext({ctxResizableTextareaEvent:"SetFocus" & Text(Rand())})); +``` + +The context variable `ctxResizableTextareaEvent` would then be bound to the property `Input Event` property. + +### Example Theme + +The following is an example of setting the theme based on the output from the [Fluent UI Theme Designer (windows.net)](https://fabricweb.z5.web.core.windows.net/pr-deploy-site/refs/heads/master/theming-designer/). + +```Power Fx +Set(varThemeBlue, { + palette: { + themePrimary: ColorValue("#0078d4"), + themeLighterAlt: ColorValue("#eff6fc"), + themeLighter: ColorValue("#deecf9"), + themeLight: ColorValue("#c7e0f4"), + themeTertiary: ColorValue("#71afe5"), + themeSecondary: ColorValue("#2b88d8"), + themeDarkAlt: ColorValue("#106ebe"), + themeDark: ColorValue("#005a9e"), + themeDarker: ColorValue("#004578"), + neutralLighterAlt: ColorValue("#faf9f8"), + neutralLighter: ColorValue("#f3f2f1"), + neutralLight: ColorValue("#edebe9"), + neutralQuaternaryAlt: ColorValue("#e1dfdd"), + neutralQuaternary: ColorValue("#d0d0d0"), + neutralTertiaryAlt: ColorValue("#c8c6c4"), + neutralTertiary: ColorValue("#a19f9d"), + neutralSecondary: ColorValue("#605e5c"), + neutralPrimaryAlt: ColorValue("#3b3a39"), + neutralPrimary:ColorValue( "#323130"), + neutralDark: ColorValue("#201f1e"), + black: ColorValue("#000000"), + white: ColorValue("#ffffff") + }}); + +Set(varThemeBlueJSON,"{""palette"":{ + ""themePrimary"": ""#0078d4"", + ""themeLighterAlt"": ""#eff6fc"", + ""themeLighter"": ""#deecf9"", + ""themeLight"": ""#c7e0f4"", + ""themeTertiary"": ""#71afe5"", + ""themeSecondary"": ""#2b88d8"", + ""themeDarkAlt"": ""#106ebe"", + ""themeDark"": ""#005a9e"", + ""themeDarker"": ""#004578"", + ""neutralLighterAlt"": ""#faf9f8"", + ""neutralLighter"": ""#f3f2f1"", + ""neutralLight"": ""#edebe9"", + ""neutralQuaternaryAlt"": ""#e1dfdd"", + ""neutralQuaternary"": ""#d0d0d0"", + ""neutralTertiaryAlt"": ""#c8c6c4"", + ""neutralTertiary"": ""#a19f9d"", + ""neutralSecondary"": ""#605e5c"", + ""neutralPrimaryAlt"": ""#3b3a39"", + ""neutralPrimary"": ""#323130"", + ""neutralDark"": ""#201f1e"", + ""black"": ""#000000"", + ""white"": ""#ffffff"" +} +}"); +``` + +The Theme JSON string is passed to the component property, whilst the varTheme can be used to style other standard components such as buttons using the individual colors. + +## Design challenges + +The following are items of note that were encountered when creating this component: + +### Forked FocusZone + +Since canvas apps (not custom pages) assigned a positive tab index to its controls (see below) - the Fluent UI `FocusZone` has to be modified to allow for the default tab index to be specified from its props rather than always being zero. The `Facepile`/`Pivot`/`CommandBar` components also need to be forked to accommodate this change since they do not allow for providing a custom `FocusZone` implementation. See [the Fluent UI fork read me](CommandsMenusNavs\fluentui-fork\README.md) for more information. + +[Code for Fluent UI 8.29.0 that is forked](https://github.com/microsoft/fluentui/releases/tag/%40fluentui%2Freact_v8.29.0) + +## Unsupported techniques + +The following items are considered unsupported techniques at this time and will be removed once the platform allows: + +### Non-zero tab index in canvas apps + +When canvas apps add html controls such as buttons and inputs to the DOM, it assigns a non-zero `tabindex`. E.g.: + +```html +