@googleworkspace/drive-picker-element
Version:
A web component for Google Drive Picker
355 lines (309 loc) • 13.5 kB
text/typescript
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
getBoolAttr,
getNumberAttribute,
loadApi,
requestAccessToken,
setBoolAttrWithDefault,
} from "../utils";
type View = google.picker.DocsView;
interface DrivePickerDocsViewElement extends HTMLElement {
view: google.picker.DocsView;
}
declare global {
interface GlobalEventHandlersEventMap {
/** @deprecated - Use "picker:oauth:response" */
"picker:authenticated": CustomEvent<{ token: string }>;
"picker:oauth:error": CustomEvent<
| google.accounts.oauth2.ClientConfigError
| google.accounts.oauth2.TokenResponse
>;
"picker:oauth:response": CustomEvent<google.accounts.oauth2.TokenResponse>;
"picker:canceled": CustomEvent<google.picker.ResponseObject>;
"picker:picked": CustomEvent<google.picker.ResponseObject>;
"picker:error": CustomEvent<unknown>;
}
}
/**
* The `drive-picker` web component provides a convenient way to declaratively
* build
* [`google.picker.Picker`](https://developers.google.com/drive/picker/reference/picker)
* by using the component attributes mapped to the corresponding methods of
* [`google.picker.PickerBuilder`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder).
*
* @element drive-picker
*
* @fires {google.picker.ResponseObject} picker:canceled - Triggered when the user cancels the picker dialog. See [`ResponseObject`](https://developers.google.com/drive/picker/reference/picker.responseobject).
* @fires {google.picker.ResponseObject} picker:picked - Triggered when the user picks one or more items. See [`ResponseObject`](https://developers.google.com/drive/picker/reference/picker.responseobject).
* @fires {google.picker.ResponseObject} picker:error - Triggered when an error occurs. See [`ResponseObject`](https://developers.google.com/drive/picker/reference/picker.responseobject).
* @fires {google.accounts.oauth2.ClientConfigError|google.accounts.oauth2.TokenResponse} picker:oauth:error - Triggered when an error occurs in the OAuth flow. See the [error guide](https://developers.google.com/identity/oauth2/web/guides/error). Note that the `TokenResponse` object can have error fields.
* @fires {google.accounts.oauth2.TokenResponse} picker:oauth:response - Triggered when an OAuth flow completes. See the [token model guide](https://developers.google.com/identity/oauth2/web/guides/use-token-model).
*
* @slot - The default slot contains View elements to display in the picker.
* Each View element should implement a property `view` of type
* `google.picker.View`.
* @attr {string} app-id - The Google Drive app ID. See [`PickerBuilder.setAppId`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setappid).
* @attr {string} client-id - The OAuth 2.0 client ID. See [Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2).
* @attr {string} developer-key - The API key for accessing Google Picker API. See [`PickerBuilder.setDeveloperKey`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setdeveloperkey).
* @attr {"default"|"true"|"false"} hide-title-bar - Hides the title bar of the
* picker if set to true. See [`PickerBuilder.hideTitleBar`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.hidetitlebar).
* @attr {string} locale - The locale to use for the picker. See [`PickerBuilder.setLocale`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setlocale).
* @attr {number} max-items - The maximum number of items that can be selected. See [`PickerBuilder.setMaxItems`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setmaxitems).
* @attr {boolean} mine-only - If set to true, only shows files owned by the
* user. See [`PickerBuilder.enableFeature`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.enablefeature).
* @attr {boolean} multiselect - Enables multiple file selection if set to true. See [`PickerBuilder.enableFeature`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.enablefeature).
* @attr {boolean} nav-hidden - Hides the navigation pane if set to true. See [`PickerBuilder.enableFeature`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.enablefeature).
* @attr {string} oauth-token - The OAuth 2.0 token for authentication. See [`PickerBuilder.setOAuthToken`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setoauthtoken).
* @attr {string} origin - The origin parameter for the picker. See [`PickerBuilder.setOrigin`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setorigin).
* @attr {string} relay-url - The relay URL for the picker. See [`PickerBuilder.setRelayUrl`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.setrelayurl).
* @attr {string} scope - The OAuth 2.0 scope for the picker. The default is `https://www.googleapis.com/auth/drive.file`. See [Drive API scopes](https://developers.google.com/drive/api/guides/api-specific-auth#drive-scopes).
* @attr {string} title - The title of the picker. See [`PickerBuilder.setTitle`](https://developers.google.com/drive/picker/reference/picker.pickerbuilder.settitle).
* @attr {string} hd - The hosted domain to restrict sign-in to. (Optional) See the `hd` field in the OpenID Connect docs.
* @attr {boolean} include-granted-scopes - Enables applications to use incremental authorization. See [`TokenClientConfig.include_granted_scopes`](https://developers.google.com/identity/oauth2/web/reference/js-reference#TokenClientConfig).
* @attr {string} login-hint - An email address or an ID token 'sub' value. Google will use the value as a hint of which user to sign in. See the `login_hint` field in the OpenID Connect docs.
* @attr {""|"none"|"consent"|"select_account"} prompt - A space-delimited, case-sensitive list of prompts to present the user. See [`TokenClientConfig.prompt`](https://developers.google.com/identity/oauth2/web/reference/js-reference#TokenClientConfig)
*
* @example
*
*```html
*<drive-picker
* app-id="246724281745"
* client-id="246724281745-v9ouai8ood5o69r3ug29aaqeqflomijd.apps.googleusercontent.com"
*>
* <drive-picker-docs-view></drive-picker-docs-view>
*</drive-picker>
*```
*
*/
export class DrivePickerElement extends HTMLElement {
static get observedAttributes() {
return [
"app-id",
"client-id",
"developer-key",
"hide-title-bar",
"locale",
"max-items",
"mine-only",
"multiselect",
"nav-hidden",
"oauth-token",
"origin",
"relay-url",
"scope",
"title",
];
}
private picker: google.picker.Picker | undefined;
private observer: MutationObserver | undefined;
private google: typeof google | undefined;
private loading: Promise<void> | undefined;
/**
* The visibility of the picker.
*/
public get visible(): boolean {
return Boolean(this.picker?.isVisible());
}
/**
* Controls the visibility of the picker after the picker dialog has been
* closed. If any of the attributes change, the picker will be rebuilt and
* the visibility will be reset.
*/
set visible(value: boolean) {
this.picker?.setVisible(value);
}
public get tokenClientConfig(): Omit<
google.accounts.oauth2.TokenClientConfig,
"callback" | "error_callback"
> {
const clientId = this.getAttribute("client-id");
const scope =
this.getAttribute("scope") ??
"https://www.googleapis.com/auth/drive.file";
if (!clientId || !scope) {
throw new Error("client-id and scope are required attributes");
}
return {
client_id: clientId,
hd: this.getAttribute("hd") ?? undefined,
include_granted_scopes: Boolean(
this.getAttribute("include-granted-scope"),
),
login_hint: this.getAttribute("login-hint") ?? undefined,
prompt: (this.getAttribute("prompt") ??
"") as google.accounts.oauth2.TokenClientConfig["prompt"],
scope,
};
}
attributeChangedCallback() {
this.build();
return;
}
private async build() {
this.picker?.dispose();
// this await is necessary as an attribute may have changed
// prior to the API initially being loaded
await this.loading;
if (!this.google) return;
let builder = new this.google.picker.PickerBuilder().setCallback(
(data: google.picker.ResponseObject) => {
this.callbackToDispatchEvent(data);
},
);
const appId = this.getAttribute("app-id");
if (appId !== null) builder = builder.setAppId(appId);
const developerKey = this.getAttribute("developer-key");
if (developerKey !== null) builder = builder.setDeveloperKey(developerKey);
const locale = this.getAttribute("locale");
if (locale !== null)
builder = builder.setLocale(locale as google.picker.Locales);
const maxItems = getNumberAttribute(this, "max-items");
if (maxItems !== null) builder = builder.setMaxItems(maxItems);
const origin = this.getAttribute("origin");
if (origin !== null) builder = builder.setOrigin(origin);
const relayUrl = this.getAttribute("relay-url");
if (relayUrl !== null) builder = builder.setRelayUrl(relayUrl);
const title = this.getAttribute("title");
if (title !== null) builder = builder.setTitle(title);
setBoolAttrWithDefault(
"hide-title-bar",
this,
builder.hideTitleBar,
builder,
);
// OAuth token is required either as an attribute or from the OAuth flow using the client ID and scope
const oauthToken =
this.getAttribute("oauth-token") ?? (await this.requestAccessToken());
if (!oauthToken) return;
// biome-ignore lint/style/noNonNullAssertion: <explanation>
builder = builder.setOAuthToken(oauthToken!);
if (getBoolAttr(this, "multiselect")) {
builder = builder.enableFeature(
this.google.picker.Feature.MULTISELECT_ENABLED,
);
}
if (getBoolAttr(this, "mine-only")) {
builder = builder.enableFeature(this.google.picker.Feature.MINE_ONLY);
}
if (getBoolAttr(this, "nav-hidden")) {
builder = builder.enableFeature(this.google.picker.Feature.NAV_HIDDEN);
}
for (const view of this.views) {
builder = builder.addView(view);
}
this.picker = builder.build();
this.picker.setVisible(true);
}
/**
* The `google.Picker.View` objects to display in the picker as defined by the slot elements.
*/
private get views(): (View | google.picker.ViewId)[] {
const views = nestedViews(this);
return views.length ? views : ["all" as google.picker.ViewId];
}
async connectedCallback(): Promise<void> {
this.loading = loadApi().then((google) => {
this.google = google;
this.build();
});
// Watch for changes in the picker element slot and their attributes
this.observer = new MutationObserver((mutations) => {
const filteredMutations = mutations.filter(
(mutation) =>
mutation.type === "childList" ||
(mutation.type === "attributes" && mutation.target !== this),
);
if (filteredMutations.length) {
this.build();
}
});
this.observer?.observe(this, {
childList: true,
subtree: true,
attributes: true,
});
}
private callbackToDispatchEvent(detail: google.picker.ResponseObject) {
let eventType: keyof GlobalEventHandlersEventMap;
switch (detail.action) {
case google.picker.Action.CANCEL:
eventType = "picker:canceled";
break;
case google.picker.Action.PICKED:
eventType = "picker:picked";
break;
case google.picker.Action.ERROR:
eventType = "picker:error";
break;
default:
return;
}
this.dispatchEvent(
new CustomEvent(eventType, {
detail,
}),
);
}
private async requestAccessToken(): Promise<string | undefined> {
return requestAccessToken(this.tokenClientConfig)
.then((response) => {
const { access_token: token } = response;
if (!token) {
this.dispatchEvent(
new CustomEvent("picker:oauth:error", {
detail: response,
}),
);
return undefined;
}
// TODO - remove deprecated event
this.dispatchEvent(
new CustomEvent("picker:authenticated", { detail: { token } }),
);
this.dispatchEvent(
new CustomEvent("picker:oauth:response", { detail: response }),
);
return token;
})
.catch((error) => {
this.dispatchEvent(
new CustomEvent("picker:oauth:error", {
detail: error,
}),
);
return undefined;
});
}
disconnectedCallback(): void {
this.picker?.dispose();
}
}
function isView(obj: HTMLElement): obj is DrivePickerDocsViewElement {
return "view" in obj && obj.view instanceof window.google.picker.View;
}
function filterElementsToViewOrViewGroup(
elements: Array<HTMLElement>,
): Array<View> {
return elements
.filter((element) => isView(element))
.map((element) => element.view);
}
function nestedViews(target: HTMLElement, selector = "*"): Array<View> {
return filterElementsToViewOrViewGroup(
Array.from(target.querySelectorAll<HTMLElement>(selector)),
);
}