diff --git a/.env b/.env new file mode 100644 index 0000000..644534e --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +CUSTOM_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_API_KEY= +GITHUB_CLIENT_ID= +AUTH0_CLIENT_ID= +AUTH0_BASE_URL= +JWT_TOKEN_SECRET= +POSTS_SERVER_URL=http://127.0.0.1:3000 diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..644534e --- /dev/null +++ b/.env_example @@ -0,0 +1,8 @@ +CUSTOM_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_API_KEY= +GITHUB_CLIENT_ID= +AUTH0_CLIENT_ID= +AUTH0_BASE_URL= +JWT_TOKEN_SECRET= +POSTS_SERVER_URL=http://127.0.0.1:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..950cd72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Looker Data Sciences, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d5c816 --- /dev/null +++ b/README.md @@ -0,0 +1,461 @@ +# IMPORTANT NOTE - PLEASE READ + +This version and versions going forward demonstrate the use of code splitting. You must be on Looker version 21.0 or above for code splitting to work correctly. + +To remove code splitting you will need to modify `KitchenSink.tsx`. Instructions are provided in this file to show how to fall back to building a single monolithic JavaScript bundle. + +# Looker Extension Kitchensink Example (React & TypeScript) + +This repository demonstrates functionality that is available to the Extension SDK. It can be used as a starting point for developing +your own extensions. + +It uses [React](https://reactjs.org/) and [TypeScript](https://www.typescriptlang.org/) for writing your extension, the [React Extension SDK](https://github.com/looker-open-source/sdk-codegen/tree/main/packages/extension-sdk-react) for interacting with Looker, and [Webpack](https://webpack.js.org/) for building your code. + +## Getting Started for Development + +1. Clone or download a copy of this repository to your development machine. + + ``` + # cd ~/ Optional. your user directory is usually a good place to git clone to. + git clone git@github.com:looker-open-source/extension-examples.git + ``` + +2. Navigate (`cd`) to the template directory on your system + + ``` + cd extension-examples/react/typescript/kitchensink + ``` + +3. Install the dependencies with [Yarn](https://yarnpkg.com/). + + ``` + yarn install + ``` + + > You may need to update your Node version or use a [Node version manager](https://github.com/nvm-sh/nvm) to change your Node version. + +4) Start the development server + + ``` + yarn develop + ``` + + Great! Your extension is now running and serving the JavaScript at http://localhost:8080/bundle.js. + +5) Now log in to Looker and create a new project. + + This is found under **Develop** => **Manage LookML Projects** => **New LookML Project**. + + You'll want to select "Blank Project" as your "Starting Point". You'll now have a new project with no files. + + 1. In your copy of the extension project you have a `manifest.lkml` file. + + You can either drag & upload this file into your Looker project, or create a `manifest.lkml` with the same content. Change the `id`, `label`, or `url` as needed. + + ``` + application: kitchensink { + label: "Kitchen sink" + url: "http://localhost:8080/bundle.js" + entitlements: { + local_storage: yes + navigation: yes + new_window: yes + use_form_submit: yes + use_embeds: yes + core_api_methods: ["all_connections","search_folders", "run_inline_query", "me", "all_looks", "run_look"] + external_api_urls: ["http://127.0.0.1:3000", "http://localhost:3000", "https://*.googleapis.com", "https://*.github.com", "https://REPLACE_ME.auth0.com"] + oauth2_urls: ["https://accounts.google.com/o/oauth2/v2/auth", "https://github.com/login/oauth/authorize", "https://dev-5eqts7im.auth0.com/authorize", "https://dev-5eqts7im.auth0.com/login/oauth/token", "https://github.com/login/oauth/access_token"] + scoped_user_attributes: ["user_value"] + global_user_attributes: ["locale"] + } + } + ``` + +The manifest includes a reference to the `oauth2_url https://REPLACE_ME.auth0.com`. This URL needs to be obtained from Auth0 and is explained later in this document. + +6. Create a `model` LookML file in your project. The name doesn't matter. The model and connection won't be used, and in the future this step may be eliminated. + + - Add a connection in this model. It can be any connection, it doesn't matter which. + - [Configure the model you created](https://docs.looker.com/data-modeling/getting-started/create-projects#configuring_a_model) so that it has access to some connection. + +7. Connect your new project to Git. You can do this multiple ways: + + - Create a new repository on GitHub or a similar service, and follow the instructions to [connect your project to Git](https://docs.looker.com/data-modeling/getting-started/setting-up-git-connection) + - A simpler but less powerful approach is to set up git with the "Bare" repository option which does not require connecting to an external Git Service. + +8. Commit your changes and deploy your them to production through the Project UI. + +9. Reload the page and click the `Browse` dropdown menu. You should see your extension in the list. + - The extension will load the JavaScript from the `url` provided in the `application` definition. By default, this is http://localhost:8080/bundle.js. If you change the port your server runs on in the package.json, you will need to also update it in the manifest.lkml. + +- Refreshing the extension page will bring in any new code changes from the extension template, although some changes will hot reload. + +10. Use with an access key requires a bit more setup. First, create a .env file in the `extension-examples/react/typescript/kitchensink` directory with the following entries. Use a password generator to create the values. These values should be set prior to starting the development and data servers. **Do NOT store the .env file in your source code repository.** + +``` +CUSTOM_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_API_KEY= +GITHUB_CLIENT_ID= +AUTH0_CLIENT_ID= +AUTH0_BASE_URL= +POSTS_SERVER_URL=http://127.0.0.1:3000 +``` + +POSTS_SERVER_URL is the URL of the data server. + +See the "External API Functions" section for more information on CUSTOM_CLIENT_SECRET and other variables. + +## Extension Entitlements + +Going forward, most new features added to the Extension SDK will require that an entitlement be defined for the feature in the +application manifest. If you plan on adding your extension to the Looker Marketplace, entitlements MUST be defined, even for +existing functionality. Eventually most existing Extension SDK features will require entitlements to be defined so it is recommended +that you start defining entitlements now. + +The external API feature demonstrated in the Kitchensink is a new feature and requires that entitlements are defined. This is now reflected +in the sample manifest contained repository. + +## Demoed Extension Features + +### Context Functions + +Extensions can share context data between users. The context data can be used for data that does not change frequently and to share amongst different users of the extension. Care should be taken when writing the data as there is no data locking and the last write wins. The context data is available to the extension immediately. Functions are provided to write and refresh the data. + +- `getContextData` - get the context data. +- `saveContextData` - writes context data to the Looker server. +- `refreshContextData` - gets the lastest context data from the Looker server. + +The configuation component demonstrates the context functionality. It can be used to show/hide views in the Kitchen Sink. It can also be used to change the keys used for the embed demonstrations. + +### API Functions + +API functions demonstrates the following functionality + +- Update title - modifies the title of the page the extension is running in. +- Navigation - navigates to different locations in the Looker server using the current page (browse and marketplace). +- Open new window - opens a new browser window. +- Verify host connection - simple mechanism to check whether the extension and Looker host are in touch. In reality an extension will never need to use this functionality. +- Local storage access - ability to read, write and remove data from local storage. Note that localstorage is namespaced to the extension. +- Pinger action - demonstrates sending data to Lookers pinger server. +- Generate error - demonstrates that extension errors are reported. Note that in Looker server development mode these are not reported. +- Route test - demonstrates routing with query strings and push state. + +### Core SDK Functions + +Core SDK functions demonstrates various calls the Looker SDK. + +- All connections (GET method) +- Search folders (GET with parameters) +- Inline query (POST method) + +### Embed Functions + +There are three Embed demonstrations: + +- Dashboard - can be toggled between class and next dashboards. +- Explore +- Look + +### External API Functions + +#### Fetch Proxy and OAUTH2 Authentication + +The fetch proxy demonstration requires that a json data server be running. To start the server run the command + +``` +yarn data-server +``` + +An error message will be displayed if the server is not running OR if the required entitlements are not defined. + +##### Custom API setup + +The custom client secret in .env can be any value. It is NOT used in the extension but is used by the demo data server to validate whether a user is authorized the data server (note that the implemention is exceedingly simplistic and is just used for demo purposes). The client secret should be added to the `.env` file so that the data server can do a simple check. + +``` +CUSTOM_CLIENT_SECRET= +``` + +The custom client secret must also be added to the User attributes in the looker server. The user attribute should be set up as follows: + +- name - `kitchensink_kitchensink_custom_secret_key` +- user acess - view +- hide values - yes +- domain whitelist - http://127.0.0.1:3000/* +- default value - your secret key + +The extension authenticates the user by adding a secret key tag to the authentication request. The secret key tag is replaced by the Looker server with the user attribute value. The authentication endpoint then returns a JWT token that can be used in subsequent requests. The code that does this is found in the `Auth.tsx` file: + +```typescript +const dataServerAuth = async (body: any): Promise => { + try { + // The custom secret will be resolved by the Looker server. + body.client_secret = extensionSDK.createSecretKeyTag('custom_secret_key') + const response = await extensionSDK.serverProxy( + `${POSTS_SERVER_URL}/auth`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + } + ) + if (response.ok && response.body && response.body.jwt_token) { + return response.body.jwt_token + } + } catch (error) { + console.error(error) + } + return undefined +} +``` + +##### Google Sheets API setup + +The demo requires a client id and an API key to access the Google sheets API. To obtain one, [click here](https://developers.google.com/sheets/api/quickstart/js) and follow the instructions in step 1. The following values need to be setup in the `.env` file, these values can be found in the [google developer console](https://console.developers.google.com/). + +``` +GOOGLE_CLIENT_ID=Application OAUTH2 client ID +GOOGLE_API_KEY=Application API key +``` + +When the user uses the Google OAUTH2 authorization mechanism the client id is used. The extension accesses the sheets API directly. Note that the OAUTH2 implicit flow is used to authorize with Google. + +When the user uses the other authorization mechanisms, the extension access the sheets API using the serverProxy call. The data server uses the API key to access the sheets API. This way the API key is NOT exposed in the extension code. + +##### Github OAUTH2 setup + +The Github OAUTH2 mechanism uses the Authorization code grant type with secret key. Create a [Github OAUTH App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Add the Github client id to your .env file + +``` +GITHUB_CLIENT_ID=Github OAUTH2 client ID +``` + +The Github client secret must also be added to the User attributes in the looker server. The user attribute should be set up as follows: + +- name - `kitchensink_kitchensink_github_secret_key` +- user acess - view +- hide values - yes +- domain whitelist - https://github.com/login/oauth/access_token +- default value - Github client secret + +See `Auth.tsx` for authorizing use Github OAUTH2 Authorization code grant type with secret key. + +```typescript +const githubSignin = async () => { + try { + const response = await extensionSDK.oauth2Authenticate( + 'https://github.com/login/oauth/authorize', + { + client_id: GITHUB_CLIENT_ID, + response_type: 'code', + }, + 'GET' + ) + const codeExchangeResponse = await extensionSDK.oauth2ExchangeCodeForToken( + 'https://github.com/login/oauth/access_token', + { + client_id: GITHUB_CLIENT_ID, + client_secret: extensionSDK.createSecretKeyTag('github_secret_key'), + code: response.code, + } + ) + const { access_token } = codeExchangeResponse + // Success handling + } catch (error) { + // Error handling + } +} +``` + +##### Auth0 OAUTH2 setup + +The Auth0 OAUTH2 mechanism uses the uses the Authorization Code grant type with either a secret key or PKCE (code challenge & code verifier). Create a [Auth0 account](https://auth0.com). Add the Auth0 client id and base URL to your .env file + +``` +AUTH0_CLIENT_ID=Auth0 Client id +AUTH0_BASE_URL=https://{tenant_id}.auth0.com +``` + +The Auth0 application client secret must also be added to the User attributes in the looker server. The user attribute should be set up as follows: + +- name - `kitchensink_kitchensink_auth0_secret_key` +- user acess - view +- hide values - yes +- domain whitelist - https://{tenant_id}.auth0.com/login/oauth/token +- default value - Auth0 client secret + +See `Auth.tsx` for authorizing using the Auth0 OAUTH2 Authorization Code grant type. The following demonstrates use of the secret key. + +```typescript +const auth0Signin = async () => { + try { + const response = await extensionSDK.oauth2Authenticate( + `${AUTH0_BASE_URL}/authorize`, + { + client_id: AUTH0_CLIENT_ID, + response_type: 'code', + scope: AUTH0_SCOPES, + }, + 'GET' + ) + const codeExchangeResponse = await extensionSDK.oauth2ExchangeCodeForToken( + `${AUTH0_BASE_URL}/login/oauth/token`, + { + grant_type: 'authorization_code', + client_id: AUTH0_CLIENT_ID, + client_secret: extensionSDK.createSecretKeyTag('auth0_secret_key'), + code: response.code, + } + ) + const { access_token, expires_in } = codeExchangeResponse + // Success processing + } catch (error) { + // Error processing + } +} +``` + +See `Auth.tsx` for authorizing using the Auth0 OAUTH2 PKCE (code challenge & code verifier). The following demonstrates use of the code challenge. + +```typescript +const auth0Signin = async () => { + try { + const response = await extensionSDK.oauth2Authenticate( + `${AUTH0_BASE_URL}/authorize`, + { + client_id: AUTH0_CLIENT_ID, + response_type: 'code', + scope: AUTH0_SCOPES, + authRequest.code_challenge_method = 'S256', + }, + 'GET' + ) + const codeExchangeResponse = await extensionSDK.oauth2ExchangeCodeForToken( + `${AUTH0_BASE_URL}/login/oauth/token`, + { + grant_type: 'authorization_code', + client_id: AUTH0_CLIENT_ID, + code: response.code, + } + ) + const { access_token, expires_in } = codeExchangeResponse + // Success processing + } catch (error) { + // Error processing + } +} +``` + +## Code Splitting + +Code-Splitting is a feature that can create _multiple_ bundles rather than just one bundle.js, which can then be dynamically loaded on the fly. This can drastically reduce bundle size, and in turn, app performance. For more info on React & code splitting, see the [React Docs](https://reactjs.org/docs/code-splitting.html). + +Code splitting relies on `React.lazy` and `Suspense` to render dynamic imports of a component. The name of the generated JavaScript file can be influenced by specifying a webpackChunkName comment. Note that the `Suspense` component is rendered by the `KitchenSink` component. + +Example: + +````typescript +import React, { lazy, Suspense } from 'react' + +const Home = lazy( + async () => import(/* webpackChunkName: "home" */ './Home') +) + +export const AsyncHome: React.FC = () => +``` + +Note that the imported component MUST be the default export for the module. + +```typescript +import React from 'react' +import { Heading, Paragraph, SpaceVertical } from '@looker/components' +import { SandboxStatus } from '../SandboxStatus' +import { HomeProps } from './types' + +const Home: React.FC = () => { + return <>. . . +} + +export default Home +```` + +## Tree Shaking + +The following packages now support tree shaking which reduces the size the bundle generated: + +1. `@looker/sdk` +2. `@looker/sdk-rtl` +3. `@looker/extension-sdk` +4. `@looker/extension-sdk-react` + +Note that the `@looker/components` does not yet support tree shaking but when it does bundle sizes should be reduced even further. + +To fully take advantage of tree shaking, the extension should use a single SDK and use `ExtensionProvider2` which only pulls in dependent code for the chosen SDK. + +Example setup (see `App.tsx`): + +```typescript +return ( + + + +) +``` + +Example usage: + +```typescript +const extensionContext = useContext>( + ExtensionContext2 +) +const { extensionSDK, coreSDK } = extensionContext + +OR + +const sdk = getCoreSDK2() +``` + +## Deployment + +The process above requires your local development server to be running to load the extension code. To allow other people to use the extension, a production build of the extension needs to be run. As the kitchensink uses code splitting to reduce the size of the initially loaded bundle, multiple JavaScript files are generated. + +1. In your extension project directory on your development machine, build the extension by running the command `yarn build`. +2. Drag and drop ALL of the generated JavaScript files contained in the `dist` directory into the Looker project interface. +3. Modify your `manifest.lkml` to use `file` instead of `url` and point it at the `bundle.js` file: + ``` + application: kitchensink { + label: "Kitchen sink" + file: "bundle.js" + entitlements: { + local_storage: yes + navigation: yes + new_window: yes + use_form_submit: yes + use_embeds: yes + core_api_methods: ["all_connections","search_folders", "run_inline_query", "me", "all_looks", "run_look"] + external_api_urls: ["http://127.0.0.1:3000", "http://localhost:3000", "https://*.googleapis.com", "https://*.github.com", "https://REPLACE_ME.auth0.com"] + oauth2_urls: ["https://accounts.google.com/o/oauth2/v2/auth", "https://github.com/login/oauth/authorize", "https://dev-5eqts7im.auth0.com/authorize", "https://dev-5eqts7im.auth0.com/login/oauth/token", "https://github.com/login/oauth/access_token"] + scoped_user_attributes: ["user_value"] + global_user_attributes: ["locale"] + } + } + ``` + +Note that the additional JavaScript files generated during the production build process do not have to be mentioned in the manifest. These files will be loaded dynamically by the extension as and when they are needed. Note that to utilize code splitting, the Looker server must be at version 7.21 or above. + +## Notes + +- This template uses Looker's [component library](https://components.looker.com) and [styled components](https://styled-components.com/). Neither of these libraries are required, and you may remove and replace them with a component library of your own choice or simply build your UI from scratch. + +## Related Projects + +- [Looker Extension SDK React](https://github.com/looker-open-source/sdk-codegen/tree/main/packages/extension-sdk-react) +- [Looker Extension SDK](https://github.com/looker-open-source/sdk-codegen/tree/main/packages/extension-sdk) +- [Looker SDK](https://github.com/looker-open-source/sdk-codegen/tree/main/packages/sdk) +- [Looker Embed SDK](https://github.com/looker-open-source/embed-sdk) +- [Looker Components](https://components.looker.com/) +- [Styled components](https://www.styled-components.com/docs) diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..909494f --- /dev/null +++ b/babel.config.js @@ -0,0 +1,68 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2019 Looker Data Sciences, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +module.exports = (api) => { + api.cache(true) + + return { + presets: [ + [ + '@babel/env', + { + targets: { + esmodules: true, + }, + modules: false, + }, + ], + [ + '@babel/preset-react', + { + development: process.env.BABEL_ENV !== 'build', + }, + ], + '@babel/preset-typescript', + ], + env: { + build: { + ignore: [ + '**/*.d.ts', + '**/*.test.js', + '**/*.test.jsx', + '**/*.test.ts', + '**/*.test.tsx', + '__snapshots__', + '__tests__', + ], + }, + }, + ignore: ['node_modules'], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-transform-runtime', + 'babel-plugin-styled-components', + ], + } +} diff --git a/db.json b/db.json new file mode 100644 index 0000000..ad3569d --- /dev/null +++ b/db.json @@ -0,0 +1,19 @@ +{ + "posts": [ + { + "id": 1, + "title": "A simple post", + "author": "Anthony Mouse" + } + ], + "comments": [ + { + "id": 1, + "body": "A simple comment", + "postId": 1 + } + ], + "profile": { + "name": "Anthony Mouse" + } +} \ No newline at end of file diff --git a/manifest.lkml b/manifest.lkml new file mode 100644 index 0000000..85b0ea7 --- /dev/null +++ b/manifest.lkml @@ -0,0 +1,18 @@ +project_name: "kitchensink" + +application: kitchensink { + label: "Kitchen sink" + url: "http://localhost:8080/bundle.js" + entitlements: { + local_storage: yes + navigation: yes + new_window: yes + use_form_submit: yes + use_embeds: yes + core_api_methods: ["all_connections","search_folders", "run_inline_query", "me", "all_looks", "run_look"] + external_api_urls: ["http://127.0.0.1:3000", "http://localhost:3000", "https://*.googleapis.com", "https://*.github.com", "https://REPLACE_ME.auth0.com"] + oauth2_urls: ["https://accounts.google.com/o/oauth2/v2/auth", "https://github.com/login/oauth/authorize", "https://dev-5eqts7im.auth0.com/authorize", "https://dev-5eqts7im.auth0.com/login/oauth/token", "https://github.com/login/oauth/access_token"] + scoped_user_attributes: ["user_value"] + global_user_attributes: ["locale"] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0a0a59 --- /dev/null +++ b/package.json @@ -0,0 +1,167 @@ +{ + "name": "extension-kitchensink", + "version": "0.11.0", + "description": "Looker Extension SDK functionality demonstration", + "main": "dist/bundle.js", + "scripts": { + "analyze": "export ANALYZE_MODE=static && yarn build", + "build": "export BABEL_ENV=build && webpack --config webpack.prod.js", + "clean": "rm -rf dist && rm -f .eslintcache", + "develop": "webpack serve --hot --port 8080 --config webpack.develop.js", + "prebuild": "yarn clean", + "tsc": "tsc", + "lint:es": "eslint 'src/**/*.ts{,x}' --cache", + "lint:es:fix": "eslint 'src/**/*.ts{,x}' --cache --fix", + "data-server": "cp db.json temp_db.json && nodemon server/index.js" + }, + "author": "Looker", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "dependencies": { + "@looker/components": "^3.0.1", + "@looker/components-date": "^2.4.1", + "@looker/components-providers": "^1.5.19", + "@looker/design-tokens": "^2.7.1", + "@looker/embed-sdk": "^1.6.1", + "@looker/extension-sdk": "^22.4.2", + "@looker/extension-sdk-react": "^22.4.2", + "@looker/icons": "1.5.13", + "@looker/sdk": "^22.4.2", + "@looker/sdk-rtl": "^21.3.3", + "@styled-icons/material": "^10.28.0", + "@styled-icons/material-outlined": "^10.34.0", + "@styled-icons/material-rounded": "^10.34.0", + "axios": "^0.21.2", + "date-fns": "^2.12.0", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.21", + "react": "^16.14.0", + "react-dom": "^16.14.0", + "react-is": "^16.13.1", + "react-router-dom": "^5.3.0", + "semver": "^7.3.4", + "styled-components": "^5.3.1" + }, + "devDependencies": { + "@babel/cli": "^7.16.0", + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.13.0", + "@babel/plugin-proposal-object-rest-spread": "^7.13.8", + "@babel/plugin-transform-react-jsx": "^7.13.12", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.12.5", + "@looker/eslint-config-oss": "^1.7.14", + "@looker/prettier-config": "^0.10.4", + "@types/lodash": "^4.14.165", + "@types/node": "^14.14.12", + "@types/react": "^16.14.2", + "@types/react-dom": "^16.9.10", + "@types/react-router-dom": "^5.1.5", + "@types/readable-stream": "^2.3.5", + "@types/semver": "^7.3.1", + "@types/styled-components": "^5.1.13", + "@types/styled-system": "^5.1.13", + "babel-loader": "^8.2.2", + "babel-preset-nano-react-app": "^0.1.0", + "dotenv": "^8.2.0", + "eslint": "^7.32.0", + "eslint-import-resolver-typescript": "^2.0.0", + "eslint-import-resolver-webpack": "^0.12.1", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-mdx": "^1.16.0", + "eslint-plugin-prettier": "^4.0.0", + "json-server": "^0.16.3", + "minimist": "^1.2.2", + "nodemon": "^2.0.6", + "npm-run-all": "^4.1.5", + "prettier": "^2.2.1", + "react-hot-loader": "^4.12.20", + "typescript": "^4.5.2", + "webpack": "^5.67.0", + "webpack-bundle-analyzer": "^4.5.0", + "webpack-cli": "^4.9.1", + "webpack-dev-server": "^4.8.1" + }, + "babel": { + "presets": [ + "nano-react-app" + ], + "plugins": [ + [ + "@babel/plugin-transform-react-jsx", + { + "pragmaFrag": "React.Fragment" + } + ] + ] + }, + "eslintConfig": { + "extends": [ + "@looker/eslint-config-oss" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "camelcase": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "args": "all", + "argsIgnorePattern": "^_" + } + ], + "sort-keys-fix/sort-keys-fix": "off", + "no-useless-constructor": "off", + "@typescript-eslint/no-empty-interface": "off", + "import/default": "off", + "sort-keys": "off", + "spaced-comment": [ + "error", + "always", + { + "markers": [ + "#region", + "#endregion" + ] + } + ], + "no-use-before-define": "off", + "no-console": 0 + }, + "settings": { + "import/resolver": { + "typescript": { + "project": "./tsconfig.json" + } + }, + "import/external-module-folders": [ + "node_modules", + "packages" + ] + }, + "overrides": [ + { + "files": [ + "*.js" + ], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] + }, + "prettier": "@looker/prettier-config", + "prettierConfig": { + "overrides": { + "rules": { + "trailingComma": "all" + } + } + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..3cd287b --- /dev/null +++ b/server/index.js @@ -0,0 +1,167 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2020 Looker Data Sciences, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +const jsonServer = require('json-server') +const jwt = require('jsonwebtoken') +const axios = require('axios') +const dotenv = require('dotenv') + +/** + * A test server that serves up test json data and that can + * protect that data if needed. A request must have an Authorization + * header to access the data. + * + * User can sign in and an authorization token will be created. + * The token will last for one hour after which the user has to + * log in again. It does not automatically extend. + * + * The user can sign in using a google access token and expiration + * (obtained by using the OAUTH2 implicit flow in the web client). + * If the token is valid (determined by calling the google token + * info server) a server token is created using the expiration as the + * length of the token. + */ + +// Key for signing JWT tokens. DO NOT DO THIS IN A PRODUCTION APP. +const JWT_KEY = + process.env.JWT_TOKEN_SECRET || + 'GV5KspkUq5Ymxkj7ZnBtrsukHySKG6y2puFbP8hGVkDbggQ7DhJFQDJrxQVRPaVv' + +dotenv.config() +const { GOOGLE_API_KEY } = process.env + +// Set up the json server +const server = jsonServer.create() +const router = jsonServer.router('temp_db.json') +const middlewares = jsonServer.defaults() +server.use(middlewares) + +// allow custom routes to use json-server's body parser +server.use(jsonServer.bodyParser) + +// With the advent of the SameSite attribute of cookies, added support +// for the token in the Authorization header. +server.use((req, res, next) => { + const authHeader = req.headers.authorization + if (authHeader) { + const token = authHeader.split(' ')[1] + try { + const payload = jwt.verify(token, JWT_KEY) + req.currentUser = payload + } catch (err) { + // most likely token has expires. Could also be tampering with + // token but this is a test application so it does not really + // matter. + } + } + next() +}) + +// Simple signout route. Now a noop as cookie sessions no longer +// supported. +server.get('/authout', (req, res) => { + res.sendStatus(200) +}) + +// Verify if user is logged in route. Determined by presence of +// currentUser in request (set up by earlier middeleware). +// The client uses this at initialization time to deterine if a +// session already exists +server.get('/auth', (req, res) => { + if (req.currentUser) { + res.send(req.currentUser) + } else { + res.sendStatus(401) + } +}) + +// Log the user in. Login is via the Looker server which expands a client secret +// stored in the Looker server. This endpoint validates the client secret. If +// it does not match the authorization is rejected. +server.post('/auth', async (req, res) => { + const { type, expires_in, name, id, client_secret } = req.body + // validate the client secret + if (client_secret !== process.env.CUSTOM_CLIENT_SECRET) { + res.sendStatus(401) + return + } + // In theory the id would be used to check if the user is + // authorized to access the data server. As this is just + // sample code we just grant access. Log the login though. + console.log(`${type}/${name}/${id} authorized to use the JSON server`) + // Create the JWT token for the session. Use the expires + // provided (google provides some value) or default to an + // hour. This is a very simple app, no proviso is built + // in to handle a token being invalidated before the JWT + // token expires. + const options = { + expiresIn: expires_in ? parseInt(expires_in) : 3600, + } + const userJwt = jwt.sign({ ...req.body }, JWT_KEY, options) + res.set('Content-Type', 'application/json') + res.status(200).send({ jwt_token: userJwt }) +}) + +// All data requests go through this guard first. +// If currentUser is not found on the request (see +// above for how that happens), the user is not logged in +// and a 401 response is returned. +server.use((req, res, next) => { + // If currentUser is present, user is authorized + if (req.currentUser) { + next() + } else { + res.sendStatus(401) + } +}) + +server.get('/sheets/:id/:range', async (req, res) => { + try { + const response = await axios.get( + `https://sheets.googleapis.com/v4/spreadsheets/${req.params.id}/values/${req.params.range}?key=${GOOGLE_API_KEY}` + ) + res.status(response.status).send(response.data) + } catch (error) { + let status = 500 + if (error.response) { + console.error('data', error.response.data) + console.error('status', error.response.status) + console.error('errors', error.response.headers) + status = error.response.status + } else if (error.request) { + console.error('request', error.request) + } else { + console.error('errror', error.message) + } + res.status(status).send({}) + } +}) + +// The json server data routes +server.use(router) + +// Start listening on port 3000 +server.listen(3000, () => { + console.log('JSON Server is listening on port 3000') +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6eb2f28 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,47 @@ +/* + + MIT License + + Copyright (c) 2022 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import React, { useState } from 'react' +import { ExtensionProvider2 } from '@looker/extension-sdk-react' +import { hot } from 'react-hot-loader/root' +import { Looker40SDK } from '@looker/sdk' +import { KitchenSink } from './KitchenSink' + +export const App: React.FC = hot(() => { + const [route, setRoute] = useState('') + const [routeState, setRouteState] = useState() + + const onRouteChange = (route: string, routeState?: any) => { + setRoute(route) + setRouteState(routeState) + } + + return ( + + + + ) +}) diff --git a/src/KitchenSink.tsx b/src/KitchenSink.tsx new file mode 100644 index 0000000..a39eed8 --- /dev/null +++ b/src/KitchenSink.tsx @@ -0,0 +1,222 @@ +/* + + MIT License + + Copyright (c) 2022 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import React, { useEffect, useState, useContext, Suspense } from 'react' +import { Switch, Route } from 'react-router-dom' +import { intersects } from 'semver' +import type { Looker40SDK } from '@looker/sdk' +import { + ComponentsProvider, + Layout, + Page, + Aside, + Section, +} from '@looker/components' +import type { ExtensionContextData2 } from '@looker/extension-sdk-react' +import { ExtensionContext2 } from '@looker/extension-sdk-react' +import { Sidebar } from './components/Sidebar' +import type { KitchenSinkProps, ConfigurationData } from './types' + +// Components loaded using code splitting +import { AsyncCoreSDKFunctions as CoreSDKFunctions } from './components/CoreSDKFunctions/CoreSDKFunctions.async' +import { AsyncApiFunctions as ApiFunctions } from './components/ApiFunctions/ApiFunctions.async' +import { AsyncHome as Home } from './components/Home/Home.async' +import { AsyncEmbedDashboard as EmbedDashboard } from './components/Embed/EmbedDashboard.async' +import { AsyncEmbedExplore as EmbedExplore } from './components/Embed/EmbedExplore.async' +import { AsyncEmbedLook as EmbedLook } from './components/Embed/EmbedLook.async' +import { AsyncExternalApiFunctions as ExternalApiFunctions } from './components/ExternalApiFunctions/ExternalApiFunctions.async' +import { AsyncMiscFunctions as MiscFunctions } from './components/MiscFunctions/MiscFunctions.async' +import { AsyncConfigure as Configure } from './components/Configure/Configure.async' + +// If the Looker server does not support code splitting (version 7.20 and below) replace the above +// imports with the imports below: +// import CoreSDKFunctions from './components/CoreSDKFunctions/CoreSDKFunctions' +// import ApiFunctions from './components/ApiFunctions/ApiFunctions' +// import Home from './components/Home/Home' +// import EmbedDashboard from './components/Embed/EmbedDashboard' +// import EmbedExplore from './components/Embed/EmbedExplore' +// import EmbedLook from './components/Embed/EmbedLook' +// import ExternalApiFunctions from './components/ExternalApiFunctions/ExternalApiFunctions' +// import MiscFunctions from './components/MiscFunctions/MiscFunctions' +// import Configure from './components/Configure/Configure' + +export enum ROUTES { + HOME_ROUTE = '/', + API_ROUTE = '/api', + CORESDK_ROUTE = '/coresdk', + EMBED_DASHBOARD = '/embed/dashboard', + EMBED_EXPLORE = '/embed/explore', + EMBED_LOOK = '/embed/look', + EXTERNAL_API_ROUTE = '/externalapi', + MISC_ROUTE = '/misc', + CONFIG_ROUTE = '/config', +} + +export const KitchenSink: React.FC = ({ + route, + routeState, +}) => { + const extensionContext = + useContext>(ExtensionContext2) + const { extensionSDK } = extensionContext + const [canPersistContextData, setCanPersistContextData] = + useState(false) + const [configurationData, setConfigurationData] = + useState() + + useEffect(() => { + const initialize = async () => { + // Context requires Looker version 7.14.0. If not supported provide + // default configuration object and disable saving of context data. + let context + if ( + intersects( + '>=7.14.0', + extensionSDK.lookerHostData?.lookerVersion || '7.0.0', + true + ) + ) { + try { + context = await extensionSDK.getContextData() + setCanPersistContextData(true) + } catch (error) { + console.error(error) + } + } + setConfigurationData( + context || { + showApiFunctions: true, + showCoreSdkFunctions: true, + showEmbedDashboard: true, + showEmbedExplore: true, + showEmbedLook: true, + showExternalApiFunctions: true, + showMiscFunctions: true, + dashboardId: 1, + exploreId: 'thelook/products', + lookId: 1, + } + ) + } + initialize() + }, []) + + const updateConfigurationData = async ( + configurationData: ConfigurationData + ): Promise => { + setConfigurationData(configurationData) + if (canPersistContextData) { + try { + await extensionSDK.saveContextData(configurationData) + return true + } catch (error) { + console.log(error) + } + } + return false + } + + return ( + <> + {configurationData && ( + + + + +
+ }> + + {configurationData.showApiFunctions && ( + + + + )} + {configurationData.showCoreSdkFunctions && ( + + + + )} + {configurationData.showEmbedDashboard && ( + + + + )} + {configurationData.showEmbedExplore && ( + + + + )} + {configurationData.showEmbedLook && ( + + + + )} + {configurationData.showExternalApiFunctions && ( + + + + )} + {configurationData.showMiscFunctions && ( + + + + )} + + + + {configurationData.showMiscFunctions && ( + + + + )} + + + + + +
+
+
+
+ )} + + ) +} diff --git a/src/components/ApiFunctions/ApiFunctions.async.tsx b/src/components/ApiFunctions/ApiFunctions.async.tsx new file mode 100644 index 0000000..1c803a5 --- /dev/null +++ b/src/components/ApiFunctions/ApiFunctions.async.tsx @@ -0,0 +1,33 @@ +/* + + MIT License + + Copyright (c) 2022 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import React, { lazy } from 'react' + +const ApiFunctions = lazy( + async () => import(/* webpackChunkName: "api_functions" */ './ApiFunctions') +) + +export const AsyncApiFunctions: React.FC = () => diff --git a/src/components/ApiFunctions/ApiFunctions.tsx b/src/components/ApiFunctions/ApiFunctions.tsx new file mode 100644 index 0000000..3d4a080 --- /dev/null +++ b/src/components/ApiFunctions/ApiFunctions.tsx @@ -0,0 +1,278 @@ +/* + + MIT License + + Copyright (c) 2022 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import React, { useContext, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Heading, Box, ButtonOutline, TextArea } from '@looker/components' +import type { Looker40SDK } from '@looker/sdk' +import type { ExtensionContextData2 } from '@looker/extension-sdk-react' +import { ExtensionContext2 } from '@looker/extension-sdk-react' +import { SandboxStatus } from '../SandboxStatus' +import { ROUTES } from '../../KitchenSink' +import type { ApiFunctionsProps } from './types' + +const ApiFunctions: React.FC = () => { + const history = useHistory() + const [messages, setMessages] = useState('') + const extensionContext = + useContext>(ExtensionContext2) + const { extensionSDK } = extensionContext + + const uaName = `${extensionSDK.lookerHostData?.extensionId.replace( + /(::|-)/g, + '_' + )}_user_value` + + const updateMessages = (message: any) => { + setMessages((prevMessages) => { + const maybeLineBreak = prevMessages.length === 0 ? '' : '\n' + return `${prevMessages}${maybeLineBreak}${message}` + }) + } + + const verifyHostConnectionClick = async () => { + try { + const value = await extensionSDK.verifyHostConnection() + if (value === true) { + updateMessages('Host verification success') + } else { + updateMessages('Invalid response ' + value) + } + } catch (error) { + updateMessages('Host verification failure') + updateMessages(error) + console.error('Host verification failure', error) + } + } + + const updateTitleButtonClick = () => { + const date = new Date() + extensionSDK.updateTitle( + `Extension Title Update ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}` + ) + updateMessages('Title updated') + } + + const goToBrowseButtonClick = () => { + extensionSDK.updateLocation('/browse') + } + + const goToMarketplaceButtonClick = () => { + extensionSDK.updateLocation('/marketplace') + } + + const openMarketplaceButtonClick = () => { + extensionSDK.openBrowserWindow('/marketplace', '_marketplace') + updateMessages('Window opened') + } + + const localStorageSet = async () => { + try { + await extensionSDK.localStorageSetItem('testbed', new Date().toString()) + updateMessages('Success') + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const localStorageGet = async () => { + try { + const value = await extensionSDK.localStorageGetItem('testbed') + updateMessages(value || 'null') + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const localStorageRemove = async () => { + try { + await extensionSDK.localStorageRemoveItem('testbed') + updateMessages('Success') + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const generateUnhandledErrorClick = () => { + updateMessages('About to generate error') + // const badApi: any = {} + // badApi.noExistentMethod() + throw new Error('Kitchensink threw an error') + } + + const testRouting = () => { + history.push(`${ROUTES.CORESDK_ROUTE}?test=abcd`, { count: 1 }) + } + + const getUserAttributeClick = async () => { + try { + const value = await extensionSDK.userAttributeGetItem('user_value') + updateMessages(`User attribute 'user_value' is ${value}`) + } catch (error) { + updateMessages( + `Create a user attribute named "${uaName}" and reload to use this attribute` + ) + console.error(error) + } + try { + const value = await extensionSDK.userAttributeGetItem('locale') + updateMessages(`User attribute 'locale' is ${value}`) + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const setUserAttributeClick = async () => { + try { + const value = await extensionSDK.userAttributeSetItem( + 'user_value', + new Date().toString() + ) + if (value) { + updateMessages(`Updated 'user_value' to '${value}'`) + } + } catch (error) { + updateMessages( + `Create a user attribute named "${uaName}" and reload to use this attribute` + ) + console.error(error) + } + // This will fail because global user attributes cannot by modified by an extension + try { + const timestamp = new Date().toString() + const value = await extensionSDK.userAttributeSetItem('locale', timestamp) + if (value) { + updateMessages(`Updated 'locale' to '${timestamp}'`) + } + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const resetUserAttributeClick = async () => { + try { + await extensionSDK.userAttributeResetItem('user_value') + updateMessages(`Reset 'user_value' to default`) + } catch (error) { + updateMessages( + `Create a user attribute named "${uaName}" and reload to use this attribute` + ) + console.error(error) + } + // This will fail because global user attributes cannot by modified by an extension + try { + await extensionSDK.userAttributeResetItem('locale') + updateMessages(`Reset 'locale' default`) + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const writeToClipboardClick = async () => { + try { + await extensionSDK.clipboardWrite( + 'https://trends.google.com/trends/trendingsearches/daily?geo=US' + ) + updateMessages( + `Google's "I'm feeling lucky" search has been written to the clipboard. Paste into the browser URL to confirm.` + ) + } catch (error) { + updateMessages(error) + console.error(error) + } + } + + const clearMessagesClick = () => { + setMessages('') + } + + return ( + <> + API Functions + + + + + Update title + + + Go to browse (update location) + + + Go to Marketplace (update location) + + + Open marketplace new window + + + Verify host connection + + + Set local storage + + + Get local storage + + + Remove local storage + + + Generate unhandled error + + + Route test + + + Get User Attribute + + + Set User Attribute + + + Reset User Attribute + + + Write to clipboard + + + Clear messages + + + +