diff --git a/ecs-article.md b/ecs-article.md new file mode 100644 index 0000000000..9102df5183 --- /dev/null +++ b/ecs-article.md @@ -0,0 +1,15 @@ +# Microsoft Graph Toolkit - Overview and extensibility + +The Microsoft Graph Toolkit is a collection of authentication providers and web components powered by Microsoft Graph. The components simplify connecting to Microsoft Graph and allow developers to focus on their application instead of authentication and the Microsoft Graph API. The components are fully functional right out of the box, but can also be extended to fit most scenarios. The Microsoft Graph Toolkit is available as a library of [web components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) and also in a React version via a lightweight wrapper. + +## Benefits + +## Authentication + +## Web Components + +## React Components + +## Styling + +## Extensibility \ No newline at end of file diff --git a/index.html b/index.html index ee1dfce74f..f9de24f615 100644 --- a/index.html +++ b/index.html @@ -38,16 +38,16 @@
- +

Developer test page

mgt-login

- + + --> +

mgt-file-list-composite

+

mgt-file-list

- -

mgt-picker

+ + +

mgt-search-results

-->
diff --git a/packages/mgt-components/src/components/mgt-theme-toggle/mock-media-match.ts b/packages/mgt-components/src/__mocks__/mock-media-match.ts similarity index 100% rename from packages/mgt-components/src/components/mgt-theme-toggle/mock-media-match.ts rename to packages/mgt-components/src/__mocks__/mock-media-match.ts diff --git a/packages/mgt-components/src/components/components.ts b/packages/mgt-components/src/components/components.ts index 90942c60d6..70faaa07ca 100644 --- a/packages/mgt-components/src/components/components.ts +++ b/packages/mgt-components/src/components/components.ts @@ -21,11 +21,14 @@ import './mgt-person/mgt-person-types'; import './mgt-tasks/mgt-tasks'; import './mgt-teams-channel-picker/mgt-teams-channel-picker'; import './mgt-todo/mgt-todo'; +import './mgt-breadcrumb/mgt-breadcrumb'; import './mgt-contact/mgt-contact'; import './mgt-messages/mgt-messages'; import './mgt-organization/mgt-organization'; import './mgt-profile/mgt-profile'; import './mgt-theme-toggle/mgt-theme-toggle'; +import './mgt-file-grid/mgt-file-grid'; +import './mgt-file-list-composite/mgt-file-list-composite'; export * from './mgt-agenda/mgt-agenda'; export * from './mgt-file/mgt-file'; @@ -43,9 +46,12 @@ export * from './mgt-person/mgt-person-types'; export * from './mgt-tasks/mgt-tasks'; export * from './mgt-teams-channel-picker/mgt-teams-channel-picker'; export * from './mgt-todo/mgt-todo'; +export * from './mgt-breadcrumb/mgt-breadcrumb'; export * from './mgt-contact/mgt-contact'; export * from './mgt-contact/mgt-contact'; export * from './mgt-messages/mgt-messages'; export * from './mgt-organization/mgt-organization'; export * from './mgt-profile/mgt-profile'; export * from './mgt-theme-toggle/mgt-theme-toggle'; +export * from './mgt-file-grid/mgt-file-grid'; +export * from './mgt-file-list-composite/mgt-file-list-composite'; diff --git a/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.scss b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.scss new file mode 100644 index 0000000000..f341d4d3f3 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.scss @@ -0,0 +1,33 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +@import '../../../../../node_modules/office-ui-fabric-core/dist/sass/References'; +@import '../../styles/shared-styles.scss'; +@import './mgt-breadcrumb.theme.scss'; + +:host { + --type-ramp-base-font-size: var(--breadcrumb-base-font-size, 18px); + --type-ramp-base-line-height: var(--breadcrumb-base-line-height, 36px); + + &::part(crumb), + &::part(control) { + padding: 0 calc((10 + (var(--design-unit) * 2 * var(--density))) * 1px); + } + + &::part(crumb) { + font-weight: 600; + } + &::part(control) { + background-color: var(--neutral-fill-rest); + background: var(--neutral-fill-rest); + padding: 0 calc((10 + (var(--design-unit) * 2 * var(--density))) * 1px); + &:hover { + background-color: var(--neutral-fill-stealth-hover); + background: var(--neutral-fill-stealth-hover); + } + } +} diff --git a/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.tests.ts b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.tests.ts new file mode 100644 index 0000000000..26f709e3b1 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.tests.ts @@ -0,0 +1,212 @@ +import '../../__mocks__/mock-media-match'; +import { screen } from 'testing-library__dom'; +import userEvent from '@testing-library/user-event'; +import { fixture } from '@open-wc/testing-helpers'; +import './mgt-breadcrumb'; +import { BreadcrumbInfo, MgtBreadcrumb } from './mgt-breadcrumb'; + +describe('mgt-breadcrumb - tests', () => { + it('should render', async () => { + const component = await fixture(` + + `); + expect(component).toBeDefined(); + }); + it('should render with a single node', async () => { + const component: MgtBreadcrumb = await fixture(` + + `); + component.breadcrumb = [ + { + id: 'root-item', + name: 'root-item' + } + ]; + await component.updateComplete; + const listItems = await screen.findAllByRole('listitem'); + expect(listItems?.length).toBe(1); + const root = await screen.findByText('root-item'); + expect(root).toBeDefined(); + }); + it('should render with three nodes', async () => { + const component: MgtBreadcrumb = await fixture(` + + `); + component.breadcrumb = [ + { + id: '0', + name: 'root-item' + }, + { + id: '1', + name: 'node-1' + }, + { + id: '2', + name: 'node-2' + } + ]; + await component.updateComplete; + const listItems = await screen.findAllByRole('listitem'); + expect(listItems?.length).toBe(3); + const root = await screen.findByText('root-item'); + expect(root).toBeDefined(); + + const buttons = await screen.findAllByRole('button'); + + expect(buttons?.length).toBe(2); + }); + + it('should emit a clicked event when a button is clicked', async () => { + const component: MgtBreadcrumb = await fixture(` + + `); + let eventEmitted; + component.addEventListener('breadcrumbclick', (e: CustomEvent) => { + eventEmitted = e.detail; + }); + + const rootNode = { + id: '0', + name: 'root-item' + }; + component.breadcrumb = [ + rootNode, + { + id: '1', + name: 'node-1' + } + ]; + await component.updateComplete; + + const buttons = await screen.findAllByRole('button'); + expect(buttons?.length).toBe(1); + + buttons[0].click(); + expect(eventEmitted).toBeDefined(); + expect(eventEmitted).toBe(rootNode); + }); + + it('should emit a clicked event when the enter button is pressed', async () => { + const user = userEvent.setup(); + const component: MgtBreadcrumb = await fixture(` + + `); + let eventEmitted; + component.addEventListener('breadcrumbclick', (e: CustomEvent) => { + eventEmitted = e.detail; + }); + + const rootNode = { + id: '0', + name: 'root-item' + }; + component.breadcrumb = [ + rootNode, + { + id: '1', + name: 'node-1' + } + ]; + await component.updateComplete; + + const buttons = await screen.findAllByRole('button'); + expect(buttons?.length).toBe(1); + buttons[0].focus(); + + await user.keyboard('{Enter}'); + expect(eventEmitted).toBeDefined(); + expect(eventEmitted).toBe(rootNode); + }); + + it('should emit a clicked event when the space button is pressed', async () => { + const user = userEvent.setup(); + const component: MgtBreadcrumb = await fixture(` + + `); + let eventEmitted; + component.addEventListener('breadcrumbclick', (e: CustomEvent) => { + eventEmitted = e.detail; + }); + + const rootNode = { + id: '0', + name: 'root-item' + }; + component.breadcrumb = [ + rootNode, + { + id: '1', + name: 'node-1' + } + ]; + await component.updateComplete; + + const buttons = await screen.findAllByRole('button'); + expect(buttons?.length).toBe(1); + buttons[0].focus(); + + await user.keyboard(' '); + expect(eventEmitted).toBeDefined(); + expect(eventEmitted).toBe(rootNode); + }); + + it('should not emit a clicked event when a regular character button is pressed', async () => { + const user = userEvent.setup(); + const component: MgtBreadcrumb = await fixture(` + + `); + let eventEmitted; + component.addEventListener('breadcrumbclick', (e: CustomEvent) => { + eventEmitted = e.detail; + }); + + const rootNode = { + id: '0', + name: 'root-item' + }; + component.breadcrumb = [ + rootNode, + { + id: '1', + name: 'node-1' + } + ]; + await component.updateComplete; + + const buttons = await screen.findAllByRole('button'); + expect(buttons?.length).toBe(1); + buttons[0].focus(); + + await user.keyboard('r'); + expect(eventEmitted).toBeUndefined(); + }); + + it('should not emit a clicked event when the last node is clicked', async () => { + const component: MgtBreadcrumb = await fixture(` + + `); + let eventEmitted; + component.addEventListener('breadcrumbclick', (e: CustomEvent) => { + eventEmitted = e.detail; + }); + + const rootNode = { + id: '0', + name: 'root-item' + }; + component.breadcrumb = [ + rootNode, + { + id: '1', + name: 'node-1' + } + ]; + await component.updateComplete; + + const notButton = await screen.findByText('node-1'); + notButton.click(); + + expect(eventEmitted).toBeUndefined(); + }); +}); diff --git a/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.theme.scss b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.theme.scss new file mode 100644 index 0000000000..19c6215d33 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.theme.scss @@ -0,0 +1,12 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +@import '../../styles/shared-sass-variables.scss'; + +$breadcrumb: (); + +@include create-themes($breadcrumb); diff --git a/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.ts b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.ts new file mode 100644 index 0000000000..3048b2ceff --- /dev/null +++ b/packages/mgt-components/src/components/mgt-breadcrumb/mgt-breadcrumb.ts @@ -0,0 +1,112 @@ +import { html, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { fluentBreadcrumb, fluentBreadcrumbItem, fluentButton } from '@fluentui/web-components'; +import { registerFluentComponents } from '../../utils/FluentComponents'; +import { customElement, MgtBaseComponent } from '@microsoft/mgt-element'; +import { styles } from './mgt-breadcrumb-css'; + +registerFluentComponents(fluentButton, fluentBreadcrumb, fluentBreadcrumbItem); + +/** + * Defines a base type for breadcrumb data + */ +export type BreadcrumbInfo = { + /** + * unique identifier for the breadcrumb + * + * @type {string} + */ + id: string; + /** + * Name of the breadcrumb, used to display the item. + * + * @type {string} + */ + name: string; +}; + +/** + * Custom breadcrumb component + * + * @fires {CustomEvent} breadcrumbclick - Fired when a breadcrumb is clicked. Will not fire when the last breadcrumb is clicked. + * + * @cssprop --breadcrumb-base-font-size - {Length} Breadcrumb base font size. Default is 18px. + * @cssprop --breadcrumb-base-line-height - {Length} Breadcrumb line height. Default is 36px + * + * @export + * @class MgtBreadcrumb + * @extends {MgtBaseComponent} + */ +@customElement('breadcrumb') +export class MgtBreadcrumb extends MgtBaseComponent { + /** + * Array of styles to apply to the element. The styles should be defined + * using the `css` tag function. + */ + static get styles() { + return styles; + } + + private _breadcrumb: BreadcrumbInfo[] = []; + /** + * An array of nodes to show in the breadcrumb + * + * @type {BreadcrumbInfo[]} + * @readonly + * @memberof MgtFileList + */ + @property({ + attribute: false + }) + public get breadcrumb(): BreadcrumbInfo[] { + return this._breadcrumb; + } + public set breadcrumb(value: BreadcrumbInfo[]) { + this._breadcrumb = value; + // this is needed to trigger a re-render + this.requestUpdate(); + } + + private isLastCrumb = (b: BreadcrumbInfo): boolean => this.breadcrumb.indexOf(b) === this.breadcrumb.length - 1; + + /** + * Renders the component + * + * @return {*} {TemplateResult} + * @memberof MgtBreadcrumb + */ + public render(): TemplateResult { + return html` + + ${repeat( + this.breadcrumb, + b => b.id, + b => + !this.isLastCrumb(b) + ? html` + + this.handleBreadcrumbClick(b)} + > + ${b.name} + + + ` + : html` + + ${b.name} + + ` + )} + + `; + } + + private handleBreadcrumbClick(b: BreadcrumbInfo): void { + // last crumb does nothing + if (this.isLastCrumb(b)) return; + this.fireCustomEvent('breadcrumbclick', b); + } +} diff --git a/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.scss b/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.scss new file mode 100644 index 0000000000..910afdea47 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.scss @@ -0,0 +1,129 @@ +@import '../../../../../node_modules/office-ui-fabric-core/dist/sass/References'; +@import '../../styles/shared-styles.scss'; +@import '../mgt-file-list/mgt-file-list.theme.scss'; + +$file-list-border-radius: var(--file-list-border-radius, 4px); +$file-list-box-shadow: var( + --file-list-box-shadow, + 0px 3.2px 7.2px rgba(0, 0, 0, 0.132), + 0px 0.6px 1.8px rgba(0, 0, 0, 0.108) +); +$file-list-border: var(--file-list-border, none); +$file-item-border-radius: var(--file-item-border-radius, 2px); +$file-item-margin: var(--file-item-margin, 0 4px 0 4px); +$show-more-button-font-size: var(--show-more-button-font-size, 12px); +$show-more-button-padding: var(--show-more-button-padding, 6px); +$show-more-button-border-bottom-right-radius: var(--show-more-button-border-bottom-right-radius, 8px); +$show-more-button-border-bottom-left-radius: var(--show-more-button-border-bottom-left-radius, 8px); +$show-more-button-background-color--hover: var(--show-more-button-background-color--hover, $show_more_button_hover); +$progress-ring-size: var(--progress-ring-size, 24px); + +:host { + font-family: $font-family; + font-size: $font-size; + color: $color; +} + +:host { + a { + color: $color; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + .file-item { + width: fit-content; + } + + .file-grid { + display: grid; + grid-template-columns: [col-start] auto auto 1fr auto auto auto [col-end]; + grid-template-rows: [header-start] auto [header-end row-start] auto [row-end]; + grid-auto-rows: auto; + grid-auto-columns: auto; + overflow: hidden; + } + + .header, + .cell { + padding: 0 12px; + height: 100%; + display: flex; + align-items: center; + } + + .header { + font-weight: 600; + height: 42px; + grid-row: header; + } + + .file-row { + display: contents; + &:hover > .header, + &:hover > .cell { + background-color: var(--neutral-layer-2); + + .mgt-file-item { + --file-background-color: var(--neutral-layer-2); + } + .file-selector { + border: var(--neutral-foreground-rest) solid 1px; + } + } + } + + .file-name { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .selected { + background-color: var(--file-background-color-active, var(--neutral-layer-3)); + .mgt-file-item { + --file-background-color: var(--file-background-color-active, var(--neutral-layer-3)); + } + } + .file-selector { + margin: auto; + height: 16px; + width: 16px; + border-radius: 50%; + overflow: hidden; + border-color: var(--neutral-fill-strong-rest); + background-color: var(--neutral-fill-rest); + border: transparent solid 1px; + svg { + background-color: var(--neutral-fill-rest); + fill: var(--neutral-fill-rest); + } + &:hover { + svg { + background-color: var(--neutral-fill-strong-hover); + } + } + + &.selected { + border-color: var(--neutral-fill-rest); + svg { + fill: var(--accent-fill-rest); + } + &:hover { + svg { + background-color: var(--accent-fill-rest); + fill: var(--neutral-fill-rest); + } + } + } + } + .loading-indicator { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + } +} diff --git a/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.ts b/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.ts new file mode 100644 index 0000000000..03137fbc75 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-grid/mgt-file-grid.ts @@ -0,0 +1,771 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +import { GraphPageIterator, Providers, ProviderState, customElement, mgtHtml } from '@microsoft/mgt-element'; +import { DriveItem } from '@microsoft/microsoft-graph-types'; +import { CSSResult, html, nothing, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { + clearFilesCache, + fetchNextAndCacheForFilesPageIterator, + getDriveFilesByIdIterator, + getDriveFilesByPathIterator, + getFilesByIdIterator, + getFilesByListQueryIterator, + getFilesByPathIterator, + getFilesByQueries, + getFilesIterator, + getGroupFilesByIdIterator, + getGroupFilesByPathIterator, + getMyInsightsFiles, + getSiteFilesByIdIterator, + getSiteFilesByPathIterator, + getUserFilesByIdIterator, + getUserFilesByPathIterator, + getUserInsightsFiles +} from '../../graph/graph.files'; +import '../mgt-file-list/mgt-file-upload/mgt-file-upload'; +import { ViewType } from '../../graph/types'; +import { styles } from './mgt-file-grid-css'; +import { strings } from '../mgt-file-list/strings'; +import { MgtFile } from '../mgt-file/mgt-file'; +import { MgtFileUploadConfig } from '../mgt-file-list/mgt-file-upload/mgt-file-upload'; + +import { fluentProgressRing, fluentDesignSystemProvider, fluentButton } from '@fluentui/web-components'; +import { registerFluentComponents } from '../../utils/FluentComponents'; +import { classMap } from 'lit/directives/class-map.js'; +import { getSvg, SvgIcon } from '../../utils/SvgHelper'; +import '../mgt-menu/mgt-menu'; +import { MgtFileListBase } from '../mgt-file-list/mgt-file-list-base'; +import { MenuCommand } from '../mgt-menu/mgt-menu'; +import '../mgt-person/mgt-person'; +import { formatBytes, getRelativeDisplayDate } from '../../utils/Utils'; + +registerFluentComponents(fluentProgressRing, fluentDesignSystemProvider, fluentButton); + +// re-export to ensure it's in the final package as mgt-menu is internal only +export { MenuCommand }; + +/** + * The File List component displays a list of multiple folders and files by + * using the file/folder name, an icon, and other properties specified by the developer. + * This component uses the mgt-file component. + * + * @export + * @class MgtFileList + * @extends {MgtTemplatedComponent} + * + * @fires {CustomEvent} itemClick - Fired when user click a file. Returns the file (DriveItem) details. + * @fires {CustomEvent} selectionChanged - Fired when user select a file. Returns the selected files (DriveItem) details. + * + * @cssprop --file-upload-border- {String} File upload border top style + * @cssprop --file-upload-background-color - {Color} File upload background color with opacity style + * @cssprop --file-upload-button-float - {string} Upload button float position + * @cssprop --file-upload-button-background-color - {Color} Background color of upload button + * @cssprop --file-upload-dialog-background-color - {Color} Background color of upload dialog + * @cssprop --file-upload-dialog-content-background-color - {Color} Background color of dialog content + * @cssprop --file-upload-dialog-content-color - {Color} Color of dialog content + * @cssprop --file-upload-dialog-primarybutton-background-color - {Color} Background color of primary button + * @cssprop --file-upload-dialog-primarybutton-color - {Color} Color text of primary button + * @cssprop --file-upload-button-color - {Color} Text color of upload button + * @cssprop --file-list-background-color - {Color} File list background color + * @cssprop --file-list-box-shadow - {String} File list box shadow style + * @cssprop --file-list-border - {String} File list border styles + * @cssprop --file-list-padding -{String} File list padding + * @cssprop --file-list-margin -{String} File list margin + * @cssprop --file-item-background-color--hover - {Color} File item background hover color + * @cssprop --file-item-border-top - {String} File item border top style + * @cssprop --file-item-border-left - {String} File item border left style + * @cssprop --file-item-border-right - {String} File item border right style + * @cssprop --file-item-border-bottom - {String} File item border bottom style + * @cssprop --file-item-background-color--active - {Color} File item background active color + * @cssprop --file-item-border-radius - {String} File item border radius + * @cssprop --file-item-margin - {String} File item margin + * @cssprop --show-more-button-background-color - {Color} Show more button background color + * @cssprop --show-more-button-background-color--hover - {Color} Show more button background hover color + * @cssprop --show-more-button-font-size - {String} Show more button font size + * @cssprop --show-more-button-padding - {String} Show more button padding + * @cssprop --show-more-button-border-bottom-right-radius - {String} Show more button bottom right radius + * @cssprop --show-more-button-border-bottom-left-radius - {String} Show more button bottom left radius + * @cssprop --progress-ring-size -{String} Progress ring height and width + */ + +// tslint:disable-next-line: max-classes-per-file +@customElement('file-grid') +export class MgtFileGrid extends MgtFileListBase { + /** + * Array of styles to apply to the element. The styles should be defined + * using the `css` tag function. + */ + static get styles(): CSSResult[] { + return styles; + } + + /** + * Strings to be used in the component + * + * @readonly + * @protected + * @memberof MgtFileList + */ + protected get strings(): Record { + return strings; + } + + /** + * Get the scopes required for file list + * + * @static + * @return {*} {string[]} + * @memberof MgtFileList + */ + public static get requiredScopes(): string[] { + return [...new Set([...MgtFile.requiredScopes])]; + } + + /** + * Property to set the available actions on the file context menu + * + * @type {MenuCommand[]} + * @memberof MgtFileGrid + */ + @property({ + attribute: false + }) + public commands: MenuCommand[] = []; + + private _preloadedFiles: DriveItem[]; + private pageIterator: GraphPageIterator; + // tracking user arrow key input of selection for accessibility purpose + private _focusedItemIndex = -1; + + @state() + private _isLoadingMore: boolean; + + @state() + private _selectedFiles: Map; + + constructor() { + super(); + this._selectedFiles = new Map(); + this.pageSize = 10; + this.itemView = ViewType.image; + this.maxUploadFile = 10; + this.enableFileUpload = false; + this._preloadedFiles = []; + } + + /** + * Override requestStateUpdate to include clearstate. + * + * @memberof MgtFileGrid + */ + protected requestStateUpdate(force?: boolean) { + this.clearState(); + return super.requestStateUpdate(force); + } + + /** + * Reset state + * + * @memberof MgtFileList + */ + protected clearState(): void { + super.clearState(); + this.files = null; + this._selectedFiles = new Map(); + this.fireCustomEvent('selectionChanged', []); + } + + /** + * Render the file list + * + * @memberof MgtFileList + */ + public render() { + if (!this.files && this.isLoadingState) { + return this.renderLoading(); + } + + if (!this.files || this.files.length === 0) { + return this.renderNoData(); + } + + return this.renderTemplate('default', { files: this.files }) || this.renderFiles(); + } + + /** + * Render the loading state + * + * @protected + * @memberof MgtFileList + */ + protected renderLoading() { + return this.renderTemplate('loading', null) || nothing; + } + + /** + * Render the state when no data is available + * + * @protected + * @returns {TemplateResult} + * @memberof MgtFileList + */ + protected renderNoData(): TemplateResult { + return ( + this.renderTemplate('no-data', null) || + (this.enableFileUpload === true && Providers.globalProvider !== undefined + ? html` + +
+ ${this.renderFileUpload()} +
+
+ ` + : html``) + ); + } + + /** + * render the files in a data grid + * + * @protected + * @return {*} {TemplateResult} + * @memberof MgtFileList + */ + protected renderFiles(): TemplateResult { + const headerClasses = { + header: true, + selected: this.allSelected() + }; + // the hidden anchor tag is used to download file + return html` + +
+
+
${this.renderSelectorHeader()}
+
+
Name
+
Modified
+
Modified By
+
Size
+
+ ${repeat( + this.files, + f => f.id, + f => html` +
this.onSelectorClicked(f)} + data-drive-item-id=${f.id} + > +
${this.renderSelector(f)}
+
${this.renderFileIcon(f)}
+
+ ${this.renderFileName(f)} + ${this.renderMenu(f)} +
+
${getRelativeDisplayDate(new Date(f.lastModifiedDateTime))}
+
${this.renderUser(f)}
+
${this.sizeText(f)}
+
+ ` + )} +
+ ${ + !this.hideMoreFilesButton && this.pageIterator && (this.pageIterator.hasNext || this._preloadedFiles.length) + ? this.renderMoreFileButton() + : null + } + `; + } + + private sizeText(file: DriveItem) { + if (file.folder) return `${file.folder.childCount} ${file.folder.childCount === 0 ? strings.item : strings.items}`; + + return formatBytes(file.size); + } + + private renderUser(file: DriveItem) { + if (file.lastModifiedByUser) + return mgtHtml` + `; + + if (file.lastModifiedBy?.user) + return mgtHtml` + `; + + return nothing; + } + + private isSelected(file: DriveItem): boolean { + return this._selectedFiles.has(file.id); + } + + private renderSelectorHeader(): TemplateResult { + const classes = { + 'file-selector': true, + selected: this.allSelected() + }; + + return html` +
+ ${getSvg(SvgIcon.FilledCheckMark)} +
+ `; + } + + private allSelected(): boolean { + return this.files && this.files.length > 0 && this._selectedFiles.size === this.files.length; + } + + private selectAll = (): void => { + const tmp = new Map(); + this.files.forEach(file => tmp.set(file.id, file)); + this._selectedFiles = tmp; + this.fireCustomEvent('selectionChanged', this.files); + }; + + private deselectAll = (): void => { + this._selectedFiles = new Map(); + this.fireCustomEvent('selectionChanged', []); + }; + + private renderSelector(file: DriveItem): TemplateResult { + const classes = { + 'file-selector': true, + selected: this.isSelected(file) + }; + + return html`
${getSvg(SvgIcon.FilledCheckMark)}
`; + } + + private onSelectorClicked(file: DriveItem): void { + if (this._selectedFiles.has(file.id)) { + this._selectedFiles.delete(file.id); + } else { + this._selectedFiles.set(file.id, file); + } + + // request a re-render as we're mutating the state of the _selectedFiles map without an assignment + this.requestUpdate(); + + this.fireCustomEvent( + 'selectionChanged', + Array.from(this._selectedFiles, ([, value]) => value) + ); + } + + private renderMenu(file: DriveItem): TemplateResult { + return html`${ + !this.commands || this.commands.length === 0 + ? nothing + : mgtHtml` + + ` + }`; + } + + /** + * Render an individual file. + * + * @protected + * @returns {TemplateResult} + * @memberof mgtFileList + */ + protected renderFileIcon(file: DriveItem): TemplateResult { + const view = this.itemView; + return ( + this.renderTemplate('file', { file }, file.id) || + mgtHtml` + this.handleItemSelect(file, e)} + class="file-item" + .fileDetails=${file} + .view=${view} + > + ` + ); + } + + /** + * Render an individual file. + * + * @protected + * @returns {TemplateResult} + * @memberof mgtFileList + */ + protected renderFileName(file: DriveItem): TemplateResult { + return file.folder + ? html` this.handleItemSelect(file, e)}>${file.name}` + : html`${file.name}`; + } + + /** + * Render the button when clicked will show more files. + * + * @protected + * @returns {TemplateResult} + * @memberof MgtFileList + */ + protected renderMoreFileButton(): TemplateResult { + if (this._isLoadingMore) { + return html` +
+ +
+ `; + } else { + return html` + this.renderNextPage()} + > + ${this.strings.showMoreSubtitle} + `; + } + } + + /** + * Render MgtFileUpload sub component + * + * @returns + */ + protected renderFileUpload(): TemplateResult { + const fileUploadConfig: MgtFileUploadConfig = { + graph: Providers.globalProvider.graph.forComponent(this), + driveId: this.driveId, + excludedFileExtensions: this.excludedFileExtensions, + groupId: this.groupId, + itemId: this.itemId, + itemPath: this.itemPath, + userId: this.userId, + siteId: this.siteId, + maxFileSize: this.maxFileSize, + maxUploadFile: this.maxUploadFile + }; + return mgtHtml` + + `; + } + + /** + * Handle accessibility keyboard keyup events on file list + * + * @param event + */ + private onFileListKeyUp(event: KeyboardEvent): void { + const fileList = this.renderRoot.querySelector('.file-list'); + const focusedItem = fileList.children[this._focusedItemIndex]; + + if (event.code === 'Enter' || event.code === 'Space') { + event.preventDefault(); + + focusedItem?.classList.remove('selected'); + focusedItem?.classList.add('focused'); + } + } + + /** + * Handle accessibility keyboard keydown events (arrow up, arrow down, enter, tab) on file list + * + * @param event + */ + private onFileListKeyDown(event: KeyboardEvent): void { + const fileList = this.renderRoot.querySelector('.file-list'); + let focusedItem: Element; + + if (!fileList || !fileList.children.length) { + return; + } + + if (event.code === 'ArrowUp' || event.code === 'ArrowDown') { + if (event.code === 'ArrowUp') { + if (this._focusedItemIndex === -1) { + this._focusedItemIndex = fileList.children.length; + } + this._focusedItemIndex = (this._focusedItemIndex - 1 + fileList.children.length) % fileList.children.length; + } + if (event.code === 'ArrowDown') { + this._focusedItemIndex = (this._focusedItemIndex + 1) % fileList.children.length; + } + + focusedItem = fileList.children[this._focusedItemIndex]; + this.updateItemBackgroundColor(fileList, focusedItem, 'focused'); + } + + if (event.code === 'Enter' || event.code === 'Space') { + focusedItem = fileList.children[this._focusedItemIndex]; + + const file = focusedItem.children[0] as MgtFile; + event.preventDefault(); + this.raiseItemClickedEvent(file.fileDetails); + + this.updateItemBackgroundColor(fileList, focusedItem, 'selected'); + } + + if (event.code === 'Tab') { + focusedItem = fileList.children[this._focusedItemIndex]; + focusedItem?.classList.remove('focused'); + } + } + + private raiseItemClickedEvent(file: DriveItem) { + this.fireCustomEvent('itemClick', file); + } + + /** + * load state into the component. + * + * @protected + * @returns + * @memberof MgtFileList + */ + protected async loadState() { + const provider = Providers.globalProvider; + if (!provider || provider.state === ProviderState.Loading) { + return; + } + + if (provider.state === ProviderState.SignedOut) { + this.files = null; + return; + } + const graph = provider.graph.forComponent(this); + let files: DriveItem[]; + let pageIterator: GraphPageIterator; + + const getFromMyDrive = !this.driveId && !this.siteId && !this.groupId && !this.userId; + + // combinations of these attributes must be provided in order for the component to know which endpoint to call to request files + // not supplying enough for these combinations will get a null file result + if ( + (this.driveId && !this.itemId && !this.itemPath) || + (this.groupId && !this.itemId && !this.itemPath) || + (this.siteId && !this.itemId && !this.itemPath) || + (this.userId && !this.insightType && !this.itemId && !this.itemPath) + ) { + this.files = null; + } + + if (!this.files) { + if (this.fileListQuery) { + pageIterator = await getFilesByListQueryIterator(graph, this.fileListQuery, this.pageSize); + } else if (this.fileQueries) { + files = await getFilesByQueries(graph, this.fileQueries); + } else if (getFromMyDrive) { + if (this.itemId) { + pageIterator = await getFilesByIdIterator(graph, this.itemId, this.pageSize); + } else if (this.itemPath) { + pageIterator = await getFilesByPathIterator(graph, this.itemPath, this.pageSize); + } else if (this.insightType) { + files = await getMyInsightsFiles(graph, this.insightType); + } else { + pageIterator = await getFilesIterator(graph, this.pageSize); + } + } else if (this.driveId) { + if (this.itemId) { + pageIterator = await getDriveFilesByIdIterator(graph, this.driveId, this.itemId, this.pageSize); + } else if (this.itemPath) { + pageIterator = await getDriveFilesByPathIterator(graph, this.driveId, this.itemPath, this.pageSize); + } + } else if (this.groupId) { + if (this.itemId) { + pageIterator = await getGroupFilesByIdIterator(graph, this.groupId, this.itemId, this.pageSize); + } else if (this.itemPath) { + pageIterator = await getGroupFilesByPathIterator(graph, this.groupId, this.itemPath, this.pageSize); + } + } else if (this.siteId) { + if (this.itemId) { + pageIterator = await getSiteFilesByIdIterator(graph, this.siteId, this.itemId, this.pageSize); + } else if (this.itemPath) { + pageIterator = await getSiteFilesByPathIterator(graph, this.siteId, this.itemPath, this.pageSize); + } + } else if (this.userId) { + if (this.itemId) { + pageIterator = await getUserFilesByIdIterator(graph, this.userId, this.itemId, this.pageSize); + } else if (this.itemPath) { + pageIterator = await getUserFilesByPathIterator(graph, this.userId, this.itemPath, this.pageSize); + } else if (this.insightType) { + files = await getUserInsightsFiles(graph, this.userId, this.insightType); + } + } + + if (pageIterator) { + this.pageIterator = pageIterator; + this._preloadedFiles = [...this.pageIterator.value]; + + // handle when cached file length is greater than page size + if (this._preloadedFiles.length >= this.pageSize) { + files = this._preloadedFiles.splice(0, this.pageSize); + } else { + files = this._preloadedFiles.splice(0, this._preloadedFiles.length); + } + } + + // filter files when extensions are provided + let filteredByFileExtension: DriveItem[]; + if (this.fileExtensions && this.fileExtensions !== null) { + // retrieve all pages before filtering + if (this.pageIterator && this.pageIterator.value) { + while (this.pageIterator.hasNext) { + await fetchNextAndCacheForFilesPageIterator(this.pageIterator); + } + files = this.pageIterator.value; + this._preloadedFiles = []; + } + filteredByFileExtension = files.filter(file => { + for (const e of this.fileExtensions) { + if (e === this.getFileExtension(file.name)) { + return file; + } + } + }); + } + + if (filteredByFileExtension && filteredByFileExtension.length >= 0) { + this.files = filteredByFileExtension; + if (this.pageSize) { + files = this.files.splice(0, this.pageSize); + this.files = files; + } + } else { + this.files = files; + } + } + } + + /** + * Handle the click event on an item. + * + * @protected + * @memberof MgtFileList + */ + protected handleItemSelect(item: DriveItem, event: MouseEvent): void { + event?.stopPropagation(); + this.raiseItemClickedEvent(item); + if (item.file && item.webUrl) { + // open the web url if the item is a file + this.clickFileLink(item.webUrl); + } + } + + private clickFileLink = (url: string) => { + const a = this.renderRoot.querySelector('#file-link'); + a.href = url; + if (a.href) { + a.click(); + a.href = ''; + } + }; + + /** + * Handle the click event on button to show next page. + * + * @protected + * @memberof MgtFileList + */ + protected async renderNextPage() { + // render next page from cache if exists, or else use iterator + if (this._preloadedFiles.length > 0) { + this.files = [ + ...this.files, + ...this._preloadedFiles.splice(0, Math.min(this.pageSize, this._preloadedFiles.length)) + ]; + } else { + if (this.pageIterator.hasNext) { + this._isLoadingMore = true; + const root = this.renderRoot.querySelector('file-list-wrapper'); + if (root && root.animate) { + // play back + root.animate( + [ + { + height: 'auto', + transformOrigin: 'top left' + }, + { + height: 'auto', + transformOrigin: 'top left' + } + ], + { + duration: 1000, + easing: 'ease-in-out', + fill: 'both' + } + ); + } + await fetchNextAndCacheForFilesPageIterator(this.pageIterator); + this._isLoadingMore = false; + this.files = this.pageIterator.value; + } + } + + this.requestUpdate(); + } + + /** + * Get file extension string from file name + * + * @param name file name + * @returns {string} file extension + */ + private getFileExtension(name: string) { + const re = /(?:\.([^.]+))?$/; + const fileExtension = re.exec(name)[1] || ''; + + return fileExtension; + } + + /** + * Handle remove and add css class on accessibility keyboard select and focus + * + * @param fileList HTML element + * @param focusedItem HTML element + * @param className background class to be applied + */ + private updateItemBackgroundColor(fileList: Element, focusedItem: Element, className: string) { + // reset background color + for (const c of fileList.children) { + c.classList.remove(className); + } + + // set focused item background color + if (focusedItem) { + focusedItem.classList.add(className); + focusedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); + } + } + + /** + * Handle reload of File List and condition to clear cache + * + * @param clearCache boolean, if true clear cache + */ + public reload(clearCache = false) { + if (clearCache) { + // clear cache File List + void clearFilesCache(); + } + + void this.requestStateUpdate(true); + } +} diff --git a/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.scss b/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.scss new file mode 100644 index 0000000000..e93f76d867 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.scss @@ -0,0 +1,44 @@ +:host { + .root { + position: relative; + } + fluent-dialog::part(control) { + height: auto; + width: auto; + } + fluent-button::part(control) { + padding: 0 32px; + } + &::part(dialog-body) { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 12px 12px; + } + &::part(button-row) { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 24px; + align-items: center; + margin-top: 12px; + } + &::part(dialog-input) { + width: 100%; + } + + &::part(upload-button-wrapper) { + margin: 0; + display: block; + } + + .command-bar { + display: flex; + flex-direction: row; + gap: 12px; + overflow: hidden; + fluent-button { + flex-shrink: 0; + } + } +} diff --git a/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.ts b/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.ts new file mode 100644 index 0000000000..2f66ef51d9 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list-composite/mgt-file-list-composite.ts @@ -0,0 +1,703 @@ +import { customElement, mgtHtml, Providers, ProviderState } from '@microsoft/mgt-element'; +import { DriveItem } from '@microsoft/microsoft-graph-types'; +import { CSSResult, html, nothing, PropertyValueMap, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { OfficeGraphInsightString } from '../../graph/types'; +import { BreadcrumbInfo } from '../mgt-breadcrumb/mgt-breadcrumb'; +import { MgtFileListBase } from '../mgt-file-list/mgt-file-list-base'; +import { strings } from './strings'; +import '../mgt-file-grid/mgt-file-grid'; +import '../mgt-file-list/mgt-file-upload/mgt-file-upload'; +import { MenuCommand } from '../mgt-menu/mgt-menu'; +import { addFolder, clearFilesCache, deleteDriveItem, renameDriveItem, shareDriveItem } from '../../graph/graph.files'; +import { styles } from './mgt-file-list-composite-css'; +import { ButtonAppearance, fluentButton, fluentDialog, fluentTextField } from '@fluentui/web-components'; +import { registerFluentComponents } from '../../utils/FluentComponents'; +import { MgtFileGrid } from '../mgt-file-grid/mgt-file-grid'; +import { + MgtFileUpload, + MgtFileUploadConfig, + MgtFileUploadItem +} from '../mgt-file-list/mgt-file-upload/mgt-file-upload'; + +registerFluentComponents(fluentButton, fluentDialog, fluentTextField); + +/** + * FileListBreadCrumb interface + */ +type FileListBreadCrumb = { + // tslint:disable: completed-docs + name: string; + fileListQuery?: string; + itemId?: string; + itemPath?: string; + files?: DriveItem[]; + fileQueries?: string[]; + groupId?: string; + driveId?: string; + siteId?: string; + userId?: string; + insightType?: OfficeGraphInsightString; + fileExtensions?: string[]; + // tslint:enable: completed-docs +} & BreadcrumbInfo; + +const alwaysRender = () => true; + +type CommandBarItem = { + text: string; + glyph?: unknown; + onClick: (e: MouseEvent) => void; + class?: string; + appearance?: ButtonAppearance; +}; + +/** + * A File list composite component + * Provides a breadcrumb navigation to support folder navigation + * + * @class MgtFileComposite + * @extends {MgtTemplatedComponent} + */ +@customElement('file-list-composite') +class MgtFileListComposite extends MgtFileListBase { + /** + * Array of styles to apply to the element. The styles should be defined + * using the `css` tag function. + */ + static get styles(): CSSResult[] { + return styles; + } + + /** + * Strings to be used in the component + * + * @readonly + * @protected + * @memberof MgtFileList + */ + protected get strings() { + return strings; + } + constructor() { + super(); + this.breadcrumbRootName = strings.rootNode; + this.menuCommands = [ + { + id: 'share-edit', + name: 'Create editable link', + onClickFunction: this.showShareFileEditable, + shouldRender: alwaysRender + }, + { + id: 'share-read', + name: 'Create read-only link', + onClickFunction: this.showShareFileReadOnly, + shouldRender: alwaysRender + }, + { id: 'rename', name: 'Rename', onClickFunction: this.showRenameFileDialog, shouldRender: alwaysRender }, + { id: 'delete', name: 'Delete', onClickFunction: this.showDeleteDialog, shouldRender: alwaysRender }, + { id: 'download', name: 'Download', onClickFunction: this.downloadFile, shouldRender: f => !f.folder } + ]; + + this.comandBarItems = [{ text: 'New folder', onClick: this.showNewFolderDialog, appearance: 'accent' }]; + } + + /** + * Name to be used for the root node of the breadcrumb + * + * @type {string} + * @memberof MgtFileComposite + */ + @property({ + attribute: 'breadcrumb-root-name' + }) + public breadcrumbRootName: string; + + /** + * Switch to use grid view instead of list view + * + * @type {boolean} + * @memberof MgtFileListComposite + */ + @property({ + attribute: 'use-grid-view', + type: Boolean + }) + public useGridView: boolean; + + @state() + private fileUploadData: MgtFileUploadItem[] = []; + + /** + * Helper function to set properties of a give breadcrumb to match the current state of the component + * + * @param c {FileListBreadCrumb} + */ + private updateBreadcrumb(c: FileListBreadCrumb) { + c.siteId = this.siteId; + c.groupId = this.groupId; + c.driveId = this.driveId; + c.userId = this.userId; + c.files = this.files; + c.fileExtensions = this.fileExtensions; + c.fileListQuery = this.fileListQuery; + c.fileQueries = this.fileQueries; + c.itemPath = this.itemPath; + c.insightType = this.insightType; + c.itemId = this.itemId; + } + + /** + * Override connectedCallback to set initial breadcrumbstate. + * + * @memberof MgtFileList + */ + public connectedCallback(): void { + super.connectedCallback(); + const rootBreadcrumb: FileListBreadCrumb = { + name: this.breadcrumbRootName, + id: 'root-item' + }; + this.updateBreadcrumb(rootBreadcrumb); + this.breadcrumb.push(rootBreadcrumb); + + this.addEventListener('fileUploadSuccess', this.onFileUploadSuccess); + } + + /** + * Implemented to overcome React wrapping challenges and attach event listeners after rendering + * + * @param changedProperties + */ + protected updated(changedProperties: PropertyValueMap | Map): void { + super.updated(changedProperties); + // Update the last item in the breadcrumb to the current state + // Necessary as React wrapped component don't set properties on the initial render + // this casues connectecCallback logic to be called before properties are set + const currentBreadcrumb = this.breadcrumb[this.breadcrumb.length - 1]; + this.updateBreadcrumb(currentBreadcrumb); + this.uploadButton?.attachEventListeners(); + } + + /** + * An array of nodes to show in the breadcrumb + * + * @type {BreadcrumbInfo[]} + * @readonly + * @memberof MgtFileList + */ + @state() + private breadcrumb: FileListBreadCrumb[] = []; + + @state() + private _activeFile: DriveItem; + + @state() + private deleteDialogVisible = false; + + @state() + private newFolderDialogVisible = false; + + @state() + private renameDialogVisible = false; + + @state() + private shareDialogVisible = false; + + @state() + private shareMode: 'edit' | 'view' = 'view'; + + @state() + private shareUrl: string; + + /** + * Render the component + * + * @return {*} + * @memberof MgtFileComposite + */ + public render() { + if (!Providers.globalProvider || Providers.globalProvider.state !== ProviderState.SignedIn) return nothing; + return html` +
+ ${this.renderCommandBar()} + + ${this.renderBreadcrumb()} + ${this.renderFiles()} + ${this.renderDeleteDialog()} + ${this.renderNewFolderDialog()} + ${this.renderRenameDialog()} + ${this.renderShareDialog()} +
+ `; + } + + private menuCommands: MenuCommand[] = []; + + private comandBarItems: CommandBarItem[] = []; + + private get uploadButton(): MgtFileUpload { + return this.renderRoot.querySelector('mgt-file-upload'); + } + + private renderCommandBar(): TemplateResult | typeof nothing { + if (this.comandBarItems?.length < 1) return nothing; + + return html` +
+ ${repeat( + this.comandBarItems, + item => item.text, + item => html` + + ${item.text} + + ` + )} + ${this.enableFileUpload ? this.renderFileUpload() : nothing} + ${this.enableFileUpload ? this.renderUploadProgress() : nothing} +
+`; + } + + /** + * Render MgtFileUpload sub component + * + * @returns + */ + private renderFileUpload(): TemplateResult | typeof nothing { + if (!this.enableFileUpload) return nothing; + const fileUploadConfig: MgtFileUploadConfig = { + graph: Providers.globalProvider.graph.forComponent(this), + driveId: this.driveId, + excludedFileExtensions: this.excludedFileExtensions, + groupId: this.groupId, + itemId: this.itemId, + itemPath: this.itemPath, + userId: this.userId, + siteId: this.siteId, + maxFileSize: this.maxFileSize, + maxUploadFile: this.maxUploadFile, + dropTarget: () => this + }; + return mgtHtml` + + `; + } + + private renderUploadProgress(): TemplateResult | typeof nothing { + return this.fileUploadData?.length > 0 + ? mgtHtml` + ` + : nothing; + } + + private clearUploadNotification = (e: CustomEvent) => { + const uploadComponent = this.renderRoot.querySelector('mgt-file-upload'); + uploadComponent.filesToUpload = uploadComponent.filesToUpload.filter(f => f !== e.detail); + }; + + private renderFiles() { + return this.useGridView + ? mgtHtml` + +` + : mgtHtml` + +`; + } + + private renderBreadcrumb() { + return mgtHtml` + +`; + } + + private handleItemClick = (e: CustomEvent): void => { + const item = e.detail; + if (item.folder) { + // load folder contents, update breadcrumb + this.breadcrumb = [ + ...this.breadcrumb, + { name: item.name, itemId: item.id, id: item.id, driveId: item.parentReference.driveId } + ]; + // clear any existing query properties + this.siteId = null; + this.groupId = null; + this.userId = null; + this.files = null; + this.fileExtensions = null; + this.fileListQuery = null; + this.fileQueries = null; + this.itemPath = null; + this.insightType = null; + // set the item id to load the folder + this.itemId = item.id; + this.driveId = item.parentReference.driveId; + this.fireCustomEvent('itemClick', item); + } + }; + + private handleBreadcrumbClick = (e: CustomEvent): void => { + const b = e.detail; + this.breadcrumb = this.breadcrumb.slice(0, this.breadcrumb.indexOf(b) + 1); + this.siteId = b.siteId; + this.groupId = b.groupId; + this.driveId = b.driveId; + this.userId = b.userId; + this.files = b.files; + this.fileExtensions = b.fileExtensions; + this.fileListQuery = b.fileListQuery; + this.fileQueries = b.fileQueries; + this.itemPath = b.itemPath; + this.insightType = b.insightType; + this.itemId = b.itemId; + this.fireCustomEvent('breadcrumbclick', b); + }; + + private renderDeleteDialog() { + return html` + +
+

${strings.deleteFileTitle} ${this._activeFile?.name}

+ +

${strings.deleteFileMessage}

+
+ + ${strings.deleteFileButton} + + + ${strings.cancel} + +
+
+
+`; + } + + private renderNewFolderDialog() { + return html` + +
+

${strings.newFolderTitle}

+ +

+ +

+
+ + ${strings.newFolderButton} + + + ${strings.cancel} + +
+
+
+`; + } + + private renderRenameDialog() { + return html` + +
+

${strings.renameFileTitle} ${this._activeFile?.name}

+ +

+ +

+
+ + ${strings.renameFileButton} + + + ${strings.cancel} + +
+
+
+`; + } + + private renderShareDialog() { + return html` + +
+

${strings.shareFileTitle} ${this._activeFile?.name}

+ +

${this.shareMode === 'view' ? strings.shareViewOnlyLink : strings.shareEditableLink}

+

+ ${ + this.shareUrl + ? html` + + ` + : mgtHtml` +

+ +
+ ` + } +

+
+ + ${strings.copyToClipboardButton} + +
+
+
+`; + } + + private showShareFileEditable = (e: UIEvent, file: DriveItem): void => { + void this.showShareDialog(e, file, 'edit'); + }; + + private showShareFileReadOnly = (e: UIEvent, file: DriveItem): void => { + void this.showShareDialog(e, file, 'edit'); + }; + + private showShareDialog = async (e: UIEvent, file: DriveItem, shareMode: 'view' | 'edit'): Promise => { + e.stopPropagation(); + this._activeFile = file; + this.shareDialogVisible = true; + this.shareMode = shareMode; + const graph = Providers.globalProvider.graph.forComponent(this); + await shareDriveItem(graph, this._activeFile, this.shareMode).then(share => { + this.shareUrl = share.link?.webUrl; + }); + }; + + // needs to be an arrow function to preserve the this context + private downloadFile = (e: UIEvent, file: DriveItem): void => { + e.stopPropagation(); + this.clickFileLink(file['@microsoft.graph.downloadUrl'] as string); + }; + + private clickFileLink = (url: string) => { + const a: HTMLAnchorElement = this.renderRoot.querySelector('#file-link'); + a.href = url; + if (a.href) { + a.click(); + a.href = ''; + } + }; + + private showNewFolderDialog = (e: UIEvent): void => { + e.stopPropagation(); + this.newFolderDialogVisible = true; + }; + + private showRenameFileDialog = (e: UIEvent, file: DriveItem): void => { + e.stopPropagation(); + this._activeFile = file; + this.renameDialogVisible = true; + }; + + private showDeleteDialog = (e: UIEvent, file: DriveItem): void => { + e.stopPropagation(); + this._activeFile = file; + this.deleteDialogVisible = true; + }; + + private copyToClipboard = async (e: UIEvent) => { + e.stopPropagation(); + await navigator.clipboard.writeText(this.shareUrl); + this.shareUrl = null; + this.shareDialogVisible = false; + this._activeFile = null; + }; + + private cancelShare = (e: UIEvent) => { + e.stopPropagation(); + this.shareUrl = null; + this.shareDialogVisible = false; + this._activeFile = null; + }; + + private cancelRename = (e: UIEvent) => { + e.stopPropagation(); + this._activeFile = null; + this.renameDialogVisible = false; + }; + + private cancelNewFolder = (e: UIEvent) => { + e.stopPropagation(); + this.newFolderDialogVisible = false; + }; + + private reloadFiles() { + const grid: MgtFileGrid = this.renderRoot.querySelector('#files'); + grid.reload(true); + } + + private addNewFolder = async (e: UIEvent) => { + e.preventDefault(); // stop form submission and page refresh + e.stopPropagation(); + const input: HTMLInputElement = this.renderRoot.querySelector('#new-folder-name'); + const newFolderName = input.value; + if (newFolderName) { + const graph = Providers.globalProvider.graph.forComponent(this); + await addFolder(graph, this.driveId, this.itemId || 'root', newFolderName); + // need to refresh the list being shown.... + this.reloadFiles(); + this.newFolderDialogVisible = false; + await this.requestStateUpdate(); + } + }; + + private performRename = async (e: UIEvent) => { + e.preventDefault(); // stop form submission and page refresh + e.stopPropagation(); + const input: HTMLInputElement = this.renderRoot.querySelector('#new-file-name'); + const newFileName = input.value; + if (newFileName) { + const graph = Providers.globalProvider.graph.forComponent(this); + await renameDriveItem(graph, this._activeFile, newFileName); + // need to refresh the list being shown.... + this.reloadFiles(); + this.renameDialogVisible = false; + this._activeFile = null; + await this.requestStateUpdate(); + } + }; + + private performDelete = async (e: UIEvent) => { + e.stopPropagation(); + const graph = Providers.globalProvider.graph.forComponent(this); + await deleteDriveItem(graph, this._activeFile); + // need to refresh the list being shown.... + await clearFilesCache(); + this.deleteDialogVisible = false; + this._activeFile = null; + await this.requestStateUpdate(); + }; + + private cancelDelete = (e: UIEvent) => { + e.stopPropagation(); + this._activeFile = null; + this.deleteDialogVisible = false; + }; + + private onFileUploadChanged = (e: CustomEvent) => { + this.fileUploadData = e.detail.slice(); + }; + + private onFileUploadSuccess = (e: CustomEvent) => { + // this.fileUploadData = e.detail; + }; +} diff --git a/packages/mgt-components/src/components/mgt-file-list-composite/strings.ts b/packages/mgt-components/src/components/mgt-file-list-composite/strings.ts new file mode 100644 index 0000000000..323d34cfc5 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list-composite/strings.ts @@ -0,0 +1,23 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +export const strings = { + rootNode: 'Home', + deleteFileTitle: 'Delete file: ', + deleteFileMessage: 'Are you sure you want to delete this file? This action cannot be undone.', + deleteFileButton: 'Delete', + renameFileTitle: 'Rename file: ', + renameFileButton: 'Rename', + cancel: 'Cancel', + shareFileTitle: 'Share file: ', + shareViewOnlyLink: 'View only link', + shareEditableLink: 'Edit link', + copyToClipboardButton: 'Copy to clipboard', + newFolderTitle: 'New folder', + newFolderPlaceholder: 'Folder name', + newFolderButton: 'Create' +}; diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-list-base.ts b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list-base.ts new file mode 100644 index 0000000000..ffa2f444ee --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list-base.ts @@ -0,0 +1,243 @@ +import { arraysAreEqual } from '@microsoft/mgt-element'; +import { DriveItem } from '@microsoft/microsoft-graph-types'; +import { property } from 'lit/decorators.js'; +import { ViewType } from '../../graph/types'; +import { MgtFileBase } from '../mgt-file/mgt-file-base'; + +/** + * Provides the common properties for file list components + * + * @abstract + * @class MgtFileListBase + * @extends {MgtFileBase} + */ +export abstract class MgtFileListBase extends MgtFileBase { + private _fileListQuery: string; + /** + * allows developer to provide query for a file list + * + * @type {string} + * @memberof MgtFileList + */ + @property({ + attribute: 'file-list-query' + }) + public get fileListQuery(): string { + return this._fileListQuery; + } + public set fileListQuery(value: string) { + if (value === this._fileListQuery) { + return; + } + + this._fileListQuery = value; + void this.requestStateUpdate(true); + } + + private _fileQueries: string[]; + /** + * allows developer to provide an array of file queries + * + * @type {string[]} + * @memberof MgtFileList + */ + @property({ + attribute: 'file-queries', + converter: (value, type) => { + if (value) { + return value.split(',').map(v => v.trim()); + } else { + return null; + } + } + }) + public get fileQueries(): string[] { + return this._fileQueries; + } + public set fileQueries(value: string[]) { + if (arraysAreEqual(this._fileQueries, value)) { + return; + } + + this._fileQueries = value; + void this.requestStateUpdate(true); + } + + /** + * allows developer to provide an array of files + * + * @type {MicrosoftGraph.DriveItem[]} + * @memberof MgtFileList + */ + @property({ type: Object }) + public files: DriveItem[]; + + /** + * Sets what data to be rendered (file icon only, oneLine, twoLines threeLines). + * Default is 'threeLines'. + * + * @type {ViewType} + * @memberof MgtFileList + */ + @property({ + attribute: 'item-view', + converter: value => { + if (!value || value.length === 0) { + return ViewType.threelines; + } + + value = value.toLowerCase(); + + if (typeof ViewType[value] === 'undefined') { + return ViewType.threelines; + } else { + return ViewType[value] as ViewType; + } + } + }) + public itemView: ViewType; + + private _fileExtensions: string[]; + /** + * allows developer to provide file type to filter the list + * can be docx + * + * @type {string[]} + * @memberof MgtFileList + */ + @property({ + attribute: 'file-extensions', + converter: (value, type) => { + return value.split(',').map(v => v.trim()); + } + }) + public get fileExtensions(): string[] { + return this._fileExtensions; + } + public set fileExtensions(value: string[]) { + if (arraysAreEqual(this._fileExtensions, value)) { + return; + } + + this._fileExtensions = value; + void this.requestStateUpdate(true); + } + + private _pageSize: number; + /** + * A number value to indicate the number of more files to load when show more button is clicked + * + * @type {number} + * @memberof MgtFileList + */ + @property({ + attribute: 'page-size', + type: Number + }) + public get pageSize(): number { + return this._pageSize; + } + public set pageSize(value: number) { + if (value === this._pageSize) { + return; + } + + this._pageSize = value; + void this.requestStateUpdate(true); + } + + /** + * A boolean value indication if 'show-more' button should be disabled + * + * @type {boolean} + * @memberof MgtFileList + */ + @property({ + attribute: 'hide-more-files-button', + type: Boolean + }) + public hideMoreFilesButton: boolean; + + private _maxFileSize: number; + /** + * A number value indication for file size upload (KB) + * + * @type {number} + * @memberof MgtFileList + */ + @property({ + attribute: 'max-file-size', + type: Number + }) + public get maxFileSize(): number { + return this._maxFileSize; + } + public set maxFileSize(value: number) { + if (value === this._maxFileSize) { + return; + } + + this._maxFileSize = value; + void this.requestStateUpdate(true); + } + + /** + * A boolean value indication if file upload extension should be enable or disabled + * + * @type {boolean} + * @memberof MgtFileList + */ + @property({ + attribute: 'enable-file-upload', + type: Boolean + }) + public enableFileUpload: boolean; + + private _maxUploadFile: number; + /** + * A number value to indicate the max number allowed of files to upload. + * + * @type {number} + * @memberof MgtFileList + */ + @property({ + attribute: 'max-upload-file', + type: Number + }) + public get maxUploadFile(): number { + return this._maxUploadFile; + } + public set maxUploadFile(value: number) { + if (value === this._maxUploadFile) { + return; + } + + this._maxUploadFile = value; + void this.requestStateUpdate(true); + } + + private _excludedFileExtensions: string[]; + /** + * A Array of file extensions to be excluded from file upload. + * + * @type {string[]} + * @memberof MgtFileList + */ + @property({ + attribute: 'excluded-file-extensions', + converter: (value, type) => { + return value.split(',').map(v => v.trim()); + } + }) + public get excludedFileExtensions(): string[] { + return this._excludedFileExtensions; + } + public set excludedFileExtensions(value: string[]) { + if (arraysAreEqual(this._excludedFileExtensions, value)) { + return; + } + + this._excludedFileExtensions = value; + void this.requestStateUpdate(true); + } +} diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.scss b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.scss index fa4f8f2519..b975b97474 100644 --- a/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.scss +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.scss @@ -31,6 +31,56 @@ $progress-ring-size: var(--progress-ring-size, 24px); :host { font-family: $font-family; font-size: $font-size; + color: $color; +} + +:host { + &::part(data-row) { + align-items: center; + &:hover { + background-color: var(--neutral-layer-2); + + .mgt-file-item { + --file-background-color: var(--neutral-layer-2); + } + } + } + &::part(file-item) { + width: fit-content; + } + .selected-row { + background-color: var(--file-background-color-active, var(--neutral-layer-3)); + .mgt-file-item { + --file-background-color: var(--file-background-color-active, var(--neutral-layer-3)); + } + } + .file-selector { + height: 24px; + width: 24px; + border-radius: 50%; + border-color: var(--neutral-fill-strong-rest); + background-color: var(--neutral-fill-rest); + svg { + fill: var(--neutral-fill-rest); + } + &:hover { + svg { + fill: var(--neutral-fill-strong-hover); + } + } + + &.selected { + border-color: var(--neutral-fill-rest); + svg { + fill: var(--accent-fill-rest); + } + :hover { + svg { + fill: var(--neutral-fill-rest); + } + } + } + } .title { font-size: 14px; @@ -109,4 +159,7 @@ $progress-ring-size: var(--progress-ring-size, 24px); font-size: $show-more-button-font-size; } } + .interactive-breadcrumb { + cursor: pointer; + } } diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.ts b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.ts index 5045fcc994..b7d42397e0 100644 --- a/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.ts +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-list.ts @@ -5,19 +5,10 @@ * ------------------------------------------------------------------------------------------- */ -import { - arraysAreEqual, - GraphPageIterator, - Providers, - ProviderState, - customElement, - mgtHtml, - MgtTemplatedComponent -} from '@microsoft/mgt-element'; +import { GraphPageIterator, Providers, ProviderState, customElement, mgtHtml } from '@microsoft/mgt-element'; import { DriveItem } from '@microsoft/microsoft-graph-types'; -import { classMap } from 'lit/directives/class-map.js'; -import { html, TemplateResult } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { html, PropertyValueMap, TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { clearFilesCache, @@ -40,17 +31,31 @@ import { } from '../../graph/graph.files'; import './mgt-file-upload/mgt-file-upload'; import { getSvg, SvgIcon } from '../../utils/SvgHelper'; -import { OfficeGraphInsightString, ViewType } from '../../graph/types'; +import { ViewType } from '../../graph/types'; import { styles } from './mgt-file-list-css'; import { strings } from './strings'; import { MgtFile } from '../mgt-file/mgt-file'; -import { MgtFileUploadConfig } from './mgt-file-upload/mgt-file-upload'; +import { MgtFileUpload, MgtFileUploadConfig } from './mgt-file-upload/mgt-file-upload'; -import { fluentProgressRing } from '@fluentui/web-components'; +import { + fluentProgressRing, + fluentDesignSystemProvider, + fluentDataGrid, + fluentDataGridRow, + fluentDataGridCell +} from '@fluentui/web-components'; import { registerFluentComponents } from '../../utils/FluentComponents'; +import '../mgt-menu/mgt-menu'; +import { MgtFileListBase } from './mgt-file-list-base'; import { CardSection } from '../BasePersonCardSection'; -registerFluentComponents(fluentProgressRing); +registerFluentComponents( + fluentProgressRing, + fluentDesignSystemProvider, + fluentDataGrid, + fluentDataGridRow, + fluentDataGridCell +); /** * The File List component displays a list of multiple folders and files by @@ -79,9 +84,9 @@ registerFluentComponents(fluentProgressRing); * @cssprop --progress-ring-size -{String} Progress ring height and width. Default value is 24px. */ +// tslint:disable-next-line: max-classes-per-file @customElement('file-list') -// @customElement('mgt-file-list') -export class MgtFileList extends MgtTemplatedComponent implements CardSection { +export class MgtFileList extends MgtFileListBase implements CardSection { private _isCompact = false; /** * Array of styles to apply to the element. The styles should be defined @@ -91,29 +96,52 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { return styles; } + /** + * Strings to be used in the component + * + * @readonly + * @protected + * @memberof MgtFileList + */ protected get strings(): Record { return strings; } /** - * allows developer to provide query for a file list + * Get the scopes required for file list * - * @type {string} + * @static + * @return {*} {string[]} * @memberof MgtFileList */ - @property({ - attribute: 'file-list-query' - }) - public get fileListQuery(): string { - return this._fileListQuery; + public static get requiredScopes(): string[] { + return [...new Set([...MgtFile.requiredScopes])]; } - public set fileListQuery(value: string) { - if (value === this._fileListQuery) { - return; - } - this._fileListQuery = value; - void this.requestStateUpdate(true); + private _preloadedFiles: DriveItem[]; + private pageIterator: GraphPageIterator; + // tracking user arrow key input of selection for accessibility purpose + private _focusedItemIndex = -1; + + @state() + private _isLoadingMore: boolean; + + @state() + private _selectedFiles: Map; + + constructor() { + super(); + this._selectedFiles = new Map(); + this.pageSize = 10; + this.itemView = ViewType.twolines; + this.maxUploadFile = 10; + this.enableFileUpload = false; + this._preloadedFiles = []; + } + + protected updated(changedProperties: PropertyValueMap | Map): void { + super.updated(changedProperties); + this.renderRoot.querySelector('mgt-file-upload')?.attachEventListeners(); } /** @@ -148,397 +176,6 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { return getSvg(SvgIcon.Files); } - /** - * allows developer to provide an array of file queries - * - * @type {string[]} - * @memberof MgtFileList - */ - @property({ - attribute: 'file-queries', - converter: (value, type) => { - if (value) { - return value.split(',').map(v => v.trim()); - } else { - return null; - } - } - }) - public get fileQueries(): string[] { - return this._fileQueries; - } - public set fileQueries(value: string[]) { - if (arraysAreEqual(this._fileQueries, value)) { - return; - } - - this._fileQueries = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide an array of files - * - * @type {MicrosoftGraph.DriveItem[]} - * @memberof MgtFileList - */ - @property({ type: Object }) - public files: DriveItem[]; - - /** - * allows developer to provide site id for a file - * - * @type {string} - * @memberof MgtFileList - */ - @property({ - attribute: 'site-id' - }) - public get siteId(): string { - return this._siteId; - } - public set siteId(value: string) { - if (value === this._siteId) { - return; - } - - this._siteId = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide drive id for a file - * - * @type {string} - * @memberof MgtFileList - */ - @property({ - attribute: 'drive-id' - }) - public get driveId(): string { - return this._driveId; - } - public set driveId(value: string) { - if (value === this._driveId) { - return; - } - - this._driveId = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide group id for a file - * - * @type {string} - * @memberof MgtFileList - */ - @property({ - attribute: 'group-id' - }) - public get groupId(): string { - return this._groupId; - } - public set groupId(value: string) { - if (value === this._groupId) { - return; - } - - this._groupId = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide item id for a file - * - * @type {string} - * @memberof MgtFileList - */ - @property({ - attribute: 'item-id' - }) - public get itemId(): string { - return this._itemId; - } - public set itemId(value: string) { - if (value === this._itemId) { - return; - } - - this._itemId = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide item path for a file - * - * @type {string} - * @memberof MgtFileList - */ - @property({ - attribute: 'item-path' - }) - public get itemPath(): string { - return this._itemPath; - } - public set itemPath(value: string) { - if (value === this._itemPath) { - return; - } - - this._itemPath = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide user id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'user-id' - }) - public get userId(): string { - return this._userId; - } - public set userId(value: string) { - if (value === this._userId) { - return; - } - - this._userId = value; - void this.requestStateUpdate(true); - } - - /** - * allows developer to provide insight type for a file - * can be trending, used, or shared - * - * @type {OfficeGraphInsightString} - * @memberof MgtFileList - */ - @property({ - attribute: 'insight-type' - }) - public get insightType(): OfficeGraphInsightString { - return this._insightType; - } - public set insightType(value: OfficeGraphInsightString) { - if (value === this._insightType) { - return; - } - - this._insightType = value; - void this.requestStateUpdate(true); - } - - /** - * Sets what data to be rendered (file icon only, oneLine, twoLines threeLines). - * Default is 'threeLines'. - * - * @type {ViewType} - * @memberof MgtFileList - */ - @property({ - attribute: 'item-view', - converter: value => { - if (!value || value.length === 0) { - return ViewType.threelines; - } - - value = value.toLowerCase(); - - if (typeof ViewType[value] === 'undefined') { - return ViewType.threelines; - } else { - return ViewType[value] as ViewType; - } - } - }) - public itemView: ViewType; - - /** - * allows developer to provide file type to filter the list - * can be docx - * - * @type {string[]} - * @memberof MgtFileList - */ - @property({ - attribute: 'file-extensions', - converter: (value, type) => { - return value.split(',').map(v => v.trim()); - } - }) - public get fileExtensions(): string[] { - return this._fileExtensions; - } - public set fileExtensions(value: string[]) { - if (arraysAreEqual(this._fileExtensions, value)) { - return; - } - - this._fileExtensions = value; - void this.requestStateUpdate(true); - } - - /** - * A number value to indicate the number of more files to load when show more button is clicked - * - * @type {number} - * @memberof MgtFileList - */ - @property({ - attribute: 'page-size', - type: Number - }) - public get pageSize(): number { - return this._pageSize; - } - public set pageSize(value: number) { - if (value === this._pageSize) { - return; - } - - this._pageSize = value; - void this.requestStateUpdate(true); - } - - /** - * A boolean value indication if 'show-more' button should be disabled - * - * @type {boolean} - * @memberof MgtFileList - */ - @property({ - attribute: 'hide-more-files-button', - type: Boolean - }) - public hideMoreFilesButton: boolean; - - /** - * A number value indication for file size upload (KB) - * - * @type {number} - * @memberof MgtFileList - */ - @property({ - attribute: 'max-file-size', - type: Number - }) - public get maxFileSize(): number { - return this._maxFileSize; - } - public set maxFileSize(value: number) { - if (value === this._maxFileSize) { - return; - } - - this._maxFileSize = value; - void this.requestStateUpdate(true); - } - - /** - * A boolean value indication if file upload extension should be enable or disabled - * - * @type {boolean} - * @memberof MgtFileList - */ - @property({ - attribute: 'enable-file-upload', - type: Boolean - }) - public enableFileUpload: boolean; - - /** - * A number value to indicate the max number allowed of files to upload. - * - * @type {number} - * @memberof MgtFileList - */ - @property({ - attribute: 'max-upload-file', - type: Number - }) - public get maxUploadFile(): number { - return this._maxUploadFile; - } - public set maxUploadFile(value: number) { - if (value === this._maxUploadFile) { - return; - } - - this._maxUploadFile = value; - void this.requestStateUpdate(true); - } - - /** - * A Array of file extensions to be excluded from file upload. - * - * @type {string[]} - * @memberof MgtFileList - */ - @property({ - attribute: 'excluded-file-extensions', - converter: (value, type) => { - return value.split(',').map(v => v.trim()); - } - }) - public get excludedFileExtensions(): string[] { - return this._excludedFileExtensions; - } - public set excludedFileExtensions(value: string[]) { - if (arraysAreEqual(this._excludedFileExtensions, value)) { - return; - } - - this._excludedFileExtensions = value; - void this.requestStateUpdate(true); - } - - /** - * Get the scopes required for file list - * - * @static - * @return {*} {string[]} - * @memberof MgtFileList - */ - public static get requiredScopes(): string[] { - return [...new Set([...MgtFile.requiredScopes])]; - } - - private _fileListQuery: string; - private _fileQueries: string[]; - private _siteId: string; - private _itemId: string; - private _driveId: string; - private _itemPath: string; - private _groupId: string; - private _insightType: OfficeGraphInsightString; - private _fileExtensions: string[]; - private _pageSize: number; - private _excludedFileExtensions: string[]; - private _maxUploadFile: number; - private _maxFileSize: number; - private _userId: string; - private _preloadedFiles: DriveItem[]; - private pageIterator: GraphPageIterator; - // tracking user arrow key input of selection for accessibility purpose - private _focusedItemIndex = -1; - - @state() private _isLoadingMore: boolean; - - constructor() { - super(); - - this.pageSize = 10; - this.itemView = ViewType.twolines; - this.maxUploadFile = 10; - this.enableFileUpload = false; - this._preloadedFiles = []; - } - /** * Override requestStateUpdate to include clearstate. * @@ -558,6 +195,9 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { super.clearState(); this._isCompact = false; this.files = null; + this._isCompact = false; + this._selectedFiles = new Map(); + this.fireCustomEvent('selectionChanged', []); } /** @@ -675,8 +315,8 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { class="file-item" @keydown="${this.onFileListKeyDown}" @focus="${this.onFocusFirstItem}" - @click=${(e: UIEvent) => this.handleItemSelect(files[0], e)}> - ${this.renderFile(files[0])} + @click=${(e: MouseEvent) => this.handleItemSelect(this.files[0], e)}> + ${this.renderFile(this.files[0])} ${repeat( files.slice(1), @@ -685,7 +325,7 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection {
  • this.handleItemSelect(f, e)}> + @click=${(e: MouseEvent) => this.handleItemSelect(f, e)}> ${this.renderFile(f)}
  • ` @@ -715,7 +355,12 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { return ( this.renderTemplate('file', { file }, file.id) || mgtHtml` - + this.handleItemSelect(file, e)} + part="file-item" + .fileDetails=${file} + .view=${view} + > ` ); } @@ -738,7 +383,7 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { appearance="stealth" id="show-more" class="show-more" - @click=${() => this.renderNextPage()} + @click=${this.renderNextPage} > ${this.strings.showMoreSubtitle} `; @@ -809,7 +454,7 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { const file = focusedItem.children[0] as MgtFile; event.preventDefault(); - this.fireCustomEvent('itemClick', file.fileDetails); + this.raiseItemClickedEvent(file.fileDetails); this.updateItemBackgroundColor(fileList, focusedItem, 'selected'); } @@ -819,6 +464,10 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { } }; + private raiseItemClickedEvent(file: DriveItem) { + this.fireCustomEvent('itemClick', file); + } + /** * load state into the component. * @@ -946,9 +595,10 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { * @protected * @memberof MgtFileList */ - protected handleItemSelect(item: DriveItem, event: UIEvent): void { + protected handleItemSelect(item: DriveItem, event: MouseEvent): void { + event?.stopPropagation(); this.handleFileClick(item); - this.fireCustomEvent('itemClick', item); + this.raiseItemClickedEvent(item); // handle accessibility updates when item clicked if (event) { @@ -970,7 +620,7 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { * @protected * @memberof MgtFileList */ - protected async renderNextPage() { + protected renderNextPage = async () => { // render next page from cache if exists, or else use iterator if (this._preloadedFiles.length > 0) { this.files = [ @@ -1008,7 +658,7 @@ export class MgtFileList extends MgtTemplatedComponent implements CardSection { } this.requestUpdate(); - } + }; private handleFileClick(file: DriveItem) { if (file && file.webUrl) { diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.scss b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.scss new file mode 100644 index 0000000000..bae33d0887 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.scss @@ -0,0 +1,247 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +@import '../../../../../../node_modules/office-ui-fabric-core/dist/sass/References'; +@import '../../../styles/shared-sass-variables.scss'; +@import './mgt-file-upload.theme.scss'; + +$file-upload-button-border: var(--file-upload-button-border, none); +$file-upload-dialog-replace-button-border: var( + --file-upload-dialog-replace-button-border, + 1px solid var(--neutral-foreground-rest) +); +$file-upload-dialog-keep-both-button-border: var(--file-upload-dialog-keep-both-button-border, none); +$file-upload-dialog-border: var(--file-upload-dialog-border, 1px solid var(--neutral-fill-rest)); +$file-upload-dialog-width: var(--file-upload-dialog-width, auto); +$file-upload-dialog-height: var(--file-upload-dialog-height, auto); +$file-upload-dialog-padding: var(--file-upload-dialog-padding, 24px); +$file-upload-border-drag: var(--file-upload-border-drag, 1px dashed #0078d4); + +:host { + .file-upload-area-button { + width: auto; + display: flex; + align-items: end; + justify-content: end; + margin-inline-end: 36px; + margin-top: 30px; + } + + fluent-button { + .upload-icon { + path { + fill: $file-upload-button-text-color; + } + } + + &.file-upload-button { + &::part(control) { + border: $file-upload-button-border; + background: $file-upload-button-background-color; + + &:hover { + background: $file-upload-button-background-color-hover; + } + } + + .upload-text { + color: $file-upload-button-text-color; + font-weight: 400; + line-height: 20px; + } + } + } + + input { + display: none; + } + + fluent-progress { + &.file-upload-bar { + width: 180px; + margin-top: 10px; + } + } + + fluent-dialog { + &::part(overlay) { + opacity: 0.5; + } + + &::part(control) { + --dialog-width: $file-upload-dialog-width; + --dialog-height: $file-upload-dialog-height; + padding: $file-upload-dialog-padding; + border: $file-upload-dialog-border; + } + + .file-upload-dialog- { + &ok { + background: $file-upload-dialog-keep-both-button-background-color; + border: $file-upload-dialog-keep-both-button-border; + color: $file-upload-dialog-keep-both-button-text-color; + + &:hover { + background: $file-upload-dialog-keep-both-button-background-color-hover; + } + } + + &cancel { + background: $file-upload-dialog-replace-button-background-color; + border: $file-upload-dialog-replace-button-border; + color: $file-upload-dialog-replace-button-text-color; + + &:hover { + background: $file-upload-dialog-replace-button-background-color-hover; + } + } + } + } + + fluent-checkbox { + margin-top: 12px; + + .file-upload-dialog-check { + color: var(--file-upload-dialog-text-color, --foreground-on-accent-rest); + } + } +} + +:host .file-upload-table { + display: flex; + + &.upload { + display: flex; + } + + .file-upload-cell { + padding: 1px 0px 1px 1px; + display: table-cell; + vertical-align: middle; + position: relative; + + &.percent-indicator { + padding-inline-start: 10px; + } + + .description { + opacity: 0.5; + position: relative; + } + + .file-upload-filename { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-upload-status { + position: absolute; + left: 28px; + } + + .file-upload-cancel { + cursor: pointer; + margin-inline-start: 20px; + } + + .file-upload-name { + width: auto; + } + + .cancel-icon { + fill: $file-upload-dialog-text-color; + } + } +} + +:host .mgt-file-item { + --file-background-color: transparent; + --file-padding: 0 12px; + --file-padding-inline-start: 24px; +} + +:host .file-upload-Template { + display: flex; + flex-direction: row; + + .file-upload-folder-tab { + padding-inline-start: 20px; + } +} + +/* The Modal (background) */ +:host .file-upload-dialog { + display: none; + + .file-upload-dialog-content { + background-color: $file-upload-dialog-background-color; + color: $file-upload-dialog-text-color; + } + + .file-upload-dialog-content-text { + margin-bottom: 36px; + } + + .file-upload-dialog-title { + margin-top: 0px; + } + + .file-upload-dialog-editor { + display: flex; + align-items: end; + justify-content: end; + gap: 5px; + } + + .file-upload-dialog-close { + float: right; + cursor: pointer; + + svg { + fill: $file-upload-dialog-text-color; + padding-right: 5px; + } + } + + &.visible { + display: block; + } +} + +:host fluent-checkbox.file-upload-dialog-check.hide { + display: none; +} + +:host .file-upload-dialog-success { + cursor: pointer; + opacity: 0.5; +} + +:host #file-upload-border { + display: none; + + &.visible { + border: $file-upload-border-drag; + background-color: $file-upload-background-color-drag; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1; + display: inline-block; + } +} + +[dir='rtl'] { + :host .file-upload-status { + left: 0px; + right: 28px; + } +} diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.ts b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.ts new file mode 100644 index 0000000000..9c6d64e688 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload-progress.ts @@ -0,0 +1,274 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +import { fluentProgress } from '@fluentui/web-components'; +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { customElement, MgtTemplatedComponent, mgtHtml } from '@microsoft/mgt-element'; +import { MgtFileUploadItem } from './mgt-file-upload'; +import { ViewType } from '../../../graph/types'; +import { registerFluentComponents } from '../../../utils/FluentComponents'; +import { classMap } from 'lit/directives/class-map.js'; +import { getSvg, SvgIcon } from '../../../utils/SvgHelper'; +import { styles } from './mgt-file-upload-progress-css'; +import { strings } from './strings'; + +registerFluentComponents(fluentProgress); + +/** + * Component used to render progress of file upload + * + * @fires {CustomEvent} clearnotification - Fired when notification is cleared + * + * @export + * @class mgt-component + * @extends {MgtTemplatedComponent} + */ +@customElement('file-upload-progress') +export class MgtFileUploadProgress extends MgtTemplatedComponent { + /** + * Array of styles to apply to the element. The styles should be defined + * using the `css` tag function. + */ + static get styles() { + return styles; + } + + protected get strings() { + return strings; + } + + /** + * Array of progress items to render + * + * @type {MgtFileUploadItem[]} + * @memberof MgtComponent + */ + @property({ + attribute: null + }) + public progressItems: MgtFileUploadItem[] = []; + + /** + * Synchronizes property values when attributes change. + * + * @param {string} name + * @param {string} oldValue + * @param {string} newValue + * @memberof MgtPersonCard + */ + public attributeChangedCallback(name: string, oldValue: string, newValue: string) { + super.attributeChangedCallback(name, oldValue, newValue); + + // TODO: handle when an attribute changes. + // + // Ex: load data when the name attribute changes + // if (name === 'person-id' && oldval !== newval){ + // this.loadData(); + // } + } + + /** + * Invoked on each update to perform rendering tasks. This method must return + * a lit-html TemplateResult. Setting properties inside this method will *not* + * trigger the element to update. + */ + protected render() { + return html` +
    +
    + ${this.renderFolderTemplate(this.progressItems)} +
    +
    + `; + } + + /** + * Render Folder structure of files to upload + * + * @param fileItems + * @returns + */ + protected renderFolderTemplate(fileItems: MgtFileUploadItem[]) { + const folderStructure: string[] = []; + if (fileItems.length > 0) { + const templateFileItems = fileItems.map(fileItem => { + if (folderStructure.indexOf(fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/'))) === -1) { + if (fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/')) !== '') { + folderStructure.push(fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/'))); + return mgtHtml` +
    +
    + + +
    +
    + ${this.renderFileTemplate(fileItem, 'file-upload-folder-tab')}`; + } else { + return html`${this.renderFileTemplate(fileItem, '')}`; + } + } else { + return html`${this.renderFileTemplate(fileItem, 'file-upload-folder-tab')}`; + } + }); + return html`${templateFileItems}`; + } + return nothing; + } + + /** + * Render file upload area + * + * @param fileItem + * @returns + */ + protected renderFileTemplate(fileItem: MgtFileUploadItem, folderTabStyle: string) { + const completed = classMap({ + 'file-upload-table': true, + upload: fileItem.completed + }); + const folder = + folderTabStyle + (fileItem.fieldUploadResponse === 'lastModifiedDateTime' ? ' file-upload-dialog-success' : ''); + + const description = classMap({ + description: fileItem.fieldUploadResponse === 'description' + }); + + const completedTemplate = !fileItem.completed + ? this.renderFileUploadTemplate(fileItem) + : this.renderCompletedUploadButton(fileItem); + + return mgtHtml` +
    +
    +
    +
    +
    + ${fileItem.iconStatus} +
    + + +
    +
    + ${completedTemplate} +
    +
    + `; + } + + /** + * Render file upload progress + * + * @param fileItem + * @returns + */ + protected renderFileUploadTemplate(fileItem: MgtFileUploadItem) { + const completed = classMap({ + 'file-upload-table': true, + upload: fileItem.completed + }); + return html` +
    +
    +
    +
    + ${fileItem.file.name} +
    +
    +
    +
    +
    +
    + +
    + ${fileItem.percent}% + this.deleteFileUploadSession(fileItem)}> + ${getSvg(SvgIcon.Cancel)} + +
    +
    +
    +
    +
    + `; + } + + private renderCompletedUploadButton(fileItem: MgtFileUploadItem) { + const ariaLabel = `${strings.clearNotification} ${fileItem.file.name}`; + return html` + + this.removeNotification(fileItem)} + > + ${getSvg(SvgIcon.Cancel)} + + + `; + } + + private removeNotification(fileItem: MgtFileUploadItem) { + this.fireCustomEvent('clearnotification', fileItem); + } + + /** + * Function to delete existing file upload sessions + * + * @param fileItem + */ + protected deleteFileUploadSession(fileItem: MgtFileUploadItem) { + try { + if (fileItem.uploadUrl !== undefined) { + // Responses that confirm cancelation of session. + // 404 means (The upload session was not found/The resource could not be found/) + // 409 means The resource has changed since the caller last read it; usually an eTag mismatch + fileItem.uploadUrl = undefined; + fileItem.completed = true; + this.setUploadFail(fileItem, strings.cancelUploadFile); + } else { + fileItem.uploadUrl = undefined; + fileItem.completed = true; + this.setUploadFail(fileItem, strings.cancelUploadFile); + } + } catch { + fileItem.uploadUrl = undefined; + fileItem.completed = true; + this.setUploadFail(fileItem, strings.cancelUploadFile); + } + } + + /** + * Change the state of Mgt-File icon upload to Fail + * + * @param fileUpload + */ + protected setUploadFail(fileUpload: MgtFileUploadItem, errorMessage: string) { + fileUpload.iconStatus = getSvg(SvgIcon.Fail); + fileUpload.view = ViewType.twolines; + fileUpload.driveItem.description = errorMessage; + fileUpload.fieldUploadResponse = 'description'; + fileUpload.completed = true; + void super.requestStateUpdate(true); + } +} diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload.ts b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload.ts index 844d1077de..258c7d45ae 100644 --- a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload.ts +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/mgt-file-upload.ts @@ -5,14 +5,12 @@ * ------------------------------------------------------------------------------------------- */ -import { fluentButton, fluentCheckbox, fluentDialog, fluentProgress } from '@fluentui/web-components'; -import { customElement, IGraph, MgtBaseComponent, mgtHtml } from '@microsoft/mgt-element'; -import { html, TemplateResult } from 'lit'; +import { fluentButton, fluentCheckbox, fluentDialog } from '@fluentui/web-components'; +import { arraysAreEqual, customElement, IGraph, MgtBaseComponent, mgtHtml } from '@microsoft/mgt-element'; +import { html, nothing, TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; import { DriveItem } from '@microsoft/microsoft-graph-types'; -import { classMap } from 'lit/directives/class-map.js'; import { - clearFilesCache, getGraphfile, getUploadSession, sendFileContent, @@ -26,8 +24,9 @@ import { getSvg, SvgIcon } from '../../../utils/SvgHelper'; import { formatBytes } from '../../../utils/Utils'; import { styles } from './mgt-file-upload-css'; import { strings } from './strings'; +import './mgt-file-upload-progress'; -registerFluentComponents(fluentProgress, fluentButton, fluentCheckbox, fluentDialog); +registerFluentComponents(fluentButton, fluentCheckbox, fluentDialog); /** * Simple union type for file system entry and directory entry types @@ -49,6 +48,10 @@ const isFileSystemDirectoryEntry = (entry: FileEntry): entry is FileSystemDirect return entry.isDirectory; }; +const isDataTransferItem = (item: DataTransferItem | File): item is DataTransferItem => { + return (item as DataTransferItem).kind !== undefined; +}; + /** * Type guard for FileSystemDirectoryEntry * @@ -224,6 +227,15 @@ export interface MgtFileUploadConfig { * @type {string[]} */ excludedFileExtensions?: string[]; + + /** + * The element to use as the drop target for drag and drop. + * Will use the parent element if not specified and available. + * + * @type {HTMLElement} + * @memberof MgtFileUploadConfig + */ + dropTarget?: () => HTMLElement; } // eslint-disable-next-line @typescript-eslint/tslint/config @@ -239,6 +251,9 @@ interface FileWithPath extends File { * @class MgtFileUpload * @extends {MgtBaseComponent} * + * @fires - fileUploadSuccess {undefined} - Fired when a file is successfully uploaded. + * @fires - fileUploadChanged {MgtFileUploadItem[]} - Fired when file upload beings, changes state or is completed + * * @cssprop --file-upload-background-color-drag - {Color} background color of the file list when you upload by drag and drop. * @cssprop --file-upload-button-background-color - {Color} background color of the file upload button. * @cssprop --file-upload-button-background-color-hover - {Color} background color of the file upload button on hover. @@ -260,7 +275,6 @@ interface FileWithPath extends File { * @cssprop --file-upload-dialog-height - {String} the height of the file upload dialog box. Default value is auto. * @cssprop --file-upload-dialog-padding - {String} the padding of the file upload dialog box. Default value is 24px; */ - @customElement('file-upload') export class MgtFileUpload extends MgtBaseComponent { /** @@ -271,18 +285,50 @@ export class MgtFileUpload extends MgtBaseComponent { return styles; } + /** + * Strings to be used for localization + * + * @readonly + * @protected + * @memberof MgtFileUpload + */ protected get strings() { return strings; } + /** + * Disables the upload progress reporting baked into the file upload component + * + * @memberof MgtFileUpload + */ + @property({ + attribute: 'hide-inline-progress', + type: Boolean + }) + public hideInlineProgress = false; + + private _filesToUpload: MgtFileUploadItem[] = []; /** * Allows developer to provide an array of MgtFileUploadItem to upload * * @type {MgtFileUploadItem[]} * @memberof MgtFileUpload */ - @property({ type: Object }) - public filesToUpload: MgtFileUploadItem[]; + @property({ type: Object, attribute: null }) + public get filesToUpload(): MgtFileUploadItem[] { + return this._filesToUpload; + } + public set filesToUpload(value: MgtFileUploadItem[]) { + if (!arraysAreEqual(this._filesToUpload, value)) { + this._filesToUpload = value; + this.emitFileUploadChanged(); + void this.requestStateUpdate(); + } + } + + private emitFileUploadChanged() { + this.fireCustomEvent('fileUploadChanged', this._filesToUpload, true, true, true); + } /** * List of mgt-file-list properties used to upload files. @@ -320,9 +366,10 @@ export class MgtFileUpload extends MgtBaseComponent { private _maximumFileSize = false; private _excludedFileType = false; + private _dropTarget: HTMLElement; + constructor() { super(); - this.filesToUpload = []; } /** @@ -331,14 +378,6 @@ export class MgtFileUpload extends MgtBaseComponent { * @returns */ public render(): TemplateResult { - if (this.parentElement !== null) { - const root = this.parentElement; - root.addEventListener('dragenter', this.handleonDragEnter); - root.addEventListener('dragleave', this.handleonDragLeave); - root.addEventListener('dragover', this.handleonDragOver); - root.addEventListener('drop', this.handleonDrop); - } - return html`
    @@ -372,7 +411,7 @@ export class MgtFileUpload extends MgtBaseComponent {
    -
    +
    ${getSvg(SvgIcon.Upload)} ${this.strings.buttonUploadFile}
    -
    - ${this.renderFolderTemplate(this.filesToUpload)} -
    - `; - } - - /** - * Render Folder structure of files to upload - * - * @param fileItems - * @returns - */ - protected renderFolderTemplate(fileItems: MgtFileUploadItem[]) { - const folderStructure: string[] = []; - if (fileItems.length > 0) { - const templateFileItems = fileItems.map(fileItem => { - if (folderStructure.indexOf(fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/'))) === -1) { - if (fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/')) !== '') { - folderStructure.push(fileItem.fullPath.substring(0, fileItem.fullPath.lastIndexOf('/'))); - return mgtHtml` -
    -
    - - -
    -
    - ${this.renderFileTemplate(fileItem, 'file-upload-folder-tab')}`; - } else { - return html`${this.renderFileTemplate(fileItem, '')}`; - } - } else { - return html`${this.renderFileTemplate(fileItem, 'file-upload-folder-tab')}`; + ${ + // slice used here to create new array on each render to ensure that file-upload-progress re-renders as the data changes + !this.hideInlineProgress + ? mgtHtml` + ` + : nothing } - }); - return html`${templateFileItems}`; - } - return html``; + `; } - /** - * Render file upload area - * - * @param fileItem - * @returns - */ - protected renderFileTemplate(fileItem: MgtFileUploadItem, folderTabStyle: string) { - const completed = classMap({ - 'file-upload-table': true, - upload: fileItem.completed - }); - const folder = - folderTabStyle + (fileItem.fieldUploadResponse === 'lastModifiedDateTime' ? ' file-upload-dialog-success' : ''); - - const description = classMap({ - description: fileItem.fieldUploadResponse === 'description' - }); - - const completedTemplate = !fileItem.completed ? this.renderFileUploadTemplate(fileItem) : html``; - - return mgtHtml` -
    -
    -
    -
    -
    - ${fileItem.iconStatus} -
    - - -
    -
    - ${completedTemplate} -
    -
    -
    `; - } + private clearUploadNotification = (event: CustomEvent) => { + this.filesToUpload = this.filesToUpload.filter(item => item !== event.detail); + }; - /** - * Render file upload progress - * - * @param fileItem - * @returns - */ - protected renderFileUploadTemplate(fileItem: MgtFileUploadItem) { - const completed = classMap({ - 'file-upload-table': true, - upload: fileItem.completed - }); - return html` -
    -
    -
    -
    - ${fileItem.file.name} -
    -
    -
    -
    -
    -
    - -
    - ${fileItem.percent}% - this.deleteFileUploadSession(fileItem)}> - ${getSvg(SvgIcon.Cancel)} - -
    -
    -
    -
    -
    - `; + // TODO: remove these event listeners when component is disconnected + // TODO: only add eventlistners we don't have them already + public attachEventListeners() { + const root = this.fileUploadList.dropTarget?.() || this.parentElement; + if (root === this._dropTarget) return; + if (root) { + root.addEventListener('dragenter', this.handleonDragEnter); + root.addEventListener('dragleave', this.handleonDragLeave); + root.addEventListener('dragover', this.handleonDragOver); + root.addEventListener('drop', this.handleOnDrop); + this._dropTarget = root; + } } /** @@ -576,7 +516,7 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param event */ - protected handleonDragOver = (event: DragEvent) => { + private handleonDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.items && event.dataTransfer.items.length > 0) { @@ -589,10 +529,9 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param event */ - protected handleonDragEnter = (event: DragEvent) => { + private handleonDragEnter = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); - this._dragCounter++; if (event.dataTransfer.items && event.dataTransfer.items.length > 0) { event.dataTransfer.dropEffect = this._dropEffect; @@ -606,7 +545,7 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param event */ - protected handleonDragLeave = (event: DragEvent) => { + private handleonDragLeave = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); @@ -622,7 +561,7 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param event */ - protected handleonDrop = (event: DragEvent) => { + private handleOnDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); const done = (): void => { @@ -647,7 +586,7 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param inputFiles */ - protected async getSelectedFiles(files: File[]) { + private async getSelectedFiles(files: File[]) { let fileItems: MgtFileUploadItem[] = []; const fileItemsCompleted: MgtFileUploadItem[] = []; this._applyAll = false; @@ -777,7 +716,7 @@ export class MgtFileUpload extends MgtBaseComponent { * @param file * @returns */ - protected async getFileUploadStatus( + private async getFileUploadStatus( file: File, fullPath: string, DialogStatus: string, @@ -932,7 +871,7 @@ export class MgtFileUpload extends MgtBaseComponent { * @param fileItem * @returns */ - protected getGrapQuery(fullPath: string) { + private getGrapQuery(fullPath: string) { let itemPath = ''; if (this.fileUploadList.itemPath) { if (this.fileUploadList.itemPath.length > 0) { @@ -993,7 +932,7 @@ export class MgtFileUpload extends MgtBaseComponent { * @param fileUpload * @returns */ - protected async sendFileItemGraph(fileItem: MgtFileUploadItem) { + private async sendFileItemGraph(fileItem: MgtFileUploadItem) { const graph: IGraph = this.fileUploadList.graph; let graphQuery = ''; if (fileItem.file.size < this._maxChunkSize) { @@ -1057,7 +996,7 @@ export class MgtFileUpload extends MgtBaseComponent { * @param fileItem * @returns */ - protected async sendSessionUrlGraph(graph: IGraph, fileItem: MgtFileUploadItem) { + private async sendSessionUrlGraph(graph: IGraph, fileItem: MgtFileUploadItem) { while (fileItem.file.size > fileItem.minSize) { if (fileItem.mimeStreamString === undefined) { fileItem.mimeStreamString = (await this.readFileContent(fileItem.file)) as ArrayBuffer; @@ -1066,6 +1005,8 @@ export class MgtFileUpload extends MgtBaseComponent { const fileSlice: Blob = new Blob([fileItem.mimeStreamString.slice(fileItem.minSize, fileItem.maxSize)]); fileItem.percent = Math.round((fileItem.maxSize / fileItem.file.size) * 100); await super.requestStateUpdate(true); + // emit update here as the percent of the upload for the file is changed. + this.emitFileUploadChanged(); if (fileItem.uploadUrl !== undefined) { const response = await sendFileChunk( @@ -1098,17 +1039,15 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param fileUpload */ - protected setUploadSuccess(fileUpload: MgtFileUploadItem) { + private setUploadSuccess(fileUpload: MgtFileUploadItem) { fileUpload.percent = 100; - void super.requestStateUpdate(true); - setTimeout(() => { - fileUpload.iconStatus = getSvg(SvgIcon.Success); - fileUpload.view = ViewType.twolines; - fileUpload.fieldUploadResponse = 'lastModifiedDateTime'; - fileUpload.completed = true; - void super.requestStateUpdate(true); - void clearFilesCache(); - }, 500); + fileUpload.iconStatus = getSvg(SvgIcon.Success); + fileUpload.view = ViewType.twolines; + fileUpload.fieldUploadResponse = 'lastModifiedDateTime'; + fileUpload.completed = true; + this.requestUpdate(); + this.emitFileUploadChanged(); + this.fireCustomEvent('fileUploadSuccess', undefined, true, true, true); } /** @@ -1116,15 +1055,14 @@ export class MgtFileUpload extends MgtBaseComponent { * * @param fileUpload */ - protected setUploadFail(fileUpload: MgtFileUploadItem, errorMessage: string) { - setTimeout(() => { - fileUpload.iconStatus = getSvg(SvgIcon.Fail); - fileUpload.view = ViewType.twolines; - fileUpload.driveItem.description = errorMessage; - fileUpload.fieldUploadResponse = 'description'; - fileUpload.completed = true; - void super.requestStateUpdate(true); - }, 500); + private setUploadFail(fileUpload: MgtFileUploadItem, errorMessage: string) { + fileUpload.iconStatus = getSvg(SvgIcon.Fail); + fileUpload.view = ViewType.twolines; + fileUpload.driveItem.description = errorMessage; + fileUpload.fieldUploadResponse = 'description'; + fileUpload.completed = true; + this.emitFileUploadChanged(); + super.requestUpdate(); } /** @@ -1133,7 +1071,7 @@ export class MgtFileUpload extends MgtBaseComponent { * @param file * @returns */ - protected readFileContent(file: File): Promise { + private readFileContent(file: File): Promise { return new Promise((resolve, reject) => { const myReader: FileReader = new FileReader(); @@ -1160,8 +1098,8 @@ export class MgtFileUpload extends MgtBaseComponent { let entry: FileSystemEntry; const collectFilesItems: File[] = []; - for (const uploadFileItem of filesItems as DataTransferItemList) { - if (uploadFileItem.kind === 'file') { + for (const uploadFileItem of filesItems) { + if (isDataTransferItem(uploadFileItem) && uploadFileItem.kind === 'file') { // Defensive code to validate if function exists in Browser // Collect all Folders into Array const futureUpload = uploadFileItem as FutureDataTransferItem; @@ -1187,7 +1125,7 @@ export class MgtFileUpload extends MgtBaseComponent { collectFilesItems.push(file); } } - } else if ('function' == typeof uploadFileItem.getAsFile) { + } else if ('function' === typeof uploadFileItem.getAsFile) { const file = uploadFileItem.getAsFile(); if (file) { this.writeFilePath(file, ''); @@ -1196,7 +1134,7 @@ export class MgtFileUpload extends MgtBaseComponent { } continue; } else { - const fileItem = uploadFileItem.getAsFile(); + const fileItem = isDataTransferItem(uploadFileItem) ? uploadFileItem.getAsFile() : uploadFileItem; if (fileItem) { this.writeFilePath(fileItem, ''); collectFilesItems.push(fileItem); @@ -1217,13 +1155,10 @@ export class MgtFileUpload extends MgtBaseComponent { * @param folders * @returns */ - protected getFolderFiles(folders: FileSystemDirectoryEntry[]) { + private getFolderFiles(folders: FileSystemDirectoryEntry[]) { return new Promise(resolve => { let reading = 0; const contents: File[] = []; - folders.forEach(entry => { - readEntry(entry, ''); - }); const readEntry = (entry: FileEntry, path: string) => { if (isFileSystemDirectoryEntry(entry)) { @@ -1257,6 +1192,10 @@ export class MgtFileUpload extends MgtBaseComponent { } }); }; + + folders.forEach(entry => { + readEntry(entry, ''); + }); }); } private writeFilePath(file: File | FileSystemEntry, path: string) { diff --git a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/strings.ts b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/strings.ts index cd901487a9..f4a443b2be 100644 --- a/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/strings.ts +++ b/packages/mgt-components/src/components/mgt-file-list/mgt-file-upload/strings.ts @@ -7,7 +7,8 @@ export const strings = { failUploadFile: 'File upload fail.', - cancelUploadFile: 'File cancel.', + cancelUploadFile: 'File upload canceled.', + clearNotification: 'Clear notification for', buttonUploadFile: 'Upload Files', maximumFilesTitle: 'Maximum files', maximumFiles: diff --git a/packages/mgt-components/src/components/mgt-file-list/strings.ts b/packages/mgt-components/src/components/mgt-file-list/strings.ts index 79633ed619..dc03ac9be0 100644 --- a/packages/mgt-components/src/components/mgt-file-list/strings.ts +++ b/packages/mgt-components/src/components/mgt-file-list/strings.ts @@ -7,6 +7,8 @@ export const strings = { showMoreSubtitle: 'Show more items', + item: 'item', + items: 'items', filesSectionTitle: 'Files', sharedTextSubtitle: 'Shared' }; diff --git a/packages/mgt-components/src/components/mgt-file/mgt-file-base.ts b/packages/mgt-components/src/components/mgt-file/mgt-file-base.ts new file mode 100644 index 0000000000..2b79fae4bd --- /dev/null +++ b/packages/mgt-components/src/components/mgt-file/mgt-file-base.ts @@ -0,0 +1,189 @@ +import { property } from 'lit/decorators.js'; +import { MgtTemplatedComponent } from '@microsoft/mgt-element'; +import { OfficeGraphInsightString } from '../../graph/types'; + +/** + * Provides base property definitions for use in file and file list components + * + * @abstract + * @class MgtFileBase + * @extends {MgtTemplatedComponent} + */ +export abstract class MgtFileBase extends MgtTemplatedComponent { + private _siteId: string; + private _itemId: string; + private _driveId: string; + private _itemPath: string; + private _listId: string; + private _groupId: string; + private _userId: string; + private _insightType: OfficeGraphInsightString; + /** + * allows developer to provide site id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'site-id' + }) + public get siteId(): string { + return this._siteId; + } + public set siteId(value: string) { + if (value === this._siteId) { + return; + } + + this._siteId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide drive id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'drive-id' + }) + public get driveId(): string { + return this._driveId; + } + public set driveId(value: string) { + if (value === this._driveId) { + return; + } + + this._driveId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide group id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'group-id' + }) + public get groupId(): string { + return this._groupId; + } + public set groupId(value: string) { + if (value === this._groupId) { + return; + } + + this._groupId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide list id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'list-id' + }) + public get listId(): string { + return this._listId; + } + public set listId(value: string) { + if (value === this._listId) { + return; + } + + this._listId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide user id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'user-id' + }) + public get userId(): string { + return this._userId; + } + public set userId(value: string) { + if (value === this._userId) { + return; + } + + this._userId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide item id for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'item-id' + }) + public get itemId(): string { + return this._itemId; + } + public set itemId(value: string) { + if (value === this._itemId) { + return; + } + + this._itemId = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide item path for a file + * + * @type {string} + * @memberof MgtFile + */ + @property({ + attribute: 'item-path' + }) + public get itemPath(): string { + return this._itemPath; + } + public set itemPath(value: string) { + if (value === this._itemPath) { + return; + } + + this._itemPath = value; + void this.requestStateUpdate(); + } + + /** + * allows developer to provide insight type for a file + * can be trending, used, or shared + * + * @type {OfficeGraphInsightString} + * @memberof MgtFile + */ + @property({ + attribute: 'insight-type' + }) + public get insightType(): OfficeGraphInsightString { + return this._insightType; + } + public set insightType(value: OfficeGraphInsightString) { + if (value === this._insightType) { + return; + } + + this._insightType = value; + void this.requestStateUpdate(); + } +} diff --git a/packages/mgt-components/src/components/mgt-file/mgt-file.ts b/packages/mgt-components/src/components/mgt-file/mgt-file.ts index 204f25672e..e67dd19159 100644 --- a/packages/mgt-components/src/components/mgt-file/mgt-file.ts +++ b/packages/mgt-components/src/components/mgt-file/mgt-file.ts @@ -9,7 +9,7 @@ import { DriveItem } from '@microsoft/microsoft-graph-types'; import { html, TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; import { styles } from './mgt-file-css'; -import { MgtTemplatedComponent, Providers, ProviderState, customElement } from '@microsoft/mgt-element'; +import { Providers, ProviderState, customElement } from '@microsoft/mgt-element'; import { getDriveItemById, getDriveItemByPath, @@ -31,6 +31,7 @@ import { OfficeGraphInsightString, ViewType } from '../../graph/types'; import { getFileTypeIconUriByExtension } from '../../styles/fluent-icons'; import { getSvg, SvgIcon } from '../../utils/SvgHelper'; import { strings } from './strings'; +import { MgtFileBase } from './mgt-file-base'; /** * The File component is used to represent an individual file/folder from OneDrive or SharePoint by displaying information such as the file/folder name, an icon indicating the file type, and other properties such as the author, last modified date, or other details selected by the developer. @@ -65,7 +66,7 @@ import { strings } from './strings'; @customElement('file') // @customElement('mgt-file') -export class MgtFile extends MgtTemplatedComponent { +export class MgtFile extends MgtFileBase { /** * Array of styles to apply to the element. The styles should be defined * using the `css` tag function. @@ -73,6 +74,14 @@ export class MgtFile extends MgtTemplatedComponent { static get styles() { return styles; } + + /** + * Strings to be used in the component + * + * @readonly + * @protected + * @memberof MgtFile + */ protected get strings() { return strings; } @@ -98,175 +107,6 @@ export class MgtFile extends MgtTemplatedComponent { void this.requestStateUpdate(); } - /** - * allows developer to provide site id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'site-id' - }) - public get siteId(): string { - return this._siteId; - } - public set siteId(value: string) { - if (value === this._siteId) { - return; - } - - this._siteId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide drive id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'drive-id' - }) - public get driveId(): string { - return this._driveId; - } - public set driveId(value: string) { - if (value === this._driveId) { - return; - } - - this._driveId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide group id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'group-id' - }) - public get groupId(): string { - return this._groupId; - } - public set groupId(value: string) { - if (value === this._groupId) { - return; - } - - this._groupId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide list id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'list-id' - }) - public get listId(): string { - return this._listId; - } - public set listId(value: string) { - if (value === this._listId) { - return; - } - - this._listId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide user id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'user-id' - }) - public get userId(): string { - return this._userId; - } - public set userId(value: string) { - if (value === this._userId) { - return; - } - - this._userId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide item id for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'item-id' - }) - public get itemId(): string { - return this._itemId; - } - public set itemId(value: string) { - if (value === this._itemId) { - return; - } - - this._itemId = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide item path for a file - * - * @type {string} - * @memberof MgtFile - */ - @property({ - attribute: 'item-path' - }) - public get itemPath(): string { - return this._itemPath; - } - public set itemPath(value: string) { - if (value === this._itemPath) { - return; - } - - this._itemPath = value; - void this.requestStateUpdate(); - } - - /** - * allows developer to provide insight type for a file - * can be trending, used, or shared - * - * @type {OfficeGraphInsightString} - * @memberof MgtFile - */ - @property({ - attribute: 'insight-type' - }) - public get insightType(): OfficeGraphInsightString { - return this._insightType; - } - public set insightType(value: OfficeGraphInsightString) { - if (value === this._insightType) { - return; - } - - this._insightType = value; - void this.requestStateUpdate(); - } - /** * allows developer to provide insight id for a file * @@ -403,14 +243,6 @@ export class MgtFile extends MgtTemplatedComponent { } private _fileQuery: string; - private _siteId: string; - private _itemId: string; - private _driveId: string; - private _itemPath: string; - private _listId: string; - private _groupId: string; - private _userId: string; - private _insightType: OfficeGraphInsightString; private _insightId: string; private _fileDetails: DriveItem; private _fileIcon: string; @@ -423,6 +255,9 @@ export class MgtFile extends MgtTemplatedComponent { this.view = ViewType.threelines; } + /** + * Renders the file component + */ public render() { if (!this.driveItem && this.isLoadingState) { return this.renderLoading(); @@ -433,9 +268,7 @@ export class MgtFile extends MgtTemplatedComponent { } const file = this.driveItem; - let fileTemplate: TemplateResult; - - fileTemplate = this.renderTemplate('default', { file }); + let fileTemplate = this.renderTemplate('default', { file }); if (!fileTemplate) { const fileDetailsTemplate: TemplateResult = this.renderDetails(file); const fileTypeIconTemplate: TemplateResult = this.renderFileTypeIcon(); diff --git a/packages/mgt-components/src/components/mgt-file/strings.ts b/packages/mgt-components/src/components/mgt-file/strings.ts index 917af14c93..b387256917 100644 --- a/packages/mgt-components/src/components/mgt-file/strings.ts +++ b/packages/mgt-components/src/components/mgt-file/strings.ts @@ -7,5 +7,6 @@ export const strings = { modifiedSubtitle: 'Modified', - sizeSubtitle: 'Size' + sizeSubtitle: 'Size', + filesSectionTitle: 'Files' }; diff --git a/packages/mgt-components/src/components/mgt-menu/mgt-menu.scss b/packages/mgt-components/src/components/mgt-menu/mgt-menu.scss new file mode 100644 index 0000000000..98da90e6ae --- /dev/null +++ b/packages/mgt-components/src/components/mgt-menu/mgt-menu.scss @@ -0,0 +1,22 @@ +:host { + &::part(flyout) { + position: absolute; + z-index: 2; + } + &::part(control) { + background: transparent; + &:active { + background: var(--neutral-fill-stealth-active); + } + &:hover { + background: var(--neutral-fill-stealth-hover); + } + } + &::part(menu-button) { + &:hover { + svg { + fill: var(--accent-fill-hover); + } + } + } +} diff --git a/packages/mgt-components/src/components/mgt-menu/mgt-menu.ts b/packages/mgt-components/src/components/mgt-menu/mgt-menu.ts new file mode 100644 index 0000000000..553d514f96 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-menu/mgt-menu.ts @@ -0,0 +1,186 @@ +import { MgtTemplatedComponent, customElement, mgtHtml } from '@microsoft/mgt-element'; +import { html, nothing, TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { fluentAnchoredRegion, fluentButton, fluentMenu, fluentMenuItem } from '@fluentui/web-components'; +import { registerFluentComponents } from '../../utils/FluentComponents'; +import { getSvg, SvgIcon } from '../../utils/SvgHelper'; +import '../sub-components/mgt-flyout/mgt-flyout'; +import { styles } from './mgt-menu-css'; +import { MgtFlyout } from '../sub-components/mgt-flyout/mgt-flyout'; + +registerFluentComponents(fluentButton, fluentMenu, fluentMenuItem, fluentAnchoredRegion); + +/** + * Data type for menu items commands + */ +export type MenuCommand = { + // tslint:disable: completed-docs + id: string; + name: string; + glyph?: TemplateResult; + onClickFunction?: (e: UIEvent, item: T) => void; + subcommands?: MenuCommand[]; + shouldRender: (item: T) => boolean; + // tslint:enable: completed-docs +}; + +/** + * Component to provide configurable menus + * + * @class MgtMenu + * @extends {MgtTemplatedComponent} + */ +@customElement('menu') +class MgtMenu extends MgtTemplatedComponent { + /** + * Array of styles to apply to the element. The styles should be defined + * using the `css` tag function. + */ + static get styles() { + return styles; + } + + /** + * Gets the flyout element + * + * @protected + * @type {MgtFlyout} + * @memberof MgtLogin + */ + protected get flyout(): MgtFlyout { + return this.renderRoot.querySelector('.flyout'); + } + + /** + * The item to which the menu is attached + * + * @type {T} + * @memberof MgtMenu + */ + @property({ attribute: false }) + public item: T; + + @state() + private _menuOpen = false; + + /** + * Array of items to display in the menu + * + * @type {{ id: string; name: string, glyph?: TemplateResult }[]} + * @memberof MgtMenu + */ + @property({ attribute: false }) + public commands: MenuCommand[] = []; + /** + * Render the component + * + * @return {*} {TemplateResult} + * @memberof MgtMenu + */ + render() { + return this.commands?.length > 0 + ? html` + + ${getSvg(SvgIcon.MoreVertical)} + + ${this.renderMenu()} + ` + : nothing; + } + + private renderMenu() { + return mgtHtml` + +
    + + ${this.renderMenuItems(this.commands)} + +
    +
    + `; + } + + private flyoutOpened = () => { + this._menuOpen = true; + }; + private flyoutClosed = () => { + this._menuOpen = false; + }; + + private renderMenuItems(commands: MenuCommand[]): unknown { + return repeat( + commands, + command => command.id, + command => this.renderMenuItem(command) + ); + } + + private renderMenuItem(command: MenuCommand) { + return command.shouldRender(this.item) + ? html` + this.onMenuItemClicked(e, command)} + > + ${command.name} + ${ + command.subcommands && command.subcommands.length > 0 + ? html` + + ${this.renderMenuItems(command.subcommands)} + + ` + : nothing + } + + ` + : nothing; + } + + private onMenuItemClicked = (e: UIEvent, command: MenuCommand) => { + e.stopPropagation(); + command.onClickFunction?.(e, this.item); + this.hideFlyout(); + }; + + /** + * Show the flyout and its content. + * + * @protected + * @memberof MgtLogin + */ + protected showFlyout(): void { + const flyout = this.flyout; + if (flyout) { + flyout.open(); + } + } + + /** + * Dismiss the flyout. + * + * @protected + * @memberof MgtLogin + */ + protected hideFlyout(): void { + const flyout = this.flyout; + if (flyout) { + flyout.close(); + } + } + + private toggleMenu = (e: Event) => { + e.stopPropagation(); + if (this._menuOpen) { + this.hideFlyout(); + } else { + this.showFlyout(); + } + }; +} diff --git a/packages/mgt-components/src/components/mgt-theme-toggle/mgt-theme-toggle.tests.ts b/packages/mgt-components/src/components/mgt-theme-toggle/mgt-theme-toggle.tests.ts index 7a004cbb37..4cfe5926cb 100644 --- a/packages/mgt-components/src/components/mgt-theme-toggle/mgt-theme-toggle.tests.ts +++ b/packages/mgt-components/src/components/mgt-theme-toggle/mgt-theme-toggle.tests.ts @@ -5,7 +5,7 @@ * ------------------------------------------------------------------------------------------- */ // import the mock for media match first to ensure it's hoisted and available for our dependencies -import './mock-media-match'; +import '../../__mocks__/mock-media-match'; import { screen } from 'testing-library__dom'; import { fixture } from '@open-wc/testing-helpers'; import './mgt-theme-toggle'; diff --git a/packages/mgt-components/src/graph/graph.files.ts b/packages/mgt-components/src/graph/graph.files.ts index f9452f39f1..92cf46ea3a 100644 --- a/packages/mgt-components/src/graph/graph.files.ts +++ b/packages/mgt-components/src/graph/graph.files.ts @@ -14,7 +14,14 @@ import { IGraph, prepScopes } from '@microsoft/mgt-element'; -import { DriveItem, SharedInsight, Trending, UploadSession, UsedInsight } from '@microsoft/microsoft-graph-types'; +import { + DriveItem, + Permission, + SharedInsight, + Trending, + UploadSession, + UsedInsight +} from '@microsoft/microsoft-graph-types'; import { schemas } from './cacheStores'; import { GraphRequest, ResponseType } from '@microsoft/microsoft-graph-client'; import { blobToBase64 } from '../utils/Utils'; @@ -815,6 +822,74 @@ export const sendFileContent = async (graph: IGraph, resource: string, file: Fil } }; +/** + * share a drive item + */ +export const shareDriveItem = async ( + graph: IGraph, + item: DriveItem, + permType: 'view' | 'edit' +): Promise => { + try { + // build the resource from the driveItem + const resource = `/drives/${item.parentReference.driveId}/items/${item.id}/createLink`; + const scopes = 'files.readwrite'; + const permission = { + type: permType, + scope: 'organization' + }; + let response: Permission; + try { + response = (await graph.client + .api(resource) + .middlewareOptions(prepScopes(scopes)) + .post(permission)) as Permission; + } catch { + // no-op + } + + return response || null; + } catch (e) { + return null; + } +}; + +/** + * rename a drive item + */ +export const renameDriveItem = async (graph: IGraph, item: DriveItem, newName: string): Promise => { + try { + // build the resource from the driveItem + const resource = `/drives/${item.parentReference.driveId}/items/${item.id}`; + const scopes = 'files.readwrite'; + await graph.client.api(resource).middlewareOptions(prepScopes(scopes)).patch({ name: newName }); + } catch { + // no-op + } +}; + +export const addFolder = async (graph: IGraph, driveId: string, itemId: string, name: string) => { + const data = { + name, + folder: {}, + '@microsoft.graph.conflictBehavior': 'rename' + }; + await graph.api(`/drives/${driveId}/items/${itemId}/children`).post(data); +}; + +/** + * delete a drive item + */ +export const deleteDriveItem = async (graph: IGraph, item: DriveItem): Promise => { + try { + // build the resource from the driveItem + const resource = `/drives/${item.parentReference.driveId}/items/${item.id}`; + await deleteSessionFile(graph, resource); + } catch { + // no-op + } +}; + /** * delete upload session * diff --git a/packages/mgt-components/src/styles/shared-sass-variables.scss b/packages/mgt-components/src/styles/shared-sass-variables.scss index 62640c9c36..c638f27a00 100644 --- a/packages/mgt-components/src/styles/shared-sass-variables.scss +++ b/packages/mgt-components/src/styles/shared-sass-variables.scss @@ -212,6 +212,11 @@ $themes: ('light', 'dark'); $theme-default: 'light'; $common: ( + fluent-color: ( + _var: --neutral-foreground-rest, + dark: $text__color__main--dark, + light: $text__color__main--light + ), color: ( _var: --color, dark: $text__color__main--dark, diff --git a/packages/mgt-components/src/utils/SvgHelper.ts b/packages/mgt-components/src/utils/SvgHelper.ts index 262e04cccd..b9ab339077 100644 --- a/packages/mgt-components/src/utils/SvgHelper.ts +++ b/packages/mgt-components/src/utils/SvgHelper.ts @@ -226,6 +226,15 @@ export enum SvgIcon { * account selection */ SelectAccount, + /** + * Vertical ellipsis + */ + MoreVertical, + + /** + * A tick in a filled circle + */ + FilledCheckMark, /** * News @@ -606,10 +615,24 @@ export const getSvg = (svgIcon: SvgIcon, color?: string) => { case SvgIcon.SelectAccount: return html` - - - - `; + + + + `; + + case SvgIcon.MoreVertical: + return html` + + + + `; + + case SvgIcon.FilledCheckMark: + return html` + + + + `; case SvgIcon.News: return html` diff --git a/packages/mgt-element/src/components/baseComponent.ts b/packages/mgt-element/src/components/baseComponent.ts index 9084bfbc7b..192d8fdd93 100644 --- a/packages/mgt-element/src/components/baseComponent.ts +++ b/packages/mgt-element/src/components/baseComponent.ts @@ -9,7 +9,7 @@ import { LitElement, PropertyValueMap, PropertyValues } from 'lit'; import { state } from 'lit/decorators.js'; import { ProviderState } from '../providers/IProvider'; import { Providers } from '../providers/Providers'; -import { LocalizationHelper } from '../utils/LocalizationHelper'; +import { Direction, LocalizationHelper } from '../utils/LocalizationHelper'; import { PACKAGE_VERSION } from '../utils/version'; /** @@ -61,7 +61,7 @@ export abstract class MgtBaseComponent extends LitElement { * @protected * @memberof MgtBaseComponent */ - @state() protected direction: 'ltr' | 'rtl' | 'auto' = 'ltr'; + @state() protected direction: Direction = 'ltr'; /** * Gets the ComponentMediaQuery of the component @@ -179,7 +179,10 @@ export abstract class MgtBaseComponent extends LitElement { } /** - * Used to clear state in inherited components + * Do nothing implementation of clearState method. + * + * @protected + * @memberof MgtBaseComponent */ protected clearState(): void { // no-op diff --git a/packages/mgt-element/src/utils/LocalizationHelper.ts b/packages/mgt-element/src/utils/LocalizationHelper.ts index 1bac94a9be..2310d84d67 100644 --- a/packages/mgt-element/src/utils/LocalizationHelper.ts +++ b/packages/mgt-element/src/utils/LocalizationHelper.ts @@ -8,6 +8,11 @@ import { EventDispatcher, EventHandler } from './EventDispatcher'; +/** + * Valid values for the direction attribute + */ +export type Direction = 'ltr' | 'rtl' | 'auto'; + /** * Helper class for Localization * diff --git a/packages/mgt-react/scripts/generate.js b/packages/mgt-react/scripts/generate.js index b807697bb6..322d47836a 100644 --- a/packages/mgt-react/scripts/generate.js +++ b/packages/mgt-react/scripts/generate.js @@ -19,6 +19,8 @@ const tags = new Set([ 'todo', 'file', 'file-list', + 'file-grid', + 'file-list-composite', 'picker', 'theme-toggle', 'search-box', @@ -55,6 +57,16 @@ const addTypeToImports = type => { } // make sure to remove any generic type decorations before trying to split for union types type = removeGenericTypeDecoration(type); + + // need to handle Generic like Command + if (type.indexOf('<') > 0 && type.indexOf('>') === type.length - 1) { + const genericType = type.substring(0, type.indexOf('<')); + const genericTypeArgs = type.substring(type.indexOf('<') + 1, type.length - 2); + addTypeToImports(genericType); + addTypeToImports(genericTypeArgs); + return; + } + for (let t of type.split('|')) { t = removeGenericTypeDecoration(t.trim()); if (t.startsWith('MicrosoftGraph.') || t.startsWith('MicrosoftGraphBeta.')) { diff --git a/packages/mgt-react/src/generated/react.ts b/packages/mgt-react/src/generated/react.ts index a5be132908..91decf8b19 100644 --- a/packages/mgt-react/src/generated/react.ts +++ b/packages/mgt-react/src/generated/react.ts @@ -1,4 +1,4 @@ -import { OfficeGraphInsightString,ViewType,ResponseType,DataChangedDetail,IDynamicPerson,LoginViewType,PersonCardInteraction,PersonType,GroupType,UserType,AvatarSize,PersonViewType,TasksStringResource,TasksSource,TaskFilter,ITask,SelectedChannel,TodoFilter } from '@microsoft/mgt-components'; +import { ViewType,OfficeGraphInsightString,MenuCommand,ResponseType,DataChangedDetail,IDynamicPerson,LoginViewType,PersonCardInteraction,PersonType,GroupType,UserType,AvatarSize,PersonViewType,TasksStringResource,TasksSource,TaskFilter,ITask,SelectedChannel,TodoFilter } from '@microsoft/mgt-components'; import { TemplateContext,ComponentMediaQuery,TemplateRenderedData } from '@microsoft/mgt-element'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import * as MicrosoftGraphBeta from '@microsoft/microsoft-graph-types-beta'; @@ -21,14 +21,6 @@ export type AgendaProps = { export type FileProps = { fileQuery?: string; - siteId?: string; - driveId?: string; - groupId?: string; - listId?: string; - userId?: string; - itemId?: string; - itemPath?: string; - insightType?: OfficeGraphInsightString; insightId?: string; fileDetails?: MicrosoftGraph.DriveItem; fileIcon?: string; @@ -37,24 +29,81 @@ export type FileProps = { line2Property?: string; line3Property?: string; view?: ViewType; + siteId?: string; + driveId?: string; + groupId?: string; + listId?: string; + userId?: string; + itemId?: string; + itemPath?: string; + insightType?: OfficeGraphInsightString; templateContext?: TemplateContext; mediaQuery?: ComponentMediaQuery; templateRendered?: (e: CustomEvent) => void; } -export type FileListProps = { +export type FileGridProps = { + commands?: MenuCommand[]; + pageSize?: number; + itemView?: ViewType; + maxUploadFile?: number; + enableFileUpload?: boolean; fileListQuery?: string; + fileQueries?: string[]; + files?: MicrosoftGraph.DriveItem[]; + fileExtensions?: string[]; + hideMoreFilesButton?: boolean; + maxFileSize?: number; + excludedFileExtensions?: string[]; + siteId?: string; + driveId?: string; + groupId?: string; + listId?: string; + userId?: string; + itemId?: string; + itemPath?: string; + insightType?: OfficeGraphInsightString; + templateContext?: TemplateContext; + mediaQuery?: ComponentMediaQuery; + itemClick?: (e: CustomEvent) => void; + selectionChanged?: (e: CustomEvent) => void; + templateRendered?: (e: CustomEvent) => void; +} + +export type FileListProps = { displayName?: string; cardTitle?: string; + pageSize?: number; + itemView?: ViewType; + maxUploadFile?: number; + enableFileUpload?: boolean; + fileListQuery?: string; fileQueries?: string[]; files?: MicrosoftGraph.DriveItem[]; + fileExtensions?: string[]; + hideMoreFilesButton?: boolean; + maxFileSize?: number; + excludedFileExtensions?: string[]; siteId?: string; driveId?: string; groupId?: string; + listId?: string; + userId?: string; itemId?: string; itemPath?: string; - userId?: string; insightType?: OfficeGraphInsightString; + templateContext?: TemplateContext; + mediaQuery?: ComponentMediaQuery; + itemClick?: (e: CustomEvent) => void; + templateRendered?: (e: CustomEvent) => void; +} + +export type FileListCompositeProps = { + breadcrumbRootName?: string; + useGridView?: boolean; + fileListQuery?: string; + fileQueries?: string[]; + files?: MicrosoftGraph.DriveItem[]; itemView?: ViewType; fileExtensions?: string[]; pageSize?: number; @@ -63,9 +112,16 @@ export type FileListProps = { enableFileUpload?: boolean; maxUploadFile?: number; excludedFileExtensions?: string[]; + siteId?: string; + driveId?: string; + groupId?: string; + listId?: string; + userId?: string; + itemId?: string; + itemPath?: string; + insightType?: OfficeGraphInsightString; templateContext?: TemplateContext; mediaQuery?: ComponentMediaQuery; - itemClick?: (e: CustomEvent) => void; templateRendered?: (e: CustomEvent) => void; } @@ -288,8 +344,12 @@ export const Agenda = wrapMgt('agenda'); export const File = wrapMgt('file'); +export const FileGrid = wrapMgt('file-grid'); + export const FileList = wrapMgt('file-list'); +export const FileListComposite = wrapMgt('file-list-composite'); + export const Get = wrapMgt('get'); export const Login = wrapMgt('login'); diff --git a/stories/components/breadcrumb/breadcrumb.js b/stories/components/breadcrumb/breadcrumb.js new file mode 100644 index 0000000000..bc3394ebc7 --- /dev/null +++ b/stories/components/breadcrumb/breadcrumb.js @@ -0,0 +1,60 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +import { html } from 'lit'; +import { withCodeEditor } from '../../../.storybook/addons/codeEditorAddon/codeAddon'; + +export default { + title: 'Components/mgt-breadcrumb', + component: 'breadcrumb', + decorators: [withCodeEditor] +}; + +export const breadcrumb = () => html` + + +`; + +export const events = () => html` + + +`; diff --git a/stories/components/filesComposite/filesComposite.js b/stories/components/filesComposite/filesComposite.js new file mode 100644 index 0000000000..8d346ef191 --- /dev/null +++ b/stories/components/filesComposite/filesComposite.js @@ -0,0 +1,40 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +import { html } from 'lit'; +import { withCodeEditor } from '../../../.storybook/addons/codeEditorAddon/codeAddon'; + +export default { + title: 'Components/mgt-file-list-composite', + component: 'file-list-composite', + decorators: [withCodeEditor] +}; + +export const fileComposite = () => html` + +`; + +export const BreadcrumbRootName = () => html` + +`; + +export const UseGridView = () => html` + +`; + +export const events = () => html` + + +`;