Skip to content

Commit

Permalink
Merge pull request #3606 from SherpasGroup/add-download-feature
Browse files Browse the repository at this point in the history
New feature: Download files and folders from details list layout
  • Loading branch information
wobba authored Mar 8, 2024
2 parents 8dabc0e + 09f39b2 commit 54ace3f
Show file tree
Hide file tree
Showing 18 changed files with 278 additions and 24 deletions.
3 changes: 2 additions & 1 deletion docs/usage/search-results/layouts/details-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ The 'details list' layout allows you to display items as a structured list, the
| **Show file icon** | Hide or display the file icon in the first column.
| **Compact mode** | Display the details list in compact mode.
| **Enable grouping** | Display a grouped list, grouped by the specified column.
| **Enable sticky header** | Display the details list with a sticky header that will stay in place when scrolling. Specify the desired height for the view (in pixels) and then specify the desired items per page in _Number of items per page_ under _Paging options_ and all items on the page will be scrollable within the view.
| **Enable sticky header** | Display the details list with a sticky header that will stay in place when scrolling. Specify the desired height for the view (in pixels) and then specify the desired items per page in _Number of items per page_ under _Paging options_ and all items on the page will be scrollable within the view.
| **Enable download** | Enable download of selected files. Requires _Allow items selection_ to be enabled and supports both single and multiple selection. If single selection is enabled the selected file will be downloaded as is. If multiple selection is enabled the selected files and folders will be downloaded in a single zip file like in SharePoint document libraries. Requires _SPWebUrl_, _ContentTypeId_, _NormListID_ and _NormUniqueID_ to be selected in _Selected properties_.
5 changes: 5 additions & 0 deletions search-parts/src/components/AvailableComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { FilterSearchBoxWebComponent } from './filters/FilterSearchBoxComponent'
import { FilterValueOperatorWebComponent } from './filters/FilterValueOperatorComponent';
import { SpoPathBreadcrumbWebComponent } from './SpoPathBreadcrumbComponent';
import { SortWebComponent } from './SortComponent';
import { DownloadSelectedItemsButtonWebComponent } from './DownloadSelectedItemsButtonComponent';

export class AvailableComponents {

Expand Down Expand Up @@ -130,6 +131,10 @@ export class AvailableComponents {
{
componentName: 'pnp-sortfield',
componentClass: SortWebComponent
},
{
componentName: "pnp-download-selected-items-button",
componentClass: DownloadSelectedItemsButtonWebComponent
}
];
}
194 changes: 194 additions & 0 deletions search-parts/src/components/DownloadSelectedItemsButtonComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { BaseWebComponent } from "@pnp/modern-search-extensibility";
import { ActionButton, IIconProps, ITheme } from "@fluentui/react";
import { ISearchResultsTemplateContext } from "../models/common/ITemplateContext";
import { HttpClient, HttpClientResponse, IHttpClientOptions, ISPHttpClientOptions, SPHttpClient, SPHttpClientResponse } from "@microsoft/sp-http";
import { Guid, Log } from "@microsoft/sp-core-library";
import { IReadonlyTheme } from "@microsoft/sp-component-base";
import * as strings from "CommonStrings";


export interface IExportState {
exporting: boolean;
}

interface IExportSelectedItemsButtonProps {
/**
* The Handlebars context to inject in columns content (ex: @root)
*/
context?: ISearchResultsTemplateContext;

/**
* Current items
*/
items?: { [key: string]: any }[];

/**
* The web part context
*/
webPartContext?: any;

/**
* The current theme settings
*/
themeVariant?: IReadonlyTheme;
}

export class DownloadSelectedItemsButtonComponent extends React.Component<IExportSelectedItemsButtonProps, IExportState> {

private _selectedItems = [];

constructor(props: IExportSelectedItemsButtonProps) {
super(props);
this.state = {
exporting: false
};

this._encodeFormData = this._encodeFormData.bind(this);
this._downloadSelectedItems = this._downloadSelectedItems.bind(this);
}

public render() {
const { ...buttonProps } = this.props;
const downloadIcon: IIconProps = { iconName: "Download" };

const currentSiteHost = new URL(this.props.context.context.web.absoluteUrl).hostname;

const onlyDocumentsOrFoldersInCurrentHostSelected = this._selectedItems.every(item => item["SPWebUrl"] && new URL(item["SPWebUrl"]).hostname === currentSiteHost && item["ContentTypeId"] && (item["ContentTypeId"].startsWith("0x0101") || item["ContentTypeId"].startsWith("0x0120")));

const requiredPropertiesAvailable = this._selectedItems.every(item => item["SPWebUrl"] && item["ContentTypeId"] && item["NormListID"] && item["NormUniqueID"]);

return <ActionButton {...buttonProps}
text={strings.Controls.DownloadButtonText}
iconProps={downloadIcon}
disabled={this.state.exporting || !this.props.context.selectedKeys || this.props.context.selectedKeys.length === 0 || !onlyDocumentsOrFoldersInCurrentHostSelected || !requiredPropertiesAvailable}
onClick={this._downloadSelectedItems}
theme={this.props.themeVariant as ITheme}
/>;
}

public componentDidMount() {

if (this.props.context && this.props.context.selectedKeys && this.props.context.selectedKeys.length > 0) {

this._selectedItems = this.props.context.selectedKeys.map(key => {
return this.props.items[key.replace(this.props.context.paging.currentPageNumber.toString(), "")];
});

this.forceUpdate();
}
}

private _downloadSelectedItems() {

this.setState({
exporting: true
});
if (this._selectedItems.length === 1 && this._selectedItems[0]["ContentTypeId"].startsWith("0x0101")) {
window.document.location.href = `${this._selectedItems[0]["SPWebUrl"]}/_layouts/15/download.aspx?UniqueId=${this._selectedItems[0]["NormUniqueID"]}`;
}
else {
const fileInfoResponses = this._selectedItems.map(item => {
const spOptions1: ISPHttpClientOptions = {
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"parameters":{"RenderOptions":4103}
})
};

return this.props.webPartContext.spHttpClient.post(`${item["SPWebUrl"]}/_api/web/lists/GetById('${item["NormListID"]}')/RenderListDataAsStream?FilterField1=UniqueId&FilterValue1=${item["NormUniqueID"]}`, SPHttpClient.configurations.v1, spOptions1)
.then((response: SPHttpClientResponse) => {
return response.json()
});
});
Promise.all(fileInfoResponses).then((responses: SPHttpClientResponse[]) => {
const files = responses.map(response => {
return {name: response["ListData"]["Row"][0]["FileLeafRef"], size: parseInt((response["ListData"]["Row"][0]["File_x0020_Size"] || "").trim(), 10), docId: `${response["ListData"]["Row"][0][".spItemUrl"]}&${response["ListSchema"][".driveAccessToken"]}`, isFolder: response["ListData"]["Row"][0]["FSObjType"] === "1" ? true : false};
});
const spOptions1: ISPHttpClientOptions = {
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"resource":`${responses[0]["ListSchema"][".mediaBaseUrl"]}`
})
};

const filename = `OneDrive_1_${new Date().toLocaleDateString().replace("/", "-")}.zip`;

this.props.webPartContext.spHttpClient.post(`${this.props.context.context.web.absoluteUrl}/_api/SP.OAuth.Token/Acquire()`, SPHttpClient.configurations.v1, spOptions1)
.then((response: SPHttpClientResponse) => {
return response.json()
}).then((response: any) => {
const token = response["access_token"];
const downloadParameters = {
files: `${JSON.stringify({items: files})}`,
guid: Guid.newGuid(),
oAuthToken: token,
provider: "spo",
zipFileName: filename
};
const downloadOptions: IHttpClientOptions = {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: this._encodeFormData(downloadParameters)
};

this.props.webPartContext.httpClient.post(`${responses[0]["ListSchema"][".mediaBaseUrl"]}/transform/zip?cs=${responses[0]["ListSchema"][".callerStack"]}`, HttpClient.configurations.v1, downloadOptions)
.then((response: HttpClientResponse) => {
if (response.ok) {
return response.blob();
}
else {
throw new Error(response.statusText);
}
})
.then((blob: Blob) => {
const url = window.URL.createObjectURL(blob);

const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();

window.URL.revokeObjectURL(url);
})
.catch((error: any) => {
Log.error("DownloadSelectedItemsButtonComponent", new Error(`Error when downloading files. Details ${error}`));
});
});
});
}
}

private _encodeFormData(data) {
return Object.keys(data)
.map(key => encodeURIComponent(key) + "=" + encodeURIComponent(data[key]))
.join("&");
}
}

export class DownloadSelectedItemsButtonWebComponent extends BaseWebComponent {
public constructor() {
super();
}

public connectedCallback() {
let props = this.resolveAttributes();

props.webPartContext = {}
props.webPartContext.spHttpClient = this._serviceScope.consume(SPHttpClient.serviceKey);
props.webPartContext.httpClient = this._serviceScope.consume(HttpClient.serviceKey);

const exportButtonComponent = <DownloadSelectedItemsButtonComponent {...props} />;
ReactDOM.render(exportButtonComponent, this);
}

protected onDispose(): void {
ReactDOM.unmountComponentAtNode(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@
&--resultCount {
padding: 10px;
}
&--toolbar {
display: flex;
justify-content: space-between;
}
&--cardContainer {
display: flex;
flex-wrap: wrap;
Expand Down
12 changes: 12 additions & 0 deletions search-parts/src/layouts/results/detailsList/DetailListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface IDetailsListLayoutProperties {
* The height of the list view when sticky header is enabled
*/
stickyHeaderListViewHeight: number;

/**
* If the download button should be visible
*/
enableDownload: boolean;
}

export class DetailsListLayout extends BaseLayout<IDetailsListLayoutProperties> {
Expand Down Expand Up @@ -334,6 +339,13 @@ export class DetailsListLayout extends BaseLayout<IDetailsListLayoutProperties>
);
}

propertyPaneFields.push(
PropertyPaneToggle('layoutProperties.enableDownload', {
label: strings.Layouts.DetailsList.EnableDownload,
checked: this.properties.enableDownload
})
);

return propertyPaneFields;
}

Expand Down
18 changes: 16 additions & 2 deletions search-parts/src/layouts/results/detailsList/details-list.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,25 @@
</pnp-selectedfilters>
{{/if}}

{{#if @root.properties.showResultsCount}}
{{#or @root.properties.showResultsCount (and properties.layoutProperties.enableDownload @root.properties.itemSelectionProps.allowItemSelection) }}
<div class="template--toolbar">
<div class="template--resultCount">
{{#if @root.properties.showResultsCount}}
<label class="ms-fontWeight-semibold">{{getCountMessage @root.data.totalItemsCount @root.inputQueryText}}</label>
{{/if}}
</div>
{{/if}}
<div>
{{#and properties.layoutProperties.enableDownload @root.properties.itemSelectionProps.allowItemSelection}}
<pnp-download-selected-items-button
data-items="{{JSONstringify data.items}}"
data-context="{{JSONstringify (truncateContext @root)}}"
data-theme-variant="{{JSONstringify @root.theme}}"
>
</pnp-download-selected-items-button>
{{/and}}
</div>
</div>
{{/or}}

<pnp-detailslist
data-items="{{JSONstringify data.items}}"
Expand Down
2 changes: 2 additions & 0 deletions search-parts/src/loc/commonStrings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ declare interface ICommonStrings {
TextFieldApplyButtonText: string;
SortByPlaceholderText: string;
SortByDefaultOptionText: string;
DownloadButtonText: string;
},
Layouts: {
Debug: {
Expand Down Expand Up @@ -230,6 +231,7 @@ declare interface ICommonStrings {
ResetFieldsBtnLabel: string;
EnableStickyHeader: string;
StickyHeaderListViewHeight: string;
EnableDownload: string;
};
Cards: {
Name: string;
Expand Down
6 changes: 4 additions & 2 deletions search-parts/src/loc/da-dk.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ define([], function() {
AddStaticDataLabel: "Tilføj statisk data",
TextFieldApplyButtonText: "Anvend",
SortByPlaceholderText: "Sorter efter...",
SortByDefaultOptionText: "Standard"
SortByDefaultOptionText: "Standard",
DownloadButtonText: "Download"
},
Layouts: {
Debug: {
Expand Down Expand Up @@ -230,7 +231,8 @@ define([], function() {
CollapsedGroupsByDefault: "Vis collapsed",
ResetFieldsBtnLabel: "Nulstil felter til standardværdier",
EnableStickyHeader: "Aktivér fastgjort header",
StickyHeaderListViewHeight: "Listevisningshøjde (px)"
StickyHeaderListViewHeight: "Listevisningshøjde (px)",
EnableDownload: "Aktivér download"
},
Cards: {
Name: "Cards",
Expand Down
6 changes: 4 additions & 2 deletions search-parts/src/loc/de-de.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ define([], function () {
AddStaticDataLabel: "Statische Daten hinzufügen",
TextFieldApplyButtonText: "Übernehmen",
SortByPlaceholderText: "Standardsortierung",
SortByDefaultOptionText: "Standard"
SortByDefaultOptionText: "Standard",
DownloadButtonText: "Herunterladen"
},
Layouts: {
Debug: {
Expand Down Expand Up @@ -230,7 +231,8 @@ define([], function () {
CollapsedGroupsByDefault: "Eingeklappt anzeigen",
ResetFieldsBtnLabel: "Felder auf Standardwerte zurücksetzen",
EnableStickyHeader: "Fixierte Kopfzeile aktivieren",
StickyHeaderListViewHeight: "Höhe der Listenansicht (px)"
StickyHeaderListViewHeight: "Höhe der Listenansicht (px)",
EnableDownload: "Download aktivieren"
},
Cards: {
Name: "Karten",
Expand Down
6 changes: 4 additions & 2 deletions search-parts/src/loc/en-us.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ define([], function() {
AddStaticDataLabel: "Add static data",
TextFieldApplyButtonText: "Apply",
SortByPlaceholderText: "Sort by...",
SortByDefaultOptionText: "Default"
SortByDefaultOptionText: "Default",
DownloadButtonText: "Download"
},
Layouts: {
Debug: {
Expand Down Expand Up @@ -230,7 +231,8 @@ define([], function() {
CollapsedGroupsByDefault: "Show collapsed",
ResetFieldsBtnLabel: "Reset fields to default values",
EnableStickyHeader: "Enable sticky header",
StickyHeaderListViewHeight: "List view height (in px)"
StickyHeaderListViewHeight: "List view height (in px)",
EnableDownload: "Enable download"
},
Cards: {
Name: "Cards",
Expand Down
6 changes: 4 additions & 2 deletions search-parts/src/loc/es-es.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ define([], function() {
AddStaticDataLabel: "Añadir datos estáticos",
TextFieldApplyButtonText: "Aplicar",
SortByPlaceholderText: "Ordenar por...",
SortByDefaultOptionText: "Defecto"
SortByDefaultOptionText: "Defecto",
DownloadButtonText: "Descargar"
},
Layouts: {
Debug: {
Expand Down Expand Up @@ -230,7 +231,8 @@ define([], function() {
CollapsedGroupsByDefault: "Mostrar colapsado",
ResetFieldsBtnLabel: "Restablecer los valores por defecto de los campos",
EnableStickyHeader: "Activar el encabezado fijo",
StickyHeaderListViewHeight: "Altura de la vista de lista con encabezado fijo (en píxeles)"
StickyHeaderListViewHeight: "Altura de la vista de lista con encabezado fijo (en píxeles)",
EnableDownload: "Activar la descarga de archivos"
},
Cards: {
Name: "Tarjetas",
Expand Down
Loading

0 comments on commit 54ace3f

Please sign in to comment.