diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea78..1d0bdc2d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,9 @@ assignees: '' --- +**What page is this happening on** +Copy the URL of the page this issue is happening on. + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..b46f6719 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Run linting checks + +on: + workflow_dispatch: + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x, 14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: yarn + - run: yarn lint diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..86fd3528 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,28 @@ +name: Run Node.js Tests + +on: + workflow_dispatch: + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x, 14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: yarn + - run: | + cd lib + yarn + cd - + - run: yarn test diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..923a56d9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Run tests + +on: + workflow_dispatch: + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x, 14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: yarn + - run: yarn test diff --git a/functions/package-lock.json b/functions/package-lock.json index f2ce1c90..644b5ba2 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -4346,9 +4346,9 @@ } }, "tar": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.6.tgz", - "integrity": "sha512-oaWyu5dQbHaYcyZCTfyPpC+VmI62/OM2RTUYavTk1MDr1cwW5Boi3baeYQKiZbY2uSQJGr+iMOzb/JFxLrft+g==", + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", "optional": true, "requires": { "chownr": "^2.0.0", @@ -5507,17 +5507,17 @@ } }, "tar": { - "version": "4.4.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.15.tgz", - "integrity": "sha512-ItbufpujXkry7bHH9NpQyTXPbJ72iTlXgkBAYsAjDXk3Ds8t/3NfO5P4xZGy7u+sYuQUbimgzswX4uQIEeNVOA==", - "requires": { - "chownr": "^1.1.1", - "fs-minipass": "^1.2.5", - "minipass": "^2.8.6", - "minizlib": "^1.2.1", - "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.2", - "yallist": "^3.0.3" + "version": "4.4.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", + "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "requires": { + "chownr": "^1.1.4", + "fs-minipass": "^1.2.7", + "minipass": "^2.9.0", + "minizlib": "^1.3.3", + "mkdirp": "^0.5.5", + "safe-buffer": "^5.2.1", + "yallist": "^3.1.1" }, "dependencies": { "chownr": { diff --git a/lib/yarn.lock b/lib/yarn.lock index 013e7509..041b83f4 100644 --- a/lib/yarn.lock +++ b/lib/yarn.lock @@ -739,7 +739,7 @@ chokidar@^3.0.2: optionalDependencies: fsevents "~2.3.1" -chownr@^1.1.1: +chownr@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== @@ -1679,7 +1679,7 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-minipass@^1.2.5: +fs-minipass@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== @@ -2783,7 +2783,7 @@ minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: +minipass@^2.6.0, minipass@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== @@ -2798,7 +2798,7 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -minizlib@^1.2.1: +minizlib@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== @@ -2813,7 +2813,7 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" -"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.5: +"mkdirp@>=0.5 0", mkdirp@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -3532,7 +3532,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -3901,17 +3901,17 @@ tar-stream@^2.2.0: readable-stream "^3.1.1" tar@^4.3.0: - version "4.4.15" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.15.tgz#3caced4f39ebd46ddda4d6203d48493a919697f8" - integrity sha512-ItbufpujXkry7bHH9NpQyTXPbJ72iTlXgkBAYsAjDXk3Ds8t/3NfO5P4xZGy7u+sYuQUbimgzswX4uQIEeNVOA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" + version "4.4.19" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.19.tgz#2e4d7263df26f2b914dee10c825ab132123742f3" + integrity sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA== + dependencies: + chownr "^1.1.4" + fs-minipass "^1.2.7" + minipass "^2.9.0" + minizlib "^1.3.3" + mkdirp "^0.5.5" + safe-buffer "^5.2.1" + yallist "^3.1.1" tar@^6.0.2: version "6.1.0" @@ -4307,7 +4307,7 @@ xtend@~4.0.0: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== -yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: +yallist@^3.0.0, yallist@^3.0.2, yallist@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 6d8aa2ba..00000000 --- a/src/api.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useSelector } from "react-redux" -import React from "react" - -// TODO - this file should be removed and the api-specific things for each demo -// should be handled like in request-composer. -interface AnalyticsApi { - management: typeof gapi.client.analytics.management - metadata: typeof gapi.client.analytics.metadata - data: typeof gapi.client.analytics.data - reporting: typeof gapi.client.analytics.data -} - -export type AccountSummary = gapi.client.analytics.AccountSummary -export type WebPropertySummary = gapi.client.analytics.WebPropertySummary -export type ProfileSummary = gapi.client.analytics.ProfileSummary -export type Column = gapi.client.analytics.Column -export type Segment = gapi.client.analytics.Segment -export type GetReportsResponse = gapi.client.analyticsreporting.GetReportsResponse -export type V4Dimensions = gapi.client.analyticsreporting.Dimension - -export const getAnalyticsApi = (g: typeof gapi): AnalyticsApi => { - return (g as any).client.analytics -} - -export const useApi = (): AnalyticsApi | undefined => { - const gapi = useSelector((state: AppState) => state.gapi) - const [api, setApi] = React.useState(undefined) - - React.useEffect(() => { - if (gapi === undefined) { - return - } - setApi(getAnalyticsApi(gapi)) - }, [gapi]) - - return api -} - -export const useMetadataAPI = (): - | typeof gapi.client.analytics.metadata - | undefined => { - const g = useSelector((state: AppState) => state.gapi) - const [api, setApi] = React.useState< - typeof gapi.client.analytics.metadata | undefined - >(undefined) - - React.useEffect(() => { - if (g === undefined) { - return - } - setApi(g.client.analytics.metadata as any) - }, [g]) - - return api -} - -// TODO - should segments be filtered based on potentially selected dimensions -// and metrics? -// TODO - This should be cached locally since it's used in a lot of places. -export const useSegments = () => { - const api = useApi() - const [segments, setSegments] = React.useState() - - React.useEffect(() => { - if (api === undefined) { - return - } - - api.management.segments.list({}).then(response => { - setSegments(response.result.items) - }) - }, [api]) - - return segments -} diff --git a/src/components/AccountExplorer/ViewTable.tsx b/src/components/AccountExplorer/ViewTable.tsx index 36200a78..b1c58170 100644 --- a/src/components/AccountExplorer/ViewTable.tsx +++ b/src/components/AccountExplorer/ViewTable.tsx @@ -11,7 +11,9 @@ import Typography from "@material-ui/core/Typography" import { CopyIconButton } from "@/components/CopyButton" import HighlightText from "./HighlightText" import Spinner from "@/components/Spinner" -import { UAAccountPropertyView } from "../ViewSelector/useAccountPropertyView" +import { RequestStatus } from "@/types" +import useFlattenedViews from "../ViewSelector/useFlattenedViews" +import { AccountSummary, ProfileSummary, WebPropertySummary } from "@/types/ua" const useStyles = makeStyles(theme => ({ id: { @@ -34,7 +36,7 @@ const useStyles = makeStyles(theme => ({ })) interface ViewTableProps { - views: UAAccountPropertyView[] | undefined + flattenedViewsRequest: ReturnType className?: string search?: string } @@ -45,7 +47,7 @@ interface ViewCellProps { copyToolTip: string textToCopy: string } -const ViewCell: React.FC = ({ +const APVCell: React.FC = ({ firstRow, secondRow, copyToolTip, @@ -79,7 +81,126 @@ const textClamp = (text: string, maxWidth: number) => { } } -const ViewsTable: React.FC = ({ views, className, search }) => { +const AccountCell: React.FC<{ + account: AccountSummary | undefined + classes: ReturnType + search: string | undefined +}> = ({ account, classes, search }) => { + if (account === undefined) { + return null + } + return ( + + } + secondRow={ + + } + /> + ) +} + +const PropertyCell: React.FC<{ + property: WebPropertySummary | undefined + classes: ReturnType + search: string | undefined +}> = ({ property, classes, search }) => { + if (property === undefined) { + return No UA properties for account. + } + + return ( + + } + secondRow={ + + } + /> + ) +} + +const ViewCell: React.FC<{ + account: AccountSummary | undefined + property: WebPropertySummary | undefined + view: ProfileSummary | undefined + classes: ReturnType + search: string | undefined +}> = ({ account, property, view, classes, search }) => { + if (view === undefined) { + return No UA views for account. + } + const viewUrl = `https://analytics.google.com/analytics/web/#/report/vistors-overview/a${account?.id}w${property?.internalWebPropertyId}p${view?.id}` + return ( + + + + + } + secondRow={ + + } + /> + + } + /> + + ) +} + +const ViewsTable: React.FC = ({ + flattenedViewsRequest, + className, + search, +}) => { const classes = useStyles() return ( = ({ views, className, search }) => { - {views !== undefined ? ( - views.map(apv => { - const viewUrl = `https://analytics.google.com/analytics/web/#/report/vistors-overview/a${ - apv!.account!.id - }w${apv!.property!.internalWebPropertyId}p${apv!.view!.id}` - return ( - - - } - secondRow={ - - } - /> - - } - secondRow={ - - } - /> - - - - } - secondRow={ - - } - /> - - } - /> - - ) - }) + {flattenedViewsRequest.status === RequestStatus.Successful ? ( + flattenedViewsRequest.flattenedViews.length === 0 ? ( + + No results + + ) : ( + flattenedViewsRequest.flattenedViews.map(apv => { + return ( + + + + + + ) + }) + ) ) : ( @@ -192,11 +258,6 @@ const ViewsTable: React.FC = ({ views, className, search }) => { )} - {views?.length === 0 && ( - - No results - - )}
) diff --git a/src/components/AccountExplorer/index.spec.tsx b/src/components/AccountExplorer/index.spec.tsx index eaf8b220..a103e26d 100644 --- a/src/components/AccountExplorer/index.spec.tsx +++ b/src/components/AccountExplorer/index.spec.tsx @@ -14,43 +14,38 @@ import * as React from "react" import * as renderer from "@testing-library/react" -import { withProviders, testGapi } from "../../test-utils" +import { withProviders } from "../../test-utils" import "@testing-library/jest-dom" import AccountExplorer from "./index" describe("AccountExplorer", () => { it("renders without error for an unauthorized user", async () => { - const { wrapped, store } = withProviders() - store.dispatch({ type: "setUser", user: undefined }) - const { findByTestId } = renderer.render(wrapped) - const result = await findByTestId("components/ViewTable/no-results") - expect(result).toBeVisible() + const { wrapped } = withProviders(, { + isLoggedIn: false, + }) + const { findByText } = renderer.render(wrapped) + + const heading = await findByText("Overview") + expect(heading).toBeVisible() }) describe("with an authorized user", () => { - const user = { getId: () => "userId" } describe("with accounts", () => { it("selects the first account & shows it in the tree", async () => { - const gapi = testGapi() - const { wrapped, store } = withProviders() - store.dispatch({ type: "setUser", user }) - store.dispatch({ type: "setGapi", gapi }) + const { wrapped, gapi } = withProviders() const { findByText } = renderer.render(wrapped) await renderer.act(async () => { - await gapi.client.analytics.management.accountSummaries.list() + await gapi!.client!.analytics!.management!.accountSummaries!.list!() }) const viewColumn = await findByText("View Name 1 1 1") expect(viewColumn).toBeVisible() }) it("picking a view updates the table", async () => { - const gapi = testGapi() - const { wrapped, store } = withProviders() - store.dispatch({ type: "setUser", user }) - store.dispatch({ type: "setGapi", gapi }) + const { wrapped, gapi } = withProviders() const { findByText, findByLabelText } = renderer.render(wrapped) await renderer.act(async () => { - await gapi.client.analytics.management.accountSummaries.list() + await gapi!.client!.analytics!.management!.accountSummaries!.list!() }) await renderer.act(async () => { // Choose the second view in the list @@ -63,14 +58,11 @@ describe("AccountExplorer", () => { expect(viewColumn).toBeVisible() }) it("searching for a view updates the table", async () => { - const gapi = testGapi() - const { wrapped, store } = withProviders() - store.dispatch({ type: "setUser", user }) - store.dispatch({ type: "setGapi", gapi }) + const { wrapped, gapi } = withProviders() const { findByText, findByPlaceholderText } = renderer.render(wrapped) await renderer.act(async () => { - await gapi.client.analytics.management.accountSummaries.list() + await gapi!.client!.analytics!.management!.accountSummaries!.list!() }) await renderer.act(async () => { // Choose the second view in the list diff --git a/src/components/AccountExplorer/index.tsx b/src/components/AccountExplorer/index.tsx index ac703dde..e7bd2223 100644 --- a/src/components/AccountExplorer/index.tsx +++ b/src/components/AccountExplorer/index.tsx @@ -21,11 +21,11 @@ import { useDebounce } from "use-debounce" import ViewSelector from "@/components/ViewSelector" import ViewsTable from "./ViewTable" -import useFlattenedViews from "./useFlattenedViews" import useAccountPropertyView, { UAAccountPropertyView, } from "../ViewSelector/useAccountPropertyView" import { StorageKey } from "@/constants" +import useFlattenedViews from "../ViewSelector/useFlattenedViews" const useStyles = makeStyles(theme => ({ viewSelector: { @@ -78,27 +78,6 @@ const containsQuery = ( return !!hasMatch } -const viewsForSearch = ( - searchQuery: string, - views: UAAccountPropertyView[] -) => { - return views.filter(populated => containsQuery(searchQuery, populated)) -} - -const populatedView = ( - apv: UAAccountPropertyView | undefined -): UAAccountPropertyView | undefined => { - if ( - apv !== undefined && - apv.account !== undefined && - apv.property !== undefined && - apv.view !== undefined - ) { - return apv - } - return undefined -} - enum QueryParam { Account = "a", Property = "b", @@ -114,35 +93,17 @@ const AccountExplorer = () => { StorageKey.accountExplorerAPV, QueryParam ) - const allViews = useFlattenedViews() - const filteredViews = React.useMemo(() => { - if (populatedView(selectedAPV) !== undefined) { - return [populatedView(selectedAPV)!] - } - // If allViews is defined - if (allViews !== undefined) { - // If account or property is selected filter out views to only views with that property and view. - const filtered = allViews - .filter(view => - selectedAPV?.account !== undefined - ? selectedAPV.account.id === view.account!.id - : true - ) - .filter(view => - selectedAPV?.property !== undefined - ? selectedAPV.property.id === view.property!.id - : true - ) - // If there is a search, it should take priority - if (debouncedQuery !== "") { - return viewsForSearch(debouncedQuery, filtered) - } - return filtered - } else { - return allViews - } - }, [allViews, selectedAPV, debouncedQuery]) + const filterViews = React.useCallback( + (fv: UAAccountPropertyView) => containsQuery(debouncedQuery, fv), + [debouncedQuery] + ) + + const flattenedViewsRequest = useFlattenedViews( + selectedAPV.account, + selectedAPV.property, + filterViews + ) return ( <> @@ -178,8 +139,8 @@ const AccountExplorer = () => { /> diff --git a/src/components/AccountExplorer/useFlattenedViews.ts b/src/components/AccountExplorer/useFlattenedViews.ts deleted file mode 100644 index a185b0a2..00000000 --- a/src/components/AccountExplorer/useFlattenedViews.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from "react" -import { UAAccountPropertyView } from "../ViewSelector/useAccountPropertyView" -import useAccounts from "../ViewSelector/useAccounts" - -const useFlattenedViews = (): UAAccountPropertyView[] | undefined => { - const accounts = useAccounts() - - return useMemo( - () => - accounts?.flatMap(summary => { - const account = { ...summary } - - const properties = summary.webProperties || [] - return properties.flatMap(propertySummary => { - const property = { ...propertySummary } - - const profiles = propertySummary.profiles || [] - return profiles.map(profile => ({ - view: profile, - property, - account, - })) - }) - }), - [accounts] - ) -} - -export default useFlattenedViews diff --git a/src/components/CampaignURLBuilder/Web/GeneratedURL/WarningsFor.tsx b/src/components/CampaignURLBuilder/Web/GeneratedURL/WarningsFor.tsx index cd0c8540..0b9c47d3 100644 --- a/src/components/CampaignURLBuilder/Web/GeneratedURL/WarningsFor.tsx +++ b/src/components/CampaignURLBuilder/Web/GeneratedURL/WarningsFor.tsx @@ -78,7 +78,7 @@ const WarningsFor: React.FC = ({ ]) } return w - }, [asURL, websiteURL]) + }, [asURL]) React.useEffect(() => { if (warnings.length !== 0) { diff --git a/src/components/CampaignURLBuilder/Web/GeneratedURL/index.tsx b/src/components/CampaignURLBuilder/Web/GeneratedURL/index.tsx index a4e694de..6256e95e 100644 --- a/src/components/CampaignURLBuilder/Web/GeneratedURL/index.tsx +++ b/src/components/CampaignURLBuilder/Web/GeneratedURL/index.tsx @@ -62,6 +62,7 @@ const GeneratedURL: React.FC = ({ }, [version, websiteURL, source, medium]) const generatedURL = useGenerateURL({ + setShortened, websiteURL, source, medium, @@ -135,6 +136,7 @@ const GeneratedURL: React.FC = ({ />
websiteURL?: string source?: string medium?: string @@ -13,6 +15,7 @@ interface Arg { } const useGenerateURL = ({ + setShortened, websiteURL, source, medium, @@ -23,6 +26,8 @@ const useGenerateURL = ({ useFragment, }: Arg) => { return useMemo(() => { + // Whenever the generated url changes, clear out the shortened url. + setShortened(undefined) if (websiteURL === undefined) { return undefined } @@ -38,7 +43,17 @@ const useGenerateURL = ({ }, useFragment ) - }, [useFragment, websiteURL, source, medium, campaign, id, term, content]) + }, [ + useFragment, + websiteURL, + source, + medium, + campaign, + id, + term, + content, + setShortened, + ]) } export default useGenerateURL diff --git a/src/components/CampaignURLBuilder/index.spec.tsx b/src/components/CampaignURLBuilder/index.spec.tsx index 0a11fd65..dc8031a7 100644 --- a/src/components/CampaignURLBuilder/index.spec.tsx +++ b/src/components/CampaignURLBuilder/index.spec.tsx @@ -85,7 +85,7 @@ describe("for the Campaign URL Builder component", () => { }) }) describe("entering a no-no url shows a warning", () => { - test("url: ga-dev-tools.appspot.com", async () => { + test("url: ga-dev-tools.web.app", async () => { const { wrapped } = withProviders( { const { findByLabelText: find, findByTestId } = renderer.render(wrapped) await userEvent.type( await find(/website URL/), - "https://ga-dev-tools.appspot.com" + "https://ga-dev-tools.web.app" ) const warningBanner = await findByTestId("bad-url-warnings") diff --git a/src/components/DimensionsMetricsExplorer/ColumnGroupList.tsx b/src/components/DimensionsMetricsExplorer/ColumnGroupList.tsx index af9705c9..b3607dc1 100644 --- a/src/components/DimensionsMetricsExplorer/ColumnGroupList.tsx +++ b/src/components/DimensionsMetricsExplorer/ColumnGroupList.tsx @@ -28,10 +28,10 @@ import { Set } from "immutable" import { navigate } from "gatsby" import classnames from "classnames" -import { Column } from "@/api" import { CopyIconButton } from "@/components/CopyButton" import LabeledCheckbox from "@/components/LabeledCheckbox" import { CUBES_BY_COLUMN_NAME, CUBE_NAMES, CubesByColumnName } from "./cubes" +import { Column } from "@/types/ua" const useStyles = makeStyles(theme => ({ accordionTitle: { margin: 0 }, diff --git a/src/components/DimensionsMetricsExplorer/Explorer.tsx b/src/components/DimensionsMetricsExplorer/Explorer.tsx index fea8c919..b0301b00 100644 --- a/src/components/DimensionsMetricsExplorer/Explorer.tsx +++ b/src/components/DimensionsMetricsExplorer/Explorer.tsx @@ -147,7 +147,7 @@ const Explorer: React.FC = () => { Details:
)} diff --git a/src/components/DimensionsMetricsExplorer/GroupInfoTemplate.tsx b/src/components/DimensionsMetricsExplorer/GroupInfoTemplate.tsx index 716237d0..7324576d 100644 --- a/src/components/DimensionsMetricsExplorer/GroupInfoTemplate.tsx +++ b/src/components/DimensionsMetricsExplorer/GroupInfoTemplate.tsx @@ -24,8 +24,8 @@ import { Link } from "gatsby" import { sortBy } from "lodash" import Layout from "@/components/Layout" -import { Column } from "@/api" import { CopyIconButton } from "@/components/CopyButton" +import { Column } from "./common-types" type GroupInfoTemplateProps = { pageContext: { diff --git a/src/components/DimensionsMetricsExplorer/index.spec.tsx b/src/components/DimensionsMetricsExplorer/index.spec.tsx index be4af784..7136ce7b 100644 --- a/src/components/DimensionsMetricsExplorer/index.spec.tsx +++ b/src/components/DimensionsMetricsExplorer/index.spec.tsx @@ -30,7 +30,7 @@ describe("Dimensions and Metrics Explorer", () => { // Wait for api promise to resolve so it won't render "fetching". await renderer.act(async () => { // metadata: { columns: { list: () => metadataColumnsPromise } }, - await gapi.client.analytics.metadata.columns.list() + await gapi!.client!.analytics!.metadata!.columns!.list!() // await gapi.client.analytics.management.accountSummaries.list() }) diff --git a/src/components/DimensionsMetricsExplorer/useAnchorRedirects.ts b/src/components/DimensionsMetricsExplorer/useAnchorRedirects.ts index 6c9d9595..a6affec6 100644 --- a/src/components/DimensionsMetricsExplorer/useAnchorRedirects.ts +++ b/src/components/DimensionsMetricsExplorer/useAnchorRedirects.ts @@ -10,7 +10,7 @@ const useAnchorRedirects = ( const { hash, pathname } = useLocation() useEffect(() => { - if (hash === "" || columns === undefined) { + if (hash === undefined || hash === "" || columns === undefined) { return } if (hash.startsWith("#ga:")) { diff --git a/src/components/DimensionsMetricsExplorer/useColumns.ts b/src/components/DimensionsMetricsExplorer/useColumns.ts index 8552b822..5bc84cbe 100644 --- a/src/components/DimensionsMetricsExplorer/useColumns.ts +++ b/src/components/DimensionsMetricsExplorer/useColumns.ts @@ -1,62 +1,55 @@ import { StorageKey } from "@/constants" import useCached from "@/hooks/useCached" -import useRequestStatus from "@/hooks/useRequestStatus" import { Requestable, RequestStatus } from "@/types" +import { Column } from "@/types/ua" import moment from "moment" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useMemo } from "react" import { useSelector } from "react-redux" const useColumns = (): Requestable< - { columns: gapi.client.analytics.Column[] }, + { columns: Column[] }, {}, {}, - { errorData: any } + { error: any } > => { const gapi = useSelector((a: AppState) => a.gapi) const metadataAPI = useMemo(() => gapi?.client.analytics.metadata, [gapi]) - const { status, setSuccessful, setFailed, setInProgress } = useRequestStatus() const requestReady = useMemo(() => metadataAPI !== undefined, [metadataAPI]) - const [errorData, setErrorData] = useState() const makeRequest = useCallback(async () => { if (metadataAPI === undefined) { throw new Error("Invalid invariant - metadataAPI must be defined here.") } - setInProgress() return metadataAPI.columns .list({ reportType: "ga" }) .then(response => response.result.items) - .catch(e => { - console.error(e) - setErrorData(e) - setFailed() - }) - }, [metadataAPI, setFailed, setInProgress]) + }, [metadataAPI]) - const { value: columns } = useCached( + const columnsRequest = useCached( StorageKey.dimensionsMetricsExplorerColumns, makeRequest, moment.duration(5, "minutes"), requestReady ) - useEffect(() => { - if (columns !== undefined) { - setSuccessful() + return useMemo(() => { + switch (columnsRequest.status) { + case RequestStatus.Successful: { + const columns = columnsRequest.value + if (columns === undefined) { + throw new Error("invalid invarint - columns must be defined here") + } + return { status: columnsRequest.status, columns } + } + case RequestStatus.NotStarted: + case RequestStatus.InProgress: + return { status: columnsRequest.status } + case RequestStatus.Failed: { + return columnsRequest + } } - }, [columns, setSuccessful]) - - if (columns !== undefined || status === RequestStatus.Successful) { - if (columns === undefined) { - throw new Error("Invalid invariant - columns must be defined here.") - } - return { status: RequestStatus.Successful, columns } - } - if (status === RequestStatus.Failed) { - return { status, errorData } - } - return { status } + }, [columnsRequest]) } export default useColumns diff --git a/src/components/GA4Pickers/index.tsx b/src/components/GA4Pickers/index.tsx index 46086e73..228a4dbe 100644 --- a/src/components/GA4Pickers/index.tsx +++ b/src/components/GA4Pickers/index.tsx @@ -17,9 +17,9 @@ import * as React from "react" import { Typography, TextField, makeStyles } from "@material-ui/core" import Autocomplete from "@material-ui/lab/Autocomplete" import { useState } from "react" -import { Dispatch } from "@/types" +import { Dispatch, RequestStatus } from "@/types" import useAvailableColumns from "./useAvailableColumns" -import { AccountProperty } from "../ga4/StreamPicker/useAccountProperty" +import { DimensionsAndMetricsRequestCtx } from "../ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics" const useColumnStyles = makeStyles(() => ({ option: { @@ -62,7 +62,6 @@ export const DimensionsPicker: React.FC<{ // it required. setDimensions?: React.Dispatch> setDimensionIDs?: Dispatch - aps: AccountProperty required?: boolean helperText?: string | JSX.Element label?: string @@ -72,13 +71,13 @@ export const DimensionsPicker: React.FC<{ setDimensions, setDimensionIDs, required, - aps, label = "dimensions", }) => { + const request = React.useContext(DimensionsAndMetricsRequestCtx) const { dimensionOptionsLessSelected } = useAvailableColumns({ selectedDimensions: dimensions, selectedMetrics: [], - aps, + request, }) return ( @@ -88,6 +87,7 @@ export const DimensionsPicker: React.FC<{ autoHighlight freeSolo multiple + loading={request.status === RequestStatus.InProgress} options={dimensionOptionsLessSelected || []} getOptionLabel={dimension => dimension.apiName!} value={dimensions || []} @@ -124,7 +124,6 @@ export const MetricsPicker: React.FC<{ // it required. setMetrics?: React.Dispatch> setMetricIDs?: Dispatch - aps: AccountProperty required?: boolean helperText?: string | JSX.Element label?: string @@ -134,13 +133,13 @@ export const MetricsPicker: React.FC<{ setMetrics, setMetricIDs: setMetricsIDs, required, - aps, label = "metrics", }) => { + const request = React.useContext(DimensionsAndMetricsRequestCtx) const { metricOptionsLessSelected } = useAvailableColumns({ selectedMetrics: metrics, selectedDimensions: [], - aps, + request, }) // TODO - I'm not sure this should be a freeSolo. @@ -152,6 +151,7 @@ export const MetricsPicker: React.FC<{ autoHighlight freeSolo multiple + loading={request.status === RequestStatus.InProgress} options={metricOptionsLessSelected || []} getOptionLabel={metric => metric.apiName!} value={metrics || []} @@ -183,7 +183,6 @@ export const MetricsPicker: React.FC<{ } export const DimensionPicker: React.FC<{ - aps: AccountProperty autoSelectIfOne?: boolean setDimension?: Dispatch dimensionFilter?: (dimension: GA4Dimension) => boolean @@ -196,19 +195,19 @@ export const DimensionPicker: React.FC<{ helperText, setDimension, required, - aps, dimensionFilter, className, label = "dimension", }) => { const [selected, setSelected] = useState() + const request = React.useContext(DimensionsAndMetricsRequestCtx) const { dimensionOptions, dimensionOptionsLessSelected, } = useAvailableColumns({ selectedDimensions: selected === undefined ? [] : [selected], selectedMetrics: [], - aps, + request, dimensionFilter, }) @@ -250,6 +249,7 @@ export const DimensionPicker: React.FC<{ autoComplete autoHighlight freeSolo + loading={request.status === RequestStatus.InProgress} options={dimensionOptionsLessSelected || []} getOptionLabel={dimension => dimension.apiName!} value={selected === undefined ? null : selected} @@ -272,7 +272,6 @@ export const DimensionPicker: React.FC<{ } export const MetricPicker: React.FC<{ - aps: AccountProperty autoSelectIfOne?: boolean setMetric?: Dispatch metricFilter?: (metric: GA4Metric) => boolean @@ -285,16 +284,16 @@ export const MetricPicker: React.FC<{ helperText, setMetric, required, - aps, metricFilter, className, label = "metric", }) => { const [selected, setSelected] = useState() + const request = React.useContext(DimensionsAndMetricsRequestCtx) const { metricOptions, metricOptionsLessSelected } = useAvailableColumns({ selectedMetrics: selected === undefined ? [] : [selected], selectedDimensions: [], - aps, + request, metricFilter, }) @@ -336,6 +335,7 @@ export const MetricPicker: React.FC<{ autoComplete autoHighlight freeSolo + loading={request.status === RequestStatus.InProgress} options={metricOptionsLessSelected || []} getOptionLabel={metric => metric.apiName!} value={selected === undefined ? null : selected} diff --git a/src/components/GA4Pickers/useAvailableColumns.ts b/src/components/GA4Pickers/useAvailableColumns.ts index 3dbadeb1..5f8e0606 100644 --- a/src/components/GA4Pickers/useAvailableColumns.ts +++ b/src/components/GA4Pickers/useAvailableColumns.ts @@ -18,18 +18,17 @@ import { useMemo } from "react" import { RequestStatus } from "@/types" import { Dimension, + DimensionsAndMetricsRequest, Metric, - useDimensionsAndMetrics, } from "@/components/ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics" import { GA4Dimensions, GA4Metrics } from "." -import { AccountProperty } from "../ga4/StreamPicker/useAccountProperty" interface Arg { selectedMetrics: GA4Metrics selectedDimensions: GA4Dimensions - aps: AccountProperty dimensionFilter?: (dimension: Dimension) => boolean metricFilter?: (metric: Metric) => boolean + request: DimensionsAndMetricsRequest } export const useAvailableColumns = ({ @@ -37,15 +36,13 @@ export const useAvailableColumns = ({ selectedDimensions, dimensionFilter, metricFilter, - aps, + request, }: Arg): { metricOptions: GA4Metrics metricOptionsLessSelected: GA4Metrics dimensionOptions: GA4Dimensions dimensionOptionsLessSelected: GA4Dimensions } => { - const request = useDimensionsAndMetrics(aps) - const [metrics, dimensions] = useMemo(() => { if (request.status !== RequestStatus.Successful) { return [undefined, undefined] diff --git a/src/components/HitBuilder/hooks.ts b/src/components/HitBuilder/hooks.ts index 8edfcadd..c763c746 100644 --- a/src/components/HitBuilder/hooks.ts +++ b/src/components/HitBuilder/hooks.ts @@ -17,7 +17,6 @@ import * as React from "react" import { useSelector } from "react-redux" import { useLocation } from "@reach/router" -import { getAnalyticsApi } from "@/api" import { Params, Param, ValidationMessage, HitStatus, Property } from "./types" import * as hitUtils from "./hit" @@ -249,8 +248,9 @@ export const useProperties: UseProperties = () => { return } ;(async () => { - const api = getAnalyticsApi(gapi) - const summaries = (await api.management.accountSummaries.list({})).result + const summaries = ( + await gapi.client.analytics.management.accountSummaries.list({}) + ).result const properties: Property[] = [] summaries.items?.forEach(account => { const accountName = account.name || "" diff --git a/src/components/HitBuilder/index.spec.tsx b/src/components/HitBuilder/index.spec.tsx index 1b4666eb..ee88e0e0 100644 --- a/src/components/HitBuilder/index.spec.tsx +++ b/src/components/HitBuilder/index.spec.tsx @@ -55,7 +55,7 @@ describe("HitBuilder", () => { test("with query parameters for a non-default t parameter", async () => { const queryParams = "v=1&t=screenview&tid=UA-fake&cid=abc&an=def&cd=ghi" - const { wrapped, history } = withProviders(, { + const { wrapped } = withProviders(, { path: `/hit-builder?${queryParams}`, }) const { findByLabelText, findAllByLabelText } = renderer.render(wrapped) diff --git a/src/components/QueryExplorer/Sort.tsx b/src/components/QueryExplorer/Sort.tsx index 157e044b..b51ed7a0 100644 --- a/src/components/QueryExplorer/Sort.tsx +++ b/src/components/QueryExplorer/Sort.tsx @@ -19,9 +19,9 @@ import TextField from "@material-ui/core/TextField" import makeStyles from "@material-ui/core/styles/makeStyles" import Autocomplete from "@material-ui/lab/Autocomplete" -import { Column } from "@/api" import { SortableColumn } from "." import { Dispatch } from "@/types" +import { Column } from "@/types/ua" const useStyles = makeStyles(_ => ({ conceptOption: { diff --git a/src/components/QueryExplorer/index.tsx b/src/components/QueryExplorer/index.tsx index 7984d620..dd0207fa 100644 --- a/src/components/QueryExplorer/index.tsx +++ b/src/components/QueryExplorer/index.tsx @@ -20,10 +20,9 @@ import TextField from "@material-ui/core/TextField" import makeStyles from "@material-ui/core/styles/makeStyles" import Launch from "@material-ui/icons/Launch" -import { Column } from "@/api" import useDataAPIRequest from "./useDataAPIRequest" import useInputs from "./useInputs" -import { Url } from "@/constants" +import { StorageKey, Url } from "@/constants" import ViewSelector from "@/components/ViewSelector" import { DimensionsPicker, @@ -37,6 +36,14 @@ import ExternalLink from "@/components/ExternalLink" import Sort from "./Sort" import Report from "./Report" import usePermalink from "./usePermalink" +import { Column, ProfileSummary } from "@/types/ua" +import useUADimensionsAndMetrics, { + UADimensionsAndMetricsRequestCtx, +} from "../UAPickers/useDimensionsAndMetrics" +import { UASegmentsRequestCtx, useUASegments } from "../UAPickers/useUASegments" +import useAccountPropertyView from "../ViewSelector/useAccountPropertyView" +import { useHydratedPersistantString } from "@/hooks/useHydrated" +import { successful } from "@/types" const coreReportingApi = ( Core Reporting API @@ -110,12 +117,53 @@ const DevsiteLink: React.FC<{ hash: string }> = ({ hash }) => { ) } +export enum QueryParam { + Account = "a", + Property = "b", + View = "c", + ShowSegmentDefinitions = "d", + ViewID = "ids", + StartDate = "start-date", + EndDate = "end-date", + SelectedMetrics = "metrics", + SelectedDimensions = "dimensions", + Sort = "sort", + Filters = "filters", + Segment = "segment", + SamplingLevel = "samplingLevel", + StartIndex = "start-index", + MaxResults = "max-results", + IncludeEmptyRows = "include-empty-rows", +} + export const QueryExplorer = () => { const classes = useStyles() + const [viewID, setViewID] = useHydratedPersistantString( + StorageKey.queryExplorerViewID, + QueryParam.ViewID + ) + + const onSetView = React.useCallback( + (view: ProfileSummary | undefined) => { + if (view === undefined) { + return + } + setViewID(`ga:${view.id}`) + }, + [setViewID] + ) + + const accountPropertyView = useAccountPropertyView( + StorageKey.queryExplorerAPV, + QueryParam, + onSetView + ) + const uaDimensionsAndMetricsRequest = useUADimensionsAndMetrics( + accountPropertyView + ) + const uaSegmentsRequest = useUASegments() const { - viewID, - setViewID, startDate, setStartDate, endDate, @@ -140,9 +188,7 @@ export const QueryExplorer = () => { setIncludeEmptyRows, segment, sort, - accountPropertyView, - columns, - } = useInputs() + } = useInputs(uaDimensionsAndMetricsRequest, uaSegmentsRequest) const { account, property, view } = accountPropertyView const { @@ -193,7 +239,9 @@ export const QueryExplorer = () => { }, [permalink, updateLink]) return ( - <> + Overview This tool lets you interact with the {coreReportingApi} by building @@ -266,18 +314,12 @@ export const QueryExplorer = () => { } /> { fullWidth helperText="The filters to apply to the query." /> - + + + { - + ) } diff --git a/src/components/QueryExplorer/useDataAPIRequest.ts b/src/components/QueryExplorer/useDataAPIRequest.ts index 8a3c9c15..bfbf0c53 100644 --- a/src/components/QueryExplorer/useDataAPIRequest.ts +++ b/src/components/QueryExplorer/useDataAPIRequest.ts @@ -1,9 +1,9 @@ import * as React from "react" -import { Segment, Column, useApi } from "@/api" import { V3SamplingLevel } from "@/components/UAPickers" import { SortableColumn } from "." import { useSelector } from "react-redux" +import { Column, Segment } from "@/types/ua" export enum APIStatus { Error = "error", @@ -62,7 +62,7 @@ export const useDataAPIRequest: UseDataAPIRequest = ({ filters, sort, }) => { - const api = useApi() + const gapi = useSelector((a: AppState) => a.gapi) const user = useSelector((a: AppState) => a.user) const [queryResponse, setQueryResponse] = React.useState() @@ -86,7 +86,7 @@ export const useDataAPIRequest: UseDataAPIRequest = ({ (cb: () => void) => { if ( viewID === undefined || - api === undefined || + gapi === undefined || selectedMetrics === undefined || selectedMetrics.length === 0 || startDate === undefined || @@ -128,7 +128,7 @@ export const useDataAPIRequest: UseDataAPIRequest = ({ .join(",") } setQueryResponse({ status: APIStatus.InProgress }) - api.data.ga + gapi.client.analytics.data.ga .get(apiObject) .then(response => { setQueryResponse({ @@ -154,7 +154,7 @@ export const useDataAPIRequest: UseDataAPIRequest = ({ filters, selectedSamplingValue, includeEmptyRows, - api, + gapi, ] ) diff --git a/src/components/QueryExplorer/useInputs.ts b/src/components/QueryExplorer/useInputs.ts index 1e4d0a33..592a881a 100644 --- a/src/components/QueryExplorer/useInputs.ts +++ b/src/components/QueryExplorer/useInputs.ts @@ -1,65 +1,23 @@ import * as React from "react" -import { Segment, Column } from "@/api" import { StorageKey } from "@/constants" -import { - useUASegments, - V3SamplingLevel, - useUADimensionsAndMetrics, -} from "@/components/UAPickers" -import { SortableColumn } from "." +import { V3SamplingLevel } from "@/components/UAPickers" +import { QueryParam, SortableColumn } from "." import { useHydratedPersistantBoolean, useHydratedPersistantString, useKeyedHydratedPersistantArray, useKeyedHydratedPersistantObject, } from "@/hooks/useHydrated" -import { Dispatch } from "@/types" -import useAccountPropertyView from "../ViewSelector/useAccountPropertyView" -import { ProfileSummary } from "../ViewSelector/useViewSelector" - -export enum QueryParam { - Account = "a", - Property = "b", - View = "c", - ShowSegmentDefinitions = "d", - ViewID = "ids", - StartDate = "start-date", - EndDate = "end-date", - SelectedMetrics = "metrics", - SelectedDimensions = "dimensions", - Sort = "sort", - Filters = "filters", - Segment = "segment", - SamplingLevel = "samplingLevel", - StartIndex = "start-index", - MaxResults = "max-results", - IncludeEmptyRows = "include-empty-rows", -} - -export const useInputs = () => { - const [viewID, setViewID] = useHydratedPersistantString( - StorageKey.queryExplorerViewID, - QueryParam.ViewID - ) - - const onSetView = React.useCallback( - (view: ProfileSummary | undefined) => { - if (view === undefined) { - return - } - setViewID(`ga:${view.id}`) - }, - [setViewID] - ) - - const accountPropertyView = useAccountPropertyView( - StorageKey.queryExplorerAPV, - QueryParam, - onSetView - ) - const { columns } = useUADimensionsAndMetrics(accountPropertyView) - +import { Dispatch, RequestStatus } from "@/types" +import { Column, Segment } from "@/types/ua" +import useUADimensionsAndMetrics from "../UAPickers/useDimensionsAndMetrics" +import { useUASegments } from "../UAPickers/useUASegments" + +export const useInputs = ( + uaDimensionsAndMetricsRequest: ReturnType, + uaSegmentsRequest: ReturnType +) => { const [startDate, setStartDate] = useHydratedPersistantString( StorageKey.queryExplorerStartDate, QueryParam.StartDate, @@ -83,14 +41,15 @@ export const useInputs = () => { QueryParam.Filters ) - const segments = useUASegments() - const getSegmentByID = React.useCallback( (id: string | undefined) => { - if (id === undefined || segments === undefined) { + if ( + id === undefined || + uaSegmentsRequest.status !== RequestStatus.Successful + ) { return undefined } - const builtInSegment = segments.find(s => s.id === id) + const builtInSegment = uaSegmentsRequest.segments.find(s => s.id === id) if (builtInSegment !== undefined) { return builtInSegment } @@ -104,7 +63,7 @@ export const useInputs = () => { type: "DYNAMIC", } }, - [segments] + [uaSegmentsRequest] ) const [segment, setSegmentID] = useKeyedHydratedPersistantObject( @@ -115,12 +74,17 @@ export const useInputs = () => { const getColumnsByIDs = React.useCallback( (ids: string[] | undefined) => { - if (columns === undefined || ids === undefined) { + if ( + uaDimensionsAndMetricsRequest.status !== RequestStatus.Successful || + ids === undefined + ) { return undefined } - return columns.filter(c => ids.includes(c.id!)) + return uaDimensionsAndMetricsRequest.columns.filter(c => + ids.includes(c.id!) + ) }, - [columns] + [uaDimensionsAndMetricsRequest] ) const [ @@ -179,10 +143,13 @@ export const useInputs = () => { const getSortByIDs = React.useCallback( (ids: string[] | undefined) => { - if (columns === undefined || ids === undefined) { + if ( + uaDimensionsAndMetricsRequest.status !== RequestStatus.Successful || + ids === undefined + ) { return undefined } - return columns + return uaDimensionsAndMetricsRequest.columns .flatMap(c => [ { ...c, sort: "ASCENDING" }, { ...c, sort: "DESCENDING" }, @@ -203,7 +170,7 @@ export const useInputs = () => { }) !== undefined ) }, - [columns] + [uaDimensionsAndMetricsRequest] ) const [sort, setSortIDs] = useKeyedHydratedPersistantArray( @@ -213,8 +180,6 @@ export const useInputs = () => { ) return { - viewID, - setViewID, sort, setSortIDs, startDate, @@ -239,8 +204,6 @@ export const useInputs = () => { setShowSegmentDefiniton, samplingValue, setSamplingValue, - accountPropertyView, - columns, } } diff --git a/src/components/QueryExplorer/usePermalink.ts b/src/components/QueryExplorer/usePermalink.ts index dd0489ba..93aec745 100644 --- a/src/components/QueryExplorer/usePermalink.ts +++ b/src/components/QueryExplorer/usePermalink.ts @@ -1,14 +1,12 @@ import { useMemo } from "react" -import { Column } from "@/api" -import { QueryParam } from "./useInputs" -import { SortableColumn } from "." -import { UASegment } from "../UAPickers" +import { QueryParam, SortableColumn } from "." import { BooleanParam } from "serialize-query-params" import { AccountSummary, ProfileSummary, WebPropertySummary, -} from "../ViewSelector/useViewSelector" +} from "../ViewSelector/useAccountPropertyView" +import { Column, Segment } from "@/types/ua" type Arg = { account: AccountSummary | undefined @@ -21,7 +19,7 @@ type Arg = { selectedDimensions: Column[] | undefined sort: SortableColumn[] | undefined filters: string | undefined - segment: UASegment | undefined + segment: Segment | undefined showSegmentDefinition: boolean | undefined startIndex: string | undefined maxResults: string | undefined diff --git a/src/components/RequestComposer/CohortRequest/index.tsx b/src/components/RequestComposer/CohortRequest/index.tsx index 4a27152c..22f426ad 100644 --- a/src/components/RequestComposer/CohortRequest/index.tsx +++ b/src/components/RequestComposer/CohortRequest/index.tsx @@ -24,8 +24,6 @@ import { SegmentPicker, V4SamplingLevelPicker, CohortSizePicker, - UAColumn, - useUADimensionsAndMetrics, } from "@/components/UAPickers" import LinkedTextField from "@/components/LinkedTextField" import LabeledCheckbox from "@/components/LabeledCheckbox" @@ -34,6 +32,15 @@ import useCohortRequestParameters from "./useCohortRequestParameters" import useCohortRequest from "./useCohortRequest" import { ReportsRequest } from "../RequestComposer" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" +import useUADimensionsAndMetrics, { + UADimensionsAndMetricsRequestCtx, +} from "@/components/UAPickers/useDimensionsAndMetrics" +import { successful } from "@/types" +import { Column } from "@/types/ua" +import { + UASegmentsRequestCtx, + useUASegments, +} from "@/components/UAPickers/useUASegments" interface CohortRequestProps { apv: UAAccountPropertyView @@ -59,7 +66,8 @@ const CohortRequest: React.FC = ({ showSegmentDefinition, setShowSegmentDefinition, ] = usePersistentBoolean(StorageKey.cohortRequestShowSegmentDefinition, false) - const { columns } = useUADimensionsAndMetrics(apv) + const uaDimensionsAndMetricsRequest = useUADimensionsAndMetrics(apv) + const segmentsRequest = useUASegments() const { viewId, setViewId, @@ -71,7 +79,11 @@ const CohortRequest: React.FC = ({ setSelectedSegmentID, samplingLevel, setSamplingLevel, - } = useCohortRequestParameters(apv, columns) + } = useCohortRequestParameters( + apv, + successful(uaDimensionsAndMetricsRequest)?.columns, + successful(segmentsRequest)?.segments + ) const requestObject = useCohortRequest({ viewId, selectedMetric, @@ -85,13 +97,15 @@ const CohortRequest: React.FC = ({ }, [requestObject, setRequestObject]) const cohortFilter = React.useCallback( - (metric: NonNullable): boolean => + (metric: NonNullable): boolean => metric?.attributes?.group === "Lifetime Value and Cohorts", [] ) return ( - <> +
= ({ storageKey={StorageKey.cohortRequestCohortSize} helperText="The size of the cohort to use in the request." /> - + + + = ({ /> {children}
- +
) } diff --git a/src/components/RequestComposer/CohortRequest/useCohortRequest.ts b/src/components/RequestComposer/CohortRequest/useCohortRequest.ts index 85a2334d..2a62f20e 100644 --- a/src/components/RequestComposer/CohortRequest/useCohortRequest.ts +++ b/src/components/RequestComposer/CohortRequest/useCohortRequest.ts @@ -1,13 +1,9 @@ import { useMemo } from "react" import moment from "moment" -import { - CohortSize, - V4SamplingLevel, - UASegment, - UAColumn, -} from "@/components/UAPickers" +import { CohortSize, V4SamplingLevel } from "@/components/UAPickers" import { ReportsRequest, ReportRequest } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" type Cohort = gapi.client.analyticsreporting.Cohort @@ -101,8 +97,8 @@ const useCohortRequest = ({ samplingLevel, }: { viewId: string - selectedMetric: UAColumn | undefined - selectedSegment: UASegment | undefined + selectedMetric: Column | undefined + selectedSegment: Segment | undefined cohortSize: CohortSize | undefined samplingLevel: V4SamplingLevel | undefined }) => { diff --git a/src/components/RequestComposer/CohortRequest/useCohortRequestParameters.ts b/src/components/RequestComposer/CohortRequest/useCohortRequestParameters.ts index eafdab49..925a01ab 100644 --- a/src/components/RequestComposer/CohortRequest/useCohortRequestParameters.ts +++ b/src/components/RequestComposer/CohortRequest/useCohortRequestParameters.ts @@ -1,20 +1,16 @@ import { useState, useMemo, useCallback } from "react" -import { - V4SamplingLevel, - CohortSize, - UASegment, - UAColumn, - useUASegments, -} from "@/components/UAPickers" +import { V4SamplingLevel, CohortSize } from "@/components/UAPickers" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" import { useKeyedHydratedPersistantObject } from "@/hooks/useHydrated" import { StorageKey } from "@/constants" import { QueryParam } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" const useCohortRequestParameters = ( apv: UAAccountPropertyView, - columns: UAColumn[] | undefined + columns: Column[] | undefined, + segments: Segment[] | undefined ) => { const [viewId, setViewId] = useState("") @@ -31,7 +27,7 @@ const useCohortRequestParameters = ( const [ selectedMetric, setSelectedMetricID, - ] = useKeyedHydratedPersistantObject( + ] = useKeyedHydratedPersistantObject( StorageKey.requestComposerCohortMetric, QueryParam.Metric, getColumnByID @@ -39,7 +35,6 @@ const useCohortRequestParameters = ( const [samplingLevel, setSamplingLevel] = useState() - const segments = useUASegments() const getSegmentByID = useCallback( (id: string | undefined) => { if (id === undefined || segments === undefined) { @@ -53,7 +48,7 @@ const useCohortRequestParameters = ( const [ selectedSegment, setSelectedSegmentID, - ] = useKeyedHydratedPersistantObject( + ] = useKeyedHydratedPersistantObject( StorageKey.requestComposerCohortSegment, QueryParam.Segment, getSegmentByID diff --git a/src/components/RequestComposer/HistogramRequest/index.tsx b/src/components/RequestComposer/HistogramRequest/index.tsx index cecb51f9..fff73cbe 100644 --- a/src/components/RequestComposer/HistogramRequest/index.tsx +++ b/src/components/RequestComposer/HistogramRequest/index.tsx @@ -26,13 +26,20 @@ import { DimensionsPicker, SegmentPicker, V4SamplingLevelPicker, - useUADimensionsAndMetrics, } from "@/components/UAPickers" import useHistogramRequest from "./useHistogramRequest" import useHistogramRequestParameters from "./useHistogramRequestParameters" import { ReportsRequest } from "../RequestComposer" import LabeledCheckbox from "@/components/LabeledCheckbox" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" +import { successful } from "@/types" +import useUADimensionsAndMetrics, { + UADimensionsAndMetricsRequestCtx, +} from "@/components/UAPickers/useDimensionsAndMetrics" +import { + UASegmentsRequestCtx, + useUASegments, +} from "@/components/UAPickers/useUASegments" export const linkFor = (hash: string) => `https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#${hash}` @@ -66,7 +73,8 @@ const HistogramRequest: React.FC = ({ StorageKey.histogramRequestShowSegmentDefinition, false ) - const { columns, dimensions, metrics } = useUADimensionsAndMetrics(apv) + const uaDimensionsAndMetricsRequest = useUADimensionsAndMetrics(apv) + const segmentsRequest = useUASegments() const { viewId, setViewId, @@ -86,7 +94,11 @@ const HistogramRequest: React.FC = ({ setSelectedSegmentID, samplingLevel, setSamplingLevel, - } = useHistogramRequestParameters(apv, columns) + } = useHistogramRequestParameters( + apv, + successful(uaDimensionsAndMetricsRequest)?.columns, + successful(segmentsRequest)?.segments + ) const requestObject = useHistogramRequest({ viewId, startDate, @@ -104,7 +116,9 @@ const HistogramRequest: React.FC = ({ }, [requestObject, setRequestObject]) return ( - <> +
= ({ onChange={setFiltersExpression} helperText="Filters that restrict the data returned for the histogram request." /> - + + + = ({ /> {children}
- +
) } diff --git a/src/components/RequestComposer/HistogramRequest/useHistogramRequest.ts b/src/components/RequestComposer/HistogramRequest/useHistogramRequest.ts index 293533c6..6e88647a 100644 --- a/src/components/RequestComposer/HistogramRequest/useHistogramRequest.ts +++ b/src/components/RequestComposer/HistogramRequest/useHistogramRequest.ts @@ -1,17 +1,18 @@ import { useMemo } from "react" -import { UAColumn, UASegment, V4SamplingLevel } from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" import { ReportsRequest, ReportRequest } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" interface Parameters { - selectedMetrics: UAColumn[] | undefined - selectedDimensions: UAColumn[] | undefined + selectedMetrics: Column[] | undefined + selectedDimensions: Column[] | undefined buckets: string | undefined viewId: string startDate: string | undefined endDate: string | undefined filtersExpression: string | undefined - selectedSegment: UASegment | undefined + selectedSegment: Segment | undefined samplingLevel: V4SamplingLevel | undefined } diff --git a/src/components/RequestComposer/HistogramRequest/useHistogramRequestParameters.ts b/src/components/RequestComposer/HistogramRequest/useHistogramRequestParameters.ts index d03b3740..95a3bdbb 100644 --- a/src/components/RequestComposer/HistogramRequest/useHistogramRequestParameters.ts +++ b/src/components/RequestComposer/HistogramRequest/useHistogramRequestParameters.ts @@ -2,22 +2,19 @@ import { useState, useMemo, useCallback } from "react" import { usePersistentString } from "@/hooks" import { StorageKey } from "@/constants" -import { - V4SamplingLevel, - UASegment, - useUASegments, -} from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" import { useKeyedHydratedPersistantArray, useKeyedHydratedPersistantObject, } from "@/hooks/useHydrated" -import { Column } from "@/api" import { QueryParam } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" const useHistogramRequestParameters = ( apv: UAAccountPropertyView, - columns: Column[] | undefined + columns: Column[] | undefined, + segments: Segment[] | undefined ) => { const [viewId, setViewId] = useState("") @@ -63,7 +60,6 @@ const useHistogramRequestParameters = ( "ga:browser=~^Chrome" ) - const segments = useUASegments() const getSegmentByID = useCallback( (id: string | undefined) => { if (id === undefined || segments === undefined) { @@ -77,7 +73,7 @@ const useHistogramRequestParameters = ( const [ selectedSegment, setSelectedSegmentID, - ] = useKeyedHydratedPersistantObject( + ] = useKeyedHydratedPersistantObject( StorageKey.requestComposerHistogramSegment, QueryParam.Segment, getSegmentByID diff --git a/src/components/RequestComposer/MetricExpression/index.tsx b/src/components/RequestComposer/MetricExpression/index.tsx index 7cc1c062..4dd598b7 100644 --- a/src/components/RequestComposer/MetricExpression/index.tsx +++ b/src/components/RequestComposer/MetricExpression/index.tsx @@ -25,7 +25,6 @@ import LabeledCheckbox from "@/components/LabeledCheckbox" import { DimensionsPicker, SegmentPicker, - useUADimensionsAndMetrics, V4SamplingLevelPicker, } from "@/components/UAPickers" import { linkFor, titleFor } from "../HistogramRequest/" @@ -33,6 +32,14 @@ import useMetricExpressionRequestParameters from "./useMetricExpressionRequestPa import useMetricExpressionRequest from "./useMetricExpressionRequest" import { ReportsRequest } from "../RequestComposer" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" +import useUADimensionsAndMetrics, { + UADimensionsAndMetricsRequestCtx, +} from "@/components/UAPickers/useDimensionsAndMetrics" +import { successful } from "@/types" +import { + UASegmentsRequestCtx, + useUASegments, +} from "@/components/UAPickers/useUASegments" interface MetricExpressionRequestProps { apv: UAAccountPropertyView @@ -61,7 +68,8 @@ const MetricExpression: React.FC = ({ StorageKey.metricExpressionRequestShowSegmentDefinition, false ) - const { columns } = useUADimensionsAndMetrics(apv) + const uaDimensionsAndMetricsRequest = useUADimensionsAndMetrics(apv) + const segmentsRequest = useUASegments() const { viewId, setViewId, @@ -85,7 +93,11 @@ const MetricExpression: React.FC = ({ setPageSize, pageToken, setPageToken, - } = useMetricExpressionRequestParameters(apv, columns) + } = useMetricExpressionRequestParameters( + apv, + successful(uaDimensionsAndMetricsRequest)?.columns, + successful(segmentsRequest)?.segments + ) const requestObject = useMetricExpressionRequest({ viewId, samplingLevel, @@ -105,7 +117,9 @@ const MetricExpression: React.FC = ({ }, [requestObject, setRequestObject]) return ( - <> +
= ({ onChange={setFiltersExpression} helperText="Filters that restrict the data returned for the metric expression request." /> - + + + = ({ /> {children}
- +
) } diff --git a/src/components/RequestComposer/MetricExpression/useMetricExpressionRequest.ts b/src/components/RequestComposer/MetricExpression/useMetricExpressionRequest.ts index 240ed8ad..5991baf1 100644 --- a/src/components/RequestComposer/MetricExpression/useMetricExpressionRequest.ts +++ b/src/components/RequestComposer/MetricExpression/useMetricExpressionRequest.ts @@ -1,7 +1,8 @@ import { useMemo } from "react" -import { V4SamplingLevel, UASegment, UAColumn } from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" import { ReportsRequest, ReportRequest } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" interface Parameters { viewId: string @@ -11,8 +12,8 @@ interface Parameters { endDate: string | undefined metricExpressions: string | undefined metricAliases: string | undefined - selectedDimensions: UAColumn[] | undefined - selectedSegment: UASegment | undefined + selectedDimensions: Column[] | undefined + selectedSegment: Segment | undefined pageToken: string | undefined pageSize: string | undefined } diff --git a/src/components/RequestComposer/MetricExpression/useMetricExpressionRequestParameters.ts b/src/components/RequestComposer/MetricExpression/useMetricExpressionRequestParameters.ts index cf25515f..4e78f62c 100644 --- a/src/components/RequestComposer/MetricExpression/useMetricExpressionRequestParameters.ts +++ b/src/components/RequestComposer/MetricExpression/useMetricExpressionRequestParameters.ts @@ -2,22 +2,19 @@ import { useState, useMemo, useCallback } from "react" import { usePersistentString } from "@/hooks" import { StorageKey } from "@/constants" -import { - V4SamplingLevel, - UASegment, - UAColumn, - useUASegments, -} from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" import { useKeyedHydratedPersistantArray, useKeyedHydratedPersistantObject, } from "@/hooks/useHydrated" import { QueryParam } from "../RequestComposer" +import { Column, Segment } from "@/types/ua" const useMetricExpressionRequestParameters = ( apv: UAAccountPropertyView, - columns: UAColumn[] | undefined + columns: Column[] | undefined, + segments: Segment[] | undefined ) => { const [viewId, setViewId] = useState("") const [startDate, setStartDate] = usePersistentString( @@ -50,7 +47,7 @@ const useMetricExpressionRequestParameters = ( const [ selectedDimensions, setSelectedDimensionIDs, - ] = useKeyedHydratedPersistantArray( + ] = useKeyedHydratedPersistantArray( StorageKey.requestComposerMetricExpressionDimensions, QueryParam.Dimensions, getColumnsByIDs @@ -61,7 +58,6 @@ const useMetricExpressionRequestParameters = ( "" ) - const segments = useUASegments() const getSegmentByID = useCallback( (id: string | undefined) => { if (id === undefined || segments === undefined) { @@ -75,7 +71,7 @@ const useMetricExpressionRequestParameters = ( const [ selectedSegment, setSelectedSegmentID, - ] = useKeyedHydratedPersistantObject( + ] = useKeyedHydratedPersistantObject( StorageKey.requestComposerMetricExpressionSegment, QueryParam.Segment, getSegmentByID diff --git a/src/components/RequestComposer/PivotRequest/index.tsx b/src/components/RequestComposer/PivotRequest/index.tsx index 7fc0511c..c3c4da3a 100644 --- a/src/components/RequestComposer/PivotRequest/index.tsx +++ b/src/components/RequestComposer/PivotRequest/index.tsx @@ -27,13 +27,20 @@ import { DimensionsPicker, SegmentPicker, V4SamplingLevelPicker, - useUADimensionsAndMetrics, } from "@/components/UAPickers" import { ReportsRequest } from "../RequestComposer" import { linkFor, titleFor } from "../HistogramRequest" import usePivotRequestParameters from "./usePivotRequestParameters" import usePivotRequest from "./usePivotRequest" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" +import useUADimensionsAndMetrics, { + UADimensionsAndMetricsRequestCtx, +} from "@/components/UAPickers/useDimensionsAndMetrics" +import { successful } from "@/types" +import { + UASegmentsRequestCtx, + useUASegments, +} from "@/components/UAPickers/useUASegments" interface PivotRequestProps { apv: UAAccountPropertyView @@ -60,7 +67,8 @@ const PivotRequest: React.FC = ({ setShowSegmentDefinition, ] = usePersistentBoolean(StorageKey.pivotRequestShowSegmentDefinition, false) - const { columns } = useUADimensionsAndMetrics(apv) + const uaDimensionsAndMetricsRequest = useUADimensionsAndMetrics(apv) + const segmentsRequest = useUASegments() const { viewId, @@ -91,7 +99,11 @@ const PivotRequest: React.FC = ({ setPageToken, pageSize, setPageSize, - } = usePivotRequestParameters(apv, columns) + } = usePivotRequestParameters( + apv, + successful(uaDimensionsAndMetricsRequest)?.columns, + successful(segmentsRequest)?.segments + ) const requestObject = usePivotRequest({ viewId, startDate, @@ -114,7 +126,9 @@ const PivotRequest: React.FC = ({ }, [requestObject, setRequestObject]) return ( - <> +
= ({ onChange={setMaxGroupCount} helperText="The maximum number of groups to return." /> - + + + = ({
{children}
- +
) } diff --git a/src/components/RequestComposer/PivotRequest/usePivotRequest.ts b/src/components/RequestComposer/PivotRequest/usePivotRequest.ts index 9a8085a9..ce0e46ee 100644 --- a/src/components/RequestComposer/PivotRequest/usePivotRequest.ts +++ b/src/components/RequestComposer/PivotRequest/usePivotRequest.ts @@ -1,6 +1,7 @@ import { useMemo } from "react" -import { V4SamplingLevel, UASegment, UAColumn } from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" +import { Column, Segment } from "@/types/ua" type ReportRequest = gapi.client.analyticsreporting.ReportRequest type Request = { reportRequests: Array } @@ -9,13 +10,13 @@ interface Parameters { viewId: string | undefined startDate: string | undefined endDate: string | undefined - metrics: UAColumn[] | undefined - pivotMetrics: UAColumn[] | undefined - dimensions: UAColumn[] | undefined - pivotDimensions: UAColumn[] | undefined + metrics: Column[] | undefined + pivotMetrics: Column[] | undefined + dimensions: Column[] | undefined + pivotDimensions: Column[] | undefined startGroup: string | undefined maxGroupCount: string | undefined - selectedSegment: UASegment | undefined + selectedSegment: Segment | undefined samplingLevel: V4SamplingLevel | undefined pageToken: string | undefined pageSize: string | undefined diff --git a/src/components/RequestComposer/PivotRequest/usePivotRequestParameters.ts b/src/components/RequestComposer/PivotRequest/usePivotRequestParameters.ts index bdfa37e8..b7c3ed3c 100644 --- a/src/components/RequestComposer/PivotRequest/usePivotRequestParameters.ts +++ b/src/components/RequestComposer/PivotRequest/usePivotRequestParameters.ts @@ -2,22 +2,19 @@ import { useState, useMemo, useCallback } from "react" import { usePersistentString, usePersistentBoolean } from "@/hooks" import { StorageKey } from "@/constants" -import { - V4SamplingLevel, - UAColumn, - UASegment, - useUASegments, -} from "@/components/UAPickers" +import { V4SamplingLevel } from "@/components/UAPickers" import { UAAccountPropertyView } from "@/components/ViewSelector/useAccountPropertyView" import { QueryParam } from "../RequestComposer" import { useKeyedHydratedPersistantArray, useKeyedHydratedPersistantObject, } from "@/hooks/useHydrated" +import { Column, Segment } from "@/types/ua" const usePivotRequestParameters = ( apv: UAAccountPropertyView, - columns: UAColumn[] | undefined + columns: Column[] | undefined, + segments: Segment[] | undefined ) => { const [viewId, setViewId] = useState("") const [startDate, setStartDate] = usePersistentString( @@ -41,7 +38,7 @@ const usePivotRequestParameters = ( const [ selectedDimensions, setSelectedDimensionIDs, - ] = useKeyedHydratedPersistantArray( + ] = useKeyedHydratedPersistantArray( StorageKey.requestComposerPivotDimensions, QueryParam.Dimensions, getColumnsByIDs @@ -49,7 +46,7 @@ const usePivotRequestParameters = ( const [ selectedMetrics, setSelectedMetricIDs, - ] = useKeyedHydratedPersistantArray( + ] = useKeyedHydratedPersistantArray( StorageKey.requestComposerPivotMetrics, QueryParam.Metrics, getColumnsByIDs @@ -57,7 +54,7 @@ const usePivotRequestParameters = ( const [ pivotMetrics, setPivotMetricIDs, - ] = useKeyedHydratedPersistantArray( + ] = useKeyedHydratedPersistantArray( StorageKey.requestComposerPivotPivotMetrics, QueryParam.PivotMetrics, getColumnsByIDs @@ -65,7 +62,7 @@ const usePivotRequestParameters = ( const [ pivotDimensions, setPivotDimensionIDs, - ] = useKeyedHydratedPersistantArray( + ] = useKeyedHydratedPersistantArray( StorageKey.requestComposerPivotPivotDimensions, QueryParam.PivotDimensions, getColumnsByIDs @@ -77,7 +74,6 @@ const usePivotRequestParameters = ( StorageKey.pivotRequestMaxGroupCount ) - const segments = useUASegments() const getSegmentByID = useCallback( (id: string | undefined) => { if (id === undefined || segments === undefined) { @@ -91,7 +87,7 @@ const usePivotRequestParameters = ( const [ selectedSegment, setSelectedSegmentID, - ] = useKeyedHydratedPersistantObject( + ] = useKeyedHydratedPersistantObject( StorageKey.requestComposerPivotSegment, QueryParam.Segment, getSegmentByID diff --git a/src/components/ShortenLink/index.tsx b/src/components/ShortenLink/index.tsx index bf8f6884..8eaa0160 100644 --- a/src/components/ShortenLink/index.tsx +++ b/src/components/ShortenLink/index.tsx @@ -10,9 +10,11 @@ interface ShortenLinkProps { pab?: boolean sab?: boolean medium?: boolean + disabled?: boolean } const ShortenLink: React.FC = ({ + disabled, children = "Shorten link", pab, sab, @@ -50,7 +52,7 @@ const ShortenLink: React.FC = ({ small={!medium} medium={medium} onClick={onClick} - disabled={url === undefined} + disabled={url === undefined || disabled} > {children} diff --git a/src/components/UAPickers.tsx b/src/components/UAPickers/index.tsx similarity index 52% rename from src/components/UAPickers.tsx rename to src/components/UAPickers/index.tsx index aed3e7c9..ec12af25 100644 --- a/src/components/UAPickers.tsx +++ b/src/components/UAPickers/index.tsx @@ -18,18 +18,13 @@ import { Typography, TextField, makeStyles } from "@material-ui/core" import Autocomplete, { createFilterOptions, } from "@material-ui/lab/Autocomplete" -import { usePersistentString } from "../hooks" -import { StorageKey } from "../constants" -import { useSelector } from "react-redux" -import { AccountSummary, Column as ColumnT, WebPropertySummary } from "@/api" -import { ProfileSummary } from "./ViewSelector/useViewSelector" -import { Dispatch } from "@/types" -import useCached from "@/hooks/useCached" -import moment from "moment" -import { UAAccountPropertyView } from "./ViewSelector/useAccountPropertyView" -import { useMemo } from "react" +import { usePersistentString } from "@/hooks" +import { StorageKey } from "@/constants" +import { Column as ColumnT, Segment as SegmentT } from "@/types/ua" +import { Dispatch, RequestStatus, successful } from "@/types" +import { UADimensionsAndMetricsRequestCtx } from "./useDimensionsAndMetrics" +import { UASegmentsRequestCtx } from "./useUASegments" -export type UASegment = gapi.client.analytics.Segment export enum V4SamplingLevel { Default = "DEFAULT", SMALL = "SMALL", @@ -46,173 +41,9 @@ export enum CohortSize { Month = "MONTH", } -export type UAColumn = gapi.client.analytics.Column -type MetadataAPI = typeof gapi.client.analytics.metadata -type ManagementAPI = typeof gapi.client.analytics.management - const removeDeprecatedColumns = (column: ColumnT): true | false => column.attributes?.status !== "DEPRECATED" -export const useUADimensionsAndMetrics = ({ - account, - property, - view, -}: UAAccountPropertyView): { - dimensions: UAColumn[] | undefined - metrics: UAColumn[] | undefined - columns: UAColumn[] | undefined -} => { - const gapi = useSelector((state: AppState) => state.gapi) - - const metadataAPI = React.useMemo(() => { - return gapi?.client.analytics.metadata as any - }, [gapi]) - - const managementAPI = React.useMemo(() => { - return gapi?.client.analytics.management as any - }, [gapi]) - - const requestReady = React.useMemo(() => { - if ( - metadataAPI === undefined || - managementAPI === undefined || - account === undefined || - property === undefined || - view === undefined - ) { - return false - } - return true - }, [metadataAPI, managementAPI, account, property, view]) - - const makeRequest = React.useCallback(async () => { - if ( - metadataAPI === undefined || - managementAPI === undefined || - account === undefined || - property === undefined || - view === undefined - ) { - return Promise.resolve(undefined) - } - const columnsResponse = await metadataAPI.columns.list({ reportType: "ga" }) - const columns = columnsResponse.result.items - - const [ - customDimensionsResponse, - customMetricsResponse, - goalsResponse, - ] = await Promise.all([ - managementAPI.customDimensions.list({ - accountId: account.id!, - webPropertyId: property.id!, - }), - managementAPI.customMetrics.list({ - accountId: account.id!, - webPropertyId: property.id!, - }), - managementAPI.goals.list({ - accountId: account.id!, - webPropertyId: property.id!, - profileId: view.id!, - }), - ]) - - const customDimensions = customDimensionsResponse.result.items - const customMetrics = customMetricsResponse.result.items - const goals = goalsResponse.result.items - - return columns?.flatMap(column => { - if (customDimensions !== undefined && column.id === "ga:dimensionXX") { - return customDimensions.map( - dimension => - ({ - ...column, - id: dimension.id, - attributes: { ...column.attributes, uiName: dimension.name }, - } as ColumnT) - ) - } - if (customMetrics !== undefined && column.id === "ga:metricXX") { - return customMetrics.map( - metric => - ({ - ...column, - id: metric.id, - attributes: { ...column.attributes, uiName: metric.name }, - } as ColumnT) - ) - } - if ( - goals !== undefined && - column.attributes!.minTemplateIndex !== undefined && - /goalxx/i.test(column.id!) - ) { - return goals.map(goal => ({ - ...column, - id: column.id!.replace("XX", goal.id!), - attributes: { - ...column.attributes, - uiName: `${goal.name} (${column.attributes!.uiName.replace( - "XX", - goal.id! - )})`, - }, - })) - } - if (column.attributes!.minTemplateIndex !== undefined) { - let min = 0 - let max = 0 - if ( - property?.level === "PREMIUM" && - column.attributes!.premiumMinTemplateIndex !== undefined - ) { - min = parseInt(column.attributes!.premiumMinTemplateIndex, 10) - max = parseInt(column.attributes!.premiumMaxTemplateIndex, 10) - } else { - min = parseInt(column.attributes!.minTemplateIndex, 10) - max = parseInt(column.attributes!.maxTemplateIndex, 10) - } - const columns: gapi.client.analytics.Column[] = [] - for (let i = min; i <= max; i++) { - columns.push({ - ...column, - id: column.id!.replace("XX", i.toString()), - attributes: { - ...column.attributes, - uiName: column.attributes!.uiName.replace("XX", i.toString()), - }, - }) - } - return columns - } - return [column] - }) - }, [metadataAPI, managementAPI, account, property, view]) - - const { value: columns } = useCached( - // Even though account is sometimes undefined it doesn't really matter - // since this hook will re-run once it is. makeRequest is smartEnough to - // not do anything when account property or view are undefined. - `//ua-dims-mets/${account?.id}-${property?.id}-${view?.id}` as StorageKey, - makeRequest, - moment.duration(5, "minutes"), - requestReady - ) - - const dimensions = React.useMemo( - () => columns?.filter(column => column.attributes?.type === "DIMENSION"), - [columns] - ) - - const metrics = React.useMemo( - () => columns?.filter(column => column.attributes?.type === "METRIC"), - [columns] - ) - - return { dimensions, metrics, columns } -} - const useColumnStyles = makeStyles(() => ({ option: { display: "flex", @@ -225,7 +56,7 @@ const useColumnStyles = makeStyles(() => ({ }, })) -const Column: React.FC<{ column: UAColumn }> = ({ column }) => { +const Column: React.FC<{ column: ColumnT }> = ({ column }) => { const classes = useColumnStyles() return (
@@ -245,39 +76,27 @@ const Column: React.FC<{ column: UAColumn }> = ({ column }) => { } export const DimensionPicker: React.FC<{ - selectedDimension: UAColumn | undefined + selectedDimension: ColumnT | undefined setDimensionID: Dispatch required?: true | undefined helperText?: string - account: AccountSummary | undefined - property: WebPropertySummary | undefined - view: ProfileSummary | undefined -}> = ({ - helperText, - selectedDimension, - setDimensionID, - required, - account, - property, - view, -}) => { - const { dimensions } = useUADimensionsAndMetrics({ account, property, view }) - const dimensionOptions = React.useMemo( - () => dimensions?.filter(removeDeprecatedColumns), - [dimensions] - ) +}> = ({ helperText, selectedDimension, setDimensionID, required }) => { + const request = React.useContext(UADimensionsAndMetricsRequestCtx) return ( - + fullWidth autoComplete autoHighlight freeSolo - options={dimensionOptions || []} + loading={request.status === RequestStatus.InProgress} + options={ + successful(request)?.dimensions.filter(removeDeprecatedColumns) || [] + } getOptionLabel={dimension => dimension.id!} value={selectedDimension || null} onChange={(_event, value) => - setDimensionID(value === null ? undefined : (value as UAColumn).id) + setDimensionID(value === null ? undefined : (value as ColumnT).id) } renderOption={column => } renderInput={params => ( @@ -295,49 +114,41 @@ export const DimensionPicker: React.FC<{ } export const DimensionsPicker: React.FC<{ - selectedDimensions: UAColumn[] | undefined + selectedDimensions: ColumnT[] | undefined setDimensionIDs: Dispatch required?: true | undefined helperText?: string label?: string - account: AccountSummary | undefined - property: WebPropertySummary | undefined - view: ProfileSummary | undefined }> = ({ helperText, selectedDimensions, setDimensionIDs, required, - account, - property, - view, label = "dimensions", }) => { - const { dimensions } = useUADimensionsAndMetrics({ account, property, view }) - const dimensionOptions = React.useMemo( - () => - dimensions - ?.filter(removeDeprecatedColumns) - ?.filter( - option => - (selectedDimensions || []).find(s => s.id === option.id) === - undefined - ), - [dimensions, selectedDimensions] - ) + const request = React.useContext(UADimensionsAndMetricsRequestCtx) return ( - + fullWidth autoComplete autoHighlight freeSolo multiple - options={dimensionOptions || []} + loading={request.status === RequestStatus.InProgress} + options={ + successful(request) + ?.dimensions.filter(removeDeprecatedColumns) + .filter( + option => + (selectedDimensions || []).find(s => s.id === option.id) === + undefined + ) || [] + } getOptionLabel={dimension => dimension.id!} value={selectedDimensions || []} onChange={(_event, value) => - setDimensionIDs((value as UAColumn[])?.map(c => c.id!)) + setDimensionIDs((value as ColumnT[])?.map(c => c.id!)) } renderOption={column => } renderInput={params => ( @@ -355,44 +166,30 @@ export const DimensionsPicker: React.FC<{ } export const MetricPicker: React.FC<{ - selectedMetric: UAColumn | undefined + selectedMetric: ColumnT | undefined setMetricID: Dispatch required?: true | undefined helperText?: string - account: AccountSummary | undefined - property: WebPropertySummary | undefined - view: ProfileSummary | undefined - filter?: (metric: UAColumn) => boolean -}> = ({ - helperText, - selectedMetric, - setMetricID, - required, - filter, - account, - property, - view, -}) => { - const { metrics } = useUADimensionsAndMetrics({ account, property, view }) - const metricOptions = React.useMemo( - () => - metrics - ?.filter(removeDeprecatedColumns) - ?.filter(filter !== undefined ? filter : () => true), - [metrics, filter] - ) + filter?: (metric: ColumnT) => boolean +}> = ({ helperText, selectedMetric, setMetricID, required, filter }) => { + const request = React.useContext(UADimensionsAndMetricsRequestCtx) return ( - + fullWidth autoComplete autoHighlight freeSolo - options={metricOptions || []} + loading={request.status === RequestStatus.InProgress} + options={ + successful(request) + ?.metrics.filter(removeDeprecatedColumns) + .filter(filter !== undefined ? filter : () => true) || [] + } getOptionLabel={metric => metric.id!} value={selectedMetric || null} onChange={(_event, value) => - setMetricID(value === null ? undefined : (value as UAColumn).id) + setMetricID(value === null ? undefined : (value as ColumnT).id) } renderOption={column => } renderInput={params => ( @@ -415,43 +212,36 @@ export const MetricsPicker: React.FC<{ required?: true | undefined helperText?: string label?: string - account: AccountSummary | undefined - property: WebPropertySummary | undefined - view: ProfileSummary | undefined }> = ({ helperText, selectedMetrics, setMetricIDs, required, label = "metrics", - account, - property, - view, }) => { - const { metrics } = useUADimensionsAndMetrics({ account, property, view }) - const metricOptions = React.useMemo( - () => - metrics - ?.filter(removeDeprecatedColumns) - ?.filter( - option => - (selectedMetrics || []).find(s => s.id === option.id) === undefined - ), - [metrics, selectedMetrics] - ) + const request = React.useContext(UADimensionsAndMetricsRequestCtx) return ( - + fullWidth autoComplete autoHighlight freeSolo multiple - options={metricOptions || []} + loading={request.status === RequestStatus.InProgress} + options={ + successful(request) + ?.metrics.filter(removeDeprecatedColumns) + .filter( + option => + (selectedMetrics || []).find(s => s.id === option.id) === + undefined + ) || [] + } getOptionLabel={metric => metric.id!} value={selectedMetrics || []} onChange={(_event, value) => - setMetricIDs((value as UAColumn[])?.map(c => c.id!)) + setMetricIDs((value as ColumnT[])?.map(c => c.id!)) } renderOption={column => } renderInput={params => ( @@ -468,36 +258,8 @@ export const MetricsPicker: React.FC<{ ) } -export const useUASegments = (): UASegment[] | undefined => { - const gapi = useSelector((state: AppState) => state.gapi) - const managementAPI = React.useMemo(() => { - return gapi?.client.analytics.management as any - }, [gapi]) - - const requestSegments = React.useCallback(async () => { - if (managementAPI === undefined) { - return Promise.resolve(undefined) - } - const response = await managementAPI.segments.list({}) - return response.result.items - }, [managementAPI]) - - const requestReady = useMemo(() => { - return managementAPI !== undefined - }, [managementAPI]) - - const { value: segments } = useCached( - StorageKey.uaSegments, - requestSegments, - moment.duration(5, "minutes"), - requestReady - ) - - return segments -} - const Segment: React.FC<{ - segment: UASegment + segment: SegmentT showSegmentDefinition: boolean }> = ({ segment, showSegmentDefinition }) => { const classes = useColumnStyles() @@ -529,9 +291,9 @@ const Segment: React.FC<{ ) } -const filter = createFilterOptions() +const filter = createFilterOptions() export const SegmentPicker: React.FC<{ - segment: UASegment | undefined + segment: SegmentT | undefined setSegmentID: Dispatch showSegmentDefinition: boolean required?: true | undefined @@ -543,10 +305,10 @@ export const SegmentPicker: React.FC<{ required, showSegmentDefinition, }) => { - const segments = useUASegments() + const request = React.useContext(UASegmentsRequestCtx) const getOptionLabel = React.useCallback( - (segment: UASegment) => + (segment: SegmentT) => (showSegmentDefinition ? segment.definition || (segment.name && `${segment.name} has no segment definiton.`) @@ -555,12 +317,13 @@ export const SegmentPicker: React.FC<{ ) return ( - + fullWidth autoComplete autoHighlight freeSolo - options={segments || []} + loading={request.status === RequestStatus.InProgress} + options={successful(request)?.segments || []} getOptionSelected={(a, b) => a.id === b.id || a.definition === b.definition } @@ -582,7 +345,7 @@ export const SegmentPicker: React.FC<{ return filtered }} onChange={(_event, value) => { - setSegmentID(value === null ? undefined : (value as UASegment).id) + setSegmentID(value === null ? undefined : (value as SegmentT).id) }} renderOption={column => ( +>({ status: RequestStatus.NotStarted }) + +interface Successful { + columns: Column[] + metrics: Column[] + dimensions: Column[] +} + +const useUADimensionsAndMetrics = ({ + account, + property, + view, +}: UAAccountPropertyView): Requestable => { + const gapi = useSelector((state: AppState) => state.gapi) + + const metadataAPI = useMemo(() => gapi?.client.analytics.metadata, [gapi]) + const managementAPI = useMemo(() => gapi?.client.analytics.management, [gapi]) + + const requestReady = useMemo(() => { + if ( + metadataAPI === undefined || + managementAPI === undefined || + account === undefined || + property === undefined || + view === undefined + ) { + return false + } + return true + }, [metadataAPI, managementAPI, account, property, view]) + + const makeRequest = useCallback(async () => { + if ( + metadataAPI === undefined || + managementAPI === undefined || + account === undefined || + property === undefined || + view === undefined + ) { + throw new Error( + "Invalid invariant - metadataAPI, managementAPI, account, property, and view must be defined here." + ) + } + const columnsResponse = await metadataAPI.columns.list({ reportType: "ga" }) + const columns = columnsResponse.result.items + + const [ + customDimensionsResponse, + customMetricsResponse, + goalsResponse, + ] = await Promise.all([ + managementAPI.customDimensions.list({ + accountId: account.id!, + webPropertyId: property.id!, + }), + managementAPI.customMetrics.list({ + accountId: account.id!, + webPropertyId: property.id!, + }), + managementAPI.goals.list({ + accountId: account.id!, + webPropertyId: property.id!, + profileId: view.id!, + }), + ]) + + const customDimensions = customDimensionsResponse.result.items + const customMetrics = customMetricsResponse.result.items + const goals = goalsResponse.result.items + + return columns?.flatMap(column => { + if (customDimensions !== undefined && column.id === "ga:dimensionXX") { + return customDimensions.map( + dimension => + ({ + ...column, + id: dimension.id, + attributes: { ...column.attributes, uiName: dimension.name }, + } as Column) + ) + } + if (customMetrics !== undefined && column.id === "ga:metricXX") { + return customMetrics.map( + metric => + ({ + ...column, + id: metric.id, + attributes: { ...column.attributes, uiName: metric.name }, + } as Column) + ) + } + if ( + goals !== undefined && + column.attributes!.minTemplateIndex !== undefined && + /goalxx/i.test(column.id!) + ) { + return goals.map(goal => ({ + ...column, + id: column.id!.replace("XX", goal.id!), + attributes: { + ...column.attributes, + uiName: `${goal.name} (${column.attributes!.uiName.replace( + "XX", + goal.id! + )})`, + }, + })) + } + if (column.attributes!.minTemplateIndex !== undefined) { + let min = 0 + let max = 0 + if ( + property?.level === "PREMIUM" && + column.attributes!.premiumMinTemplateIndex !== undefined + ) { + min = parseInt(column.attributes!.premiumMinTemplateIndex, 10) + max = parseInt(column.attributes!.premiumMaxTemplateIndex, 10) + } else { + min = parseInt(column.attributes!.minTemplateIndex, 10) + max = parseInt(column.attributes!.maxTemplateIndex, 10) + } + const columns: gapi.client.analytics.Column[] = [] + for (let i = min; i <= max; i++) { + columns.push({ + ...column, + id: column.id!.replace("XX", i.toString()), + attributes: { + ...column.attributes, + uiName: column.attributes!.uiName.replace("XX", i.toString()), + }, + }) + } + return columns + } + return [column] + }) + }, [metadataAPI, managementAPI, account, property, view]) + + const columnsRequest = useCached( + // Even though account is sometimes undefined it doesn't really matter + // since this hook will re-run once it is. makeRequest is smartEnough to + // not do anything when account property or view are undefined. + `//ua-dims-mets/${account?.id}-${property?.id}-${view?.id}` as StorageKey, + makeRequest, + moment.duration(5, "minutes"), + requestReady + ) + + return useMemo(() => { + switch (columnsRequest.status) { + case RequestStatus.Successful: { + const columns = columnsRequest.value || [] + return { + status: columnsRequest.status, + columns, + dimensions: columns.filter(c => c.attributes?.type === "DIMENSION"), + metrics: columns.filter(c => c.attributes?.type === "METRIC"), + } + } + default: + return { status: columnsRequest.status } + } + }, [columnsRequest]) +} + +export default useUADimensionsAndMetrics diff --git a/src/components/UAPickers/useUASegments.ts b/src/components/UAPickers/useUASegments.ts new file mode 100644 index 00000000..cd6b1672 --- /dev/null +++ b/src/components/UAPickers/useUASegments.ts @@ -0,0 +1,53 @@ +import { Segment } from "@/types/ua" +import { createContext, useCallback, useMemo } from "react" +import { useSelector } from "react-redux" +import useCached from "@/hooks/useCached" +import { StorageKey } from "@/constants" +import moment from "moment" +import { Requestable, RequestStatus } from "@/types" + +export const UASegmentsRequestCtx = createContext< + ReturnType +>({ status: RequestStatus.NotStarted }) + +interface Successful { + segments: Segment[] +} + +export const useUASegments = (): Requestable => { + const gapi = useSelector((state: AppState) => state.gapi) + const managementAPI = useMemo(() => gapi?.client.analytics.management, [gapi]) + + const requestSegments = useCallback(async () => { + if (managementAPI === undefined) { + return Promise.resolve(undefined) + } + const response = await managementAPI.segments.list({}) + return response.result.items + }, [managementAPI]) + + const requestReady = useMemo(() => { + return managementAPI !== undefined + }, [managementAPI]) + + const segmentsRequest = useCached( + StorageKey.uaSegments, + requestSegments, + moment.duration(5, "minutes"), + requestReady + ) + + return useMemo(() => { + switch (segmentsRequest.status) { + case RequestStatus.Successful: { + const segments = segmentsRequest.value || [] + return { + status: segmentsRequest.status, + segments, + } + } + default: + return { status: segmentsRequest.status } + } + }, [segmentsRequest]) +} diff --git a/src/components/ViewSelector/index.spec.tsx b/src/components/ViewSelector/index.spec.tsx new file mode 100644 index 00000000..37b8849d --- /dev/null +++ b/src/components/ViewSelector/index.spec.tsx @@ -0,0 +1,124 @@ +// Copyright 2020 Google Inc. All rights reserved. +// +// 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 * as React from "react" + +import * as renderer from "@testing-library/react" +import { withProviders } from "@/test-utils" +import "@testing-library/jest-dom" +import Sut, { Label, TestID } from "./index" +import useAccountPropertyView from "./useAccountPropertyView" +import { StorageKey } from "@/constants" +import { fireEvent, within } from "@testing-library/react" + +enum QueryParam { + Account = "a", + Property = "b", + View = "c", +} + +describe("ViewSelector", () => { + const DefaultSut: React.FC = () => { + const apv = useAccountPropertyView("a" as StorageKey, QueryParam) + return + } + test("doesn't crash on happy path", async () => { + const { wrapped } = withProviders() + const { findByLabelText } = renderer.render(wrapped) + + const account = await findByLabelText(Label.Account) + expect(account).toBeVisible() + + const property = await findByLabelText(Label.Property) + expect(property).toBeVisible() + + const view = await findByLabelText(Label.View) + expect(view).toBeVisible() + }) + test("can select account with no properties", async () => { + const accountName = "my account name" + const listAccountSummaries = jest.fn() + listAccountSummaries.mockReturnValue( + Promise.resolve({ + result: { + items: [{ name: accountName, id: "account id", properties: [] }], + }, + }) + ) + const { wrapped } = withProviders(, { + ua: { listAccountSummaries }, + }) + const { findByTestId } = renderer.render(wrapped) + + // Select first account. + const accountAC = await findByTestId(TestID.AccountAutocomplete) + fireEvent.keyDown(accountAC, { key: "ArrowDown" }) + fireEvent.keyDown(accountAC, { key: "Enter" }) + + const accountInput = within(accountAC).getByRole("textbox") + expect(accountInput).toHaveValue(accountName) + }) + describe("with autoFill={true}", () => { + const WithAutoFill: React.FC = () => { + const apv = useAccountPropertyView("a" as StorageKey, QueryParam) + return + } + test("automatically selects account & view when both available", async () => { + const { wrapped } = withProviders() + const { findByTestId } = renderer.render(wrapped) + + // Select first account. + const accountAC = await findByTestId(TestID.AccountAutocomplete) + fireEvent.keyDown(accountAC, { key: "ArrowDown" }) + fireEvent.keyDown(accountAC, { key: "Enter" }) + + const accountInput = within(accountAC).getByRole("textbox") + expect(accountInput).toHaveValue("Account Name 1") + + const propertyAC = await findByTestId(TestID.PropertyAutocomplete) + const propertyInput = within(propertyAC).getByRole("textbox") + expect(propertyInput).toHaveValue("Property Name 1 1") + + const viewAC = await findByTestId(TestID.ViewAutocomplete) + const viewInput = within(viewAC).getByRole("textbox") + expect(viewInput).toHaveValue("View Name 1 1 1") + }) + }) + describe("with autoFill={false}", () => { + const NoAutoFill: React.FC = () => { + const apv = useAccountPropertyView("a" as StorageKey, QueryParam) + return + } + test("doesn't automatically select property or view after selecting property", async () => { + const { wrapped } = withProviders() + const { findByTestId } = renderer.render(wrapped) + + // Select first account. + const accountAC = await findByTestId(TestID.AccountAutocomplete) + fireEvent.keyDown(accountAC, { key: "ArrowDown" }) + fireEvent.keyDown(accountAC, { key: "Enter" }) + + const accountInput = within(accountAC).getByRole("textbox") + expect(accountInput).toHaveValue("Account Name 1") + + const propertyAC = await findByTestId(TestID.PropertyAutocomplete) + const propertyInput = within(propertyAC).getByRole("textbox") + expect(propertyInput).toHaveValue("") + + const viewAC = await findByTestId(TestID.ViewAutocomplete) + const viewInput = within(viewAC).getByRole("textbox") + expect(viewInput).toHaveValue("") + }) + }) +}) diff --git a/src/components/ViewSelector/index.tsx b/src/components/ViewSelector/index.tsx index 9e041a59..657afa8f 100644 --- a/src/components/ViewSelector/index.tsx +++ b/src/components/ViewSelector/index.tsx @@ -4,12 +4,13 @@ import TextField from "@material-ui/core/TextField" import Autocomplete from "@material-ui/lab/Autocomplete" import classnames from "classnames" -import { Dispatch } from "@/types" -import useViewSelector, { +import { Dispatch, RequestStatus, successful } from "@/types" +import { AccountSummary, ProfileSummary, WebPropertySummary, -} from "./useViewSelector" +} from "./useAccountPropertyView" +import useAccountSummaries from "./useAccountSummaries" const useStyles = makeStyles(theme => ({ root: props => ({ @@ -26,9 +27,9 @@ const useStyles = makeStyles(theme => ({ })) interface CommonProps { - account: AccountSummary | undefined + account?: AccountSummary setAccountID: Dispatch - property: WebPropertySummary | undefined + property?: WebPropertySummary setPropertyID: Dispatch vertical?: boolean className?: string @@ -42,13 +43,25 @@ interface OnlyProperty extends CommonProps { } interface AlsoView extends CommonProps { - view: ProfileSummary | undefined + view?: ProfileSummary setViewID: Dispatch onlyProperty?: false | undefined } type ViewSelectorProps = AlsoView | OnlyProperty +export enum Label { + Account = "account", + Property = "property", + View = "view", +} + +export enum TestID { + AccountAutocomplete = "account-autocomplete", + PropertyAutocomplete = "property-autocomplete", + ViewAutocomplete = "view-autocomplete", +} + const ViewSelector: React.FC = props => { const { autoFill, @@ -62,16 +75,19 @@ const ViewSelector: React.FC = props => { } = props const classes = useStyles(props) - const { accounts, properties, views } = useViewSelector(account, property) + const request = useAccountSummaries(account, property) return (
+ data-testid={TestID.AccountAutocomplete} blurOnSelect openOnFocus autoHighlight + noOptionsText="You don't have any Google Analytics accounts." className={classes.formControl} - options={accounts || []} + loading={request.status === RequestStatus.InProgress} + options={successful(request)?.accountSummaries || []} value={account || null} onChange={(_, a: AccountSummary | null) => { setAccountID(a?.id || undefined) @@ -91,18 +107,25 @@ const ViewSelector: React.FC = props => { renderInput={params => ( )} /> + data-testid={TestID.PropertyAutocomplete} blurOnSelect openOnFocus autoHighlight className={classes.formControl} - options={properties || []} + loading={request.status === RequestStatus.InProgress} + options={successful(request)?.propertySummaries || []} + noOptionsText={ + account === undefined + ? "Select an account to show available properties." + : "You don't have any properties for this account." + } value={property || null} onChange={(_, p: WebPropertySummary | null) => { setPropertyID(p?.id || undefined) @@ -118,7 +141,7 @@ const ViewSelector: React.FC = props => { renderInput={params => ( @@ -126,11 +149,20 @@ const ViewSelector: React.FC = props => { /> {props.onlyProperty ? null : ( + data-testid={TestID.ViewAutocomplete} blurOnSelect openOnFocus autoHighlight className={classes.formControl} - options={views || []} + loading={request.status === RequestStatus.InProgress} + options={successful(request)?.profileSummaries || []} + noOptionsText={ + account === undefined + ? "Select an account and property to show available views." + : property === undefined + ? "Select a property to show available views" + : "You don't have any views for this property." + } value={props.view || null} getOptionSelected={(a, b) => a.id === b.id} onChange={(_, v: ProfileSummary | null) => { @@ -138,7 +170,12 @@ const ViewSelector: React.FC = props => { }} getOptionLabel={view => view.name || ""} renderInput={params => ( - + )} /> )} diff --git a/src/components/ViewSelector/useAccountPropertyView.ts b/src/components/ViewSelector/useAccountPropertyView.ts index c48ead10..a33f176a 100644 --- a/src/components/ViewSelector/useAccountPropertyView.ts +++ b/src/components/ViewSelector/useAccountPropertyView.ts @@ -1,18 +1,17 @@ import { StorageKey } from "@/constants" import { useKeyedHydratedPersistantObject } from "@/hooks/useHydrated" -import { Dispatch } from "@/types" +import { Dispatch, RequestStatus } from "@/types" import { useCallback } from "react" -import useAccounts from "./useAccounts" -import { - AccountSummary, - ProfileSummary, - WebPropertySummary, -} from "./useViewSelector" +import useFlattenedViews from "./useFlattenedViews" + +export type AccountSummary = gapi.client.analytics.AccountSummary +export type WebPropertySummary = gapi.client.analytics.WebPropertySummary +export type ProfileSummary = gapi.client.analytics.ProfileSummary export interface UAAccountPropertyView { - account: AccountSummary | undefined - property: WebPropertySummary | undefined - view: ProfileSummary | undefined + account?: AccountSummary + property?: WebPropertySummary + view?: ProfileSummary } interface UAAccountPropertyViewSetters { @@ -26,16 +25,21 @@ const useAccountPropertyView = ( queryParamKeys: { Account: string; Property: string; View: string }, onSetView?: (p: ProfileSummary | undefined) => void ): UAAccountPropertyView & UAAccountPropertyViewSetters => { - const accounts = useAccounts() + const flattenedViewsRequest = useFlattenedViews() const getAccountByID = useCallback( (id: string | undefined) => { - if (accounts === undefined || id === undefined) { + if ( + flattenedViewsRequest.status !== RequestStatus.Successful || + id === undefined + ) { return undefined } - return accounts.find(a => a.id === id) + return flattenedViewsRequest.flattenedViews.find( + flattenedView => flattenedView.account?.id === id + )?.account }, - [accounts] + [flattenedViewsRequest] ) const [ @@ -49,12 +53,17 @@ const useAccountPropertyView = ( const getPropertyByID = useCallback( (id: string | undefined) => { - if (accounts === undefined || id === undefined) { + if ( + flattenedViewsRequest.status !== RequestStatus.Successful || + id === undefined + ) { return undefined } - return accounts.flatMap(a => a.webProperties || []).find(p => p.id === id) + return flattenedViewsRequest.flattenedViews.find( + flattenedView => flattenedView.property?.id === id + )?.property }, - [accounts] + [flattenedViewsRequest] ) const [ @@ -68,15 +77,17 @@ const useAccountPropertyView = ( const getViewByID = useCallback( (id: string | undefined) => { - if (accounts === undefined || id === undefined) { + if ( + flattenedViewsRequest.status !== RequestStatus.Successful || + id === undefined + ) { return undefined } - return accounts - .flatMap(a => a.webProperties || []) - .flatMap(p => p.profiles || []) - .find(p => p.id === id) + return flattenedViewsRequest.flattenedViews.find( + flattenedView => flattenedView.view?.id === id + )?.view }, - [accounts] + [flattenedViewsRequest] ) const [view, setViewID] = useKeyedHydratedPersistantObject( diff --git a/src/components/ViewSelector/useAccountSummaries.ts b/src/components/ViewSelector/useAccountSummaries.ts new file mode 100644 index 00000000..c5f272a5 --- /dev/null +++ b/src/components/ViewSelector/useAccountSummaries.ts @@ -0,0 +1,92 @@ +import { StorageKey } from "@/constants" +import useCached from "@/hooks/useCached" +import { Requestable, RequestStatus } from "@/types" +import { AccountSummary, ProfileSummary, WebPropertySummary } from "@/types/ua" +import moment from "moment" +import { useCallback, useMemo } from "react" + +import { useSelector } from "react-redux" + +interface Successful { + accountSummaries: AccountSummary[] + propertySummaries: WebPropertySummary[] + profileSummaries: ProfileSummary[] +} + +const useAccountSummaries = ( + accountSummary?: AccountSummary, + propertySummary?: WebPropertySummary +): Requestable => { + const gapi = useSelector((state: AppState) => state.gapi) + + const managementAPI = useMemo(() => { + return gapi?.client.analytics.management + }, [gapi]) + + const requestAccountSummaries = useCallback(async (): Promise< + AccountSummary[] | undefined + > => { + if (managementAPI === undefined) { + throw new Error( + "invalid invariant - fetchAccounts should never be called with an undefined managementAPI" + ) + } + const response = await managementAPI.accountSummaries.list({}) + return response.result.items + }, [managementAPI]) + + const requestReady = useMemo(() => managementAPI !== undefined, [ + managementAPI, + ]) + + const accountSummariesRequest = useCached( + StorageKey.uaAccounts, + requestAccountSummaries, + moment.duration(5, "minutes"), + requestReady + ) + + const accountFilter = useCallback( + (a: AccountSummary) => { + if (accountSummary === undefined) { + return false + } + return a.id === accountSummary.id + }, + [accountSummary] + ) + + const propertyFilter = useCallback( + (p: WebPropertySummary) => { + if (propertySummary === undefined) { + return false + } + return propertySummary.id === p.id + }, + [propertySummary] + ) + + return useMemo(() => { + switch (accountSummariesRequest.status) { + case RequestStatus.Successful: { + const accountSummaries = accountSummariesRequest.value || [] + const propertySummaries = accountSummaries + .filter(accountFilter) + .flatMap(a => a.webProperties || []) + const profileSummaries = propertySummaries + .filter(propertyFilter) + .flatMap(p => p.profiles || []) + return { + status: accountSummariesRequest.status, + accountSummaries, + propertySummaries, + profileSummaries, + } + } + default: + return { status: accountSummariesRequest.status } + } + }, [accountSummariesRequest, accountFilter, propertyFilter]) +} + +export default useAccountSummaries diff --git a/src/components/ViewSelector/useAccounts.ts b/src/components/ViewSelector/useAccounts.ts deleted file mode 100644 index 034382de..00000000 --- a/src/components/ViewSelector/useAccounts.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { AccountSummary } from "@/api" -import { StorageKey } from "@/constants" -import useCached from "@/hooks/useCached" -import moment from "moment" -import { useCallback, useMemo } from "react" - -import { useSelector } from "react-redux" - -const useAccounts = (): AccountSummary[] | undefined => { - const gapi = useSelector((state: AppState) => state.gapi) - - const managementAPI = useMemo(() => { - return gapi?.client.analytics.management - }, [gapi]) - - const fetchAccounts = useCallback(async (): Promise< - AccountSummary[] | undefined - > => { - if (managementAPI === undefined) { - throw new Error( - "invalid invariant - fetchAccounts should never be called with an undefined managementAPI" - ) - } - const response = await managementAPI.accountSummaries.list({}) - return response.result.items - }, [managementAPI]) - - const requestReady = useMemo(() => managementAPI !== undefined, [ - managementAPI, - ]) - - const { value: accounts } = useCached( - StorageKey.uaAccounts, - fetchAccounts, - moment.duration(5, "minutes"), - requestReady - ) - - return accounts -} - -export default useAccounts diff --git a/src/components/ViewSelector/useFlattenedViews.ts b/src/components/ViewSelector/useFlattenedViews.ts new file mode 100644 index 00000000..36751c87 --- /dev/null +++ b/src/components/ViewSelector/useFlattenedViews.ts @@ -0,0 +1,73 @@ +import { Requestable, RequestStatus } from "@/types" +import { useCallback, useMemo } from "react" +import { + AccountSummary, + WebPropertySummary, + UAAccountPropertyView, +} from "../ViewSelector/useAccountPropertyView" +import useAccountSummaries from "./useAccountSummaries" + +interface Successful { + flattenedViews: UAAccountPropertyView[] +} + +const useFlattenedViews = ( + accountSummary?: AccountSummary, + propertySummary?: WebPropertySummary, + filter?: (fv: UAAccountPropertyView) => boolean +): Requestable => { + const accountSummariesRequest = useAccountSummaries() + + const filterFlattened = useCallback( + (flattenedView: UAAccountPropertyView) => { + if (filter && filter(flattenedView) === false) { + return false + } + return accountSummary === undefined + ? true + : accountSummary.id === flattenedView.account?.id + ? propertySummary === undefined + ? true + : propertySummary.id === flattenedView.property?.id + : false + }, + [accountSummary, propertySummary, filter] + ) + + return useMemo(() => { + switch (accountSummariesRequest.status) { + case RequestStatus.Successful: { + const accountSummaries = accountSummariesRequest.accountSummaries + + const flattenedViews = accountSummaries + .flatMap(summary => { + const account = { ...summary } + + const properties = summary.webProperties || [] + if (properties.length === 0) { + return { account } + } + return properties.flatMap(propertySummary => { + const property = { ...propertySummary } + + const profiles = propertySummary.profiles || [] + if (profiles.length === 0) { + return { account, property } + } + return profiles.map(profile => ({ + view: profile, + property, + account, + })) + }) + }) + .filter(filterFlattened) + return { status: accountSummariesRequest.status, flattenedViews } + } + default: + return { status: accountSummariesRequest.status } + } + }, [accountSummariesRequest, filterFlattened]) +} + +export default useFlattenedViews diff --git a/src/components/ViewSelector/useViewSelector.ts b/src/components/ViewSelector/useViewSelector.ts deleted file mode 100644 index 473c7def..00000000 --- a/src/components/ViewSelector/useViewSelector.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMemo } from "react" -import useAccounts from "./useAccounts" - -export type AccountSummary = gapi.client.analytics.AccountSummary -export type WebPropertySummary = gapi.client.analytics.WebPropertySummary -export type ProfileSummary = gapi.client.analytics.ProfileSummary - -const useViewSelector = ( - account: AccountSummary | undefined, - property: WebPropertySummary | undefined -): { - accounts: AccountSummary[] | undefined - properties: WebPropertySummary[] | undefined - views: ProfileSummary[] | undefined -} => { - const accounts = useAccounts() - - const properties = useMemo(() => { - if (account === undefined || accounts === undefined) { - return undefined - } - const a = accounts.find(a => a.id === account.id) - if (a === undefined) { - return undefined - } - return a.webProperties || [] - }, [accounts, account]) - - const views = useMemo(() => { - if (property === undefined || properties === undefined) { - return undefined - } - const p = properties.find(p => p.id === property.id) - if (p === undefined) { - return [] - } - return p.profiles || [] - }, [properties, property]) - - return { - accounts, - properties, - views, - } -} - -export default useViewSelector diff --git a/src/components/ga4/DimensionsMetricsExplorer/Field.tsx b/src/components/ga4/DimensionsMetricsExplorer/Field.tsx index 52294924..d6695bfe 100644 --- a/src/components/ga4/DimensionsMetricsExplorer/Field.tsx +++ b/src/components/ga4/DimensionsMetricsExplorer/Field.tsx @@ -134,7 +134,7 @@ const Field: React.FC = props => { const link = React.useMemo(() => { let baseURL = `${window.location.origin}${window.location.pathname}` let search = `` - if (!field.value.customDefinition && account && property) { + if (field.value.customDefinition && account && property) { let urlParams = new URLSearchParams() urlParams.append(QueryParam.Account, account.name!) urlParams.append(QueryParam.Property, property.property!) diff --git a/src/components/ga4/DimensionsMetricsExplorer/useCompatibility.tsx b/src/components/ga4/DimensionsMetricsExplorer/useCompatibility.tsx index db4929a3..7c700795 100644 --- a/src/components/ga4/DimensionsMetricsExplorer/useCompatibility.tsx +++ b/src/components/ga4/DimensionsMetricsExplorer/useCompatibility.tsx @@ -40,17 +40,21 @@ const useCompatibility = (ap: AccountProperty): CompatibleHook => { const reset = useCallback(() => { setDimensions(undefined) setMetrics(undefined) + setIncompatibleDimensions(undefined) + setIncompatibleMetrics(undefined) }, []) useEffect(() => { if (gapi === undefined || ap.property === undefined) { - return undefined + return } if ( (dimensions === undefined || dimensions.length === 0) && (metrics === undefined || metrics.length === 0) ) { - return undefined + setIncompatibleMetrics(undefined) + setIncompatibleDimensions(undefined) + return } gapi.client .request({ diff --git a/src/components/ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics.ts b/src/components/ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics.ts index 40580588..8b5073c4 100644 --- a/src/components/ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics.ts +++ b/src/components/ga4/DimensionsMetricsExplorer/useDimensionsAndMetrics.ts @@ -5,7 +5,6 @@ import { useSelector } from "react-redux" import { Requestable, RequestStatus } from "@/types" import { StorageKey } from "@/constants" import useCached from "@/hooks/useCached" -import useRequestStatus from "@/hooks/useRequestStatus" import moment from "moment" import { AccountProperty } from "../StreamPicker/useAccountProperty" @@ -21,90 +20,70 @@ export type Successful = { }> } +export type DimensionsAndMetricsRequest = Requestable + +export const DimensionsAndMetricsRequestCtx = React.createContext( + { status: RequestStatus.NotStarted } +) + export const useDimensionsAndMetrics = ( aps: AccountProperty ): Requestable => { const gapi = useSelector((state: AppState) => state.gapi) const dataAPI = React.useMemo(() => gapi?.client.analyticsdata, [gapi]) - const { - status, - setInProgress, - setFailed, - setSuccessful, - setNotStarted, - } = useRequestStatus() const propertyName = React.useMemo( () => `${aps.property?.property || "properties/0"}/metadata`, [aps.property] ) - React.useEffect(() => { - setNotStarted() - }, [propertyName, setNotStarted]) - const requestReady = React.useMemo(() => dataAPI !== undefined, [dataAPI]) const getMetadata = React.useCallback(async () => { - try { - if (dataAPI === undefined) { - throw new Error("Invalid invariant - dataAPI must be defined.") - } - setInProgress() - const { result } = await dataAPI.properties.getMetadata({ - name: propertyName, - }) - return { metrics: result.metrics, dimensions: result.dimensions } - } catch (e) { - setFailed() + if (dataAPI === undefined) { + throw new Error("Invalid invariant - dataAPI must be defined.") } - }, [dataAPI, propertyName, setFailed, setInProgress]) + const { result } = await dataAPI.properties.getMetadata({ + name: propertyName, + }) + return { metrics: result.metrics, dimensions: result.dimensions } + }, [dataAPI, propertyName]) - const { value: dimsAndMets } = useCached( + const metadataRequest = useCached( `${StorageKey.ga4DimensionsMetrics}/${propertyName}` as StorageKey, getMetadata, moment.duration(5, "minutes"), requestReady ) - React.useEffect(() => { - if (dimsAndMets !== undefined) { - setSuccessful() - } - }, [dimsAndMets, setSuccessful]) - - const categories = React.useMemo( - () => - [ - ...new Set( - (dimsAndMets?.metrics || []) - // TODO - remove the any casts once the types are updated to - // include category. - .map(m => (m as any).category) - .concat(dimsAndMets?.dimensions?.map(d => (d as any).category)) - ), - ].map(category => ({ - category, - dimensions: (dimsAndMets?.dimensions || []).filter( - d => (d as any).category === category - ), - metrics: (dimsAndMets?.metrics || []).filter( - m => (m as any).category === category - ), - })), - [dimsAndMets] - ) - - const dimensions = React.useMemo(() => dimsAndMets?.dimensions, [dimsAndMets]) - const metrics = React.useMemo(() => dimsAndMets?.metrics, [dimsAndMets]) - - if (status === RequestStatus.Successful) { - if (dimensions === undefined || metrics === undefined) { - throw new Error( - "Invalid invariant - dimensions & metrics must be defined." - ) + return React.useMemo(() => { + switch (metadataRequest.status) { + case RequestStatus.InProgress: + case RequestStatus.NotStarted: + case RequestStatus.Failed: + return { status: metadataRequest.status } + case RequestStatus.Successful: { + const { metrics = [], dimensions = [] } = metadataRequest.value + const categories = [ + ...new Set( + metrics + .concat(dimensions) + // TODO - remove the any casts once the types are updated to + // include category. + .map(m => (m as any).category) + ), + ].map(category => ({ + category, + dimensions: dimensions.filter(d => (d as any).category === category), + metrics: metrics.filter(m => (m as any).category === category), + })) + return { + status: metadataRequest.status, + categories, + dimensions, + metrics, + } + } } - return { status, dimensions, metrics, categories } - } - return { status } + }, [metadataRequest]) } diff --git a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts index c9d54499..95935e89 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/usePayload.ts @@ -1,6 +1,6 @@ import { useContext, useMemo } from "react" import { EventCtx } from ".." -import { ParameterType, Parameter } from "../types" +import { ParameterType, Parameter, EventType } from "../types" const tryParseNum = (s: string | undefined): number | undefined => { if (s === undefined) { @@ -62,14 +62,23 @@ const removeEmptyObject = (o: {}): {} => { const usePayload = (): {} => { const { - eventName, + eventName: customEventName, parameters, items, userProperties, timestamp_micros, non_personalized_ads, clientIds, + type, } = useContext(EventCtx)! + + const eventName = useMemo(() => { + if (type === EventType.CustomEvent) { + return customEventName + } + return type + }, [type, customEventName]) + const itemsParameter = useMemo( () => items === undefined diff --git a/src/components/ga4/QueryExplorer/BasicReport/index.tsx b/src/components/ga4/QueryExplorer/BasicReport/index.tsx index 03e2c0a9..3cf23217 100644 --- a/src/components/ga4/QueryExplorer/BasicReport/index.tsx +++ b/src/components/ga4/QueryExplorer/BasicReport/index.tsx @@ -24,6 +24,10 @@ import useInputs from "./useInputs" import useFormStyles from "@/hooks/useFormStyles" import StreamPicker from "../../StreamPicker" import useAccountProperty from "../../StreamPicker/useAccountProperty" +import { + DimensionsAndMetricsRequestCtx, + useDimensionsAndMetrics, +} from "../../DimensionsMetricsExplorer/useDimensionsAndMetrics" const useStyles = makeStyles(theme => ({ showRequestJSON: { @@ -102,6 +106,7 @@ const BasicReport = () => { const classes = useStyles() const formClasses = useFormStyles() const aps = useAccountProperty(StorageKey.ga4QueryExplorerAPS, QueryParam) + const dimensionsAndMetricsRequest = useDimensionsAndMetrics(aps) const { property } = aps const { dateRanges, @@ -132,7 +137,7 @@ const BasicReport = () => { removeDateRanges, showAdvanced, setShowAdvanced, - } = useInputs(aps) + } = useInputs(dimensionsAndMetricsRequest) const useMake = useMakeRequest({ metricAggregations, property, @@ -164,174 +169,174 @@ const BasicReport = () => { }, [metrics, dimensions]) return ( - - - Returns a customized report of your Google Analytics event data. Reports - contain statistics derived from data collected by the Google Analytics - measurement code. Basic Report uses the {runReportLink} API endpoint. - -
- Select property - - Set parameters - - Show advanced options - - - - The metrics to include in the request. See {metricsLink} on - devsite. - - } - /> - - The dimensions to include in the request. See {dimensionsLink} on - devsite. - - } - /> - - - - - The {showAdvanced ? "filters" : "filter"} to use for the - dimensions in the request. See {dimensionFiltersLink} on devsite. - - } - > - - - - The {showAdvanced ? "filters" : "filter"} to use for the metrics - in the request. See {metricFiltersLink} on devsite. - - } - > - + + + Returns a customized report of your Google Analytics event data. + Reports contain statistics derived from data collected by the Google + Analytics measurement code. Basic Report uses the {runReportLink} API + endpoint. + +
+ Select property + + Set parameters + + Show advanced options + + - - - {showAdvanced && ( - + The metrics to include in the request. See {metricsLink} on + devsite. + + } /> - )} - {showAdvanced && ( - + The dimensions to include in the request. See {dimensionsLink}{" "} + on devsite. + + } /> - )} - - Return rows with all metrics equal to 0 separately. See{" "} - {keepEmptyRowsLink} on devsite. - - } - > + + + + + The {showAdvanced ? "filters" : "filter"} to use for the + dimensions in the request. See {dimensionFiltersLink} on + devsite. + + } + > + + + + The {showAdvanced ? "filters" : "filter"} to use for the metrics + in the request. See {metricFiltersLink} on devsite. + + } + > + + + + {showAdvanced && ( + + )} + {showAdvanced && ( + + )} + + Return rows with all metrics equal to 0 separately. See{" "} + {keepEmptyRowsLink} on devsite. + + } + > + + keep empty rows. + + + + + Make Request + - keep empty rows. + Show request JSON - +
- - Make Request - - - Show request JSON - -
- - {showRequestJSON && ( - + )} + - )} - -
+ + ) } diff --git a/src/components/ga4/QueryExplorer/BasicReport/useInputs.ts b/src/components/ga4/QueryExplorer/BasicReport/useInputs.ts index a48af5bd..f12da3a6 100644 --- a/src/components/ga4/QueryExplorer/BasicReport/useInputs.ts +++ b/src/components/ga4/QueryExplorer/BasicReport/useInputs.ts @@ -10,18 +10,17 @@ import useAvailableColumns from "@/components/GA4Pickers/useAvailableColumns" import { DateRange } from "../DateRanges" import { FilterExpression } from "../Filter" import { MetricAggregation } from "../MetricAggregations" -import { AccountProperty } from "../../StreamPicker/useAccountProperty" import { useKeyedHydratedPersistantArray } from "@/hooks/useHydrated" import { QueryParam } from "." -import { useDimensionsAndMetrics } from "../../DimensionsMetricsExplorer/useDimensionsAndMetrics" -import { successful } from "@/types" +import { DimensionsAndMetricsRequest } from "../../DimensionsMetricsExplorer/useDimensionsAndMetrics" +import { RequestStatus } from "@/types" type OrderBy = gapi.client.analyticsdata.OrderBy type CohortSpec = gapi.client.analyticsdata.CohortSpec -const useInputs = (aps: AccountProperty) => { - const dimensionsAndMetricsRequest = useDimensionsAndMetrics(aps) - +const useInputs = ( + dimensionsAndMetricsRequest: DimensionsAndMetricsRequest +) => { const [showRequestJSON, setShowRequestJSON] = usePersistentBoolean( StorageKey.ga4RequestComposerBasicShowRequestJSON, true @@ -32,11 +31,15 @@ const useInputs = (aps: AccountProperty) => { const getDimensionsByIDs = useCallback( (ids: string[] | undefined) => { - if (ids === undefined || !successful(dimensionsAndMetricsRequest)) { + if ( + ids === undefined || + dimensionsAndMetricsRequest.status !== RequestStatus.Successful + ) { return undefined } - const { dimensions } = successful(dimensionsAndMetricsRequest)! - return dimensions.filter(m => ids.includes(m.apiName!)) + return dimensionsAndMetricsRequest.dimensions.filter(m => + ids.includes(m.apiName!) + ) }, [dimensionsAndMetricsRequest] ) @@ -51,11 +54,15 @@ const useInputs = (aps: AccountProperty) => { const getMetricsByIDs = useCallback( (ids: string[] | undefined) => { - if (ids === undefined || !successful(dimensionsAndMetricsRequest)) { + if ( + ids === undefined || + dimensionsAndMetricsRequest.status !== RequestStatus.Successful + ) { return undefined } - const { metrics } = successful(dimensionsAndMetricsRequest)! - return metrics.filter(m => ids.includes(m.apiName!)) + return dimensionsAndMetricsRequest.metrics.filter(m => + ids.includes(m.apiName!) + ) }, [dimensionsAndMetricsRequest] ) @@ -90,7 +97,7 @@ const useInputs = (aps: AccountProperty) => { const { dimensionOptions } = useAvailableColumns({ selectedMetrics: metrics, selectedDimensions: dimensions, - aps, + request: dimensionsAndMetricsRequest, }) const [cohortSpec, setCohortSpec] = usePersistantObject( diff --git a/src/components/ga4/QueryExplorer/CohortSpec.tsx b/src/components/ga4/QueryExplorer/CohortSpec.tsx index f2546082..2c8a9516 100644 --- a/src/components/ga4/QueryExplorer/CohortSpec.tsx +++ b/src/components/ga4/QueryExplorer/CohortSpec.tsx @@ -18,7 +18,6 @@ import LinkedTextField from "@/components/LinkedTextField" import { GADateRange } from "@/components/GADate" import { Dispatch } from "@/types" import makeStyles from "@material-ui/core/styles/makeStyles" -import { AccountProperty } from "../StreamPicker/useAccountProperty" type DateRange = gapi.client.analyticsdata.DateRange type CohortsRange = gapi.client.analyticsdata.CohortsRange @@ -245,7 +244,6 @@ const useStyles = makeStyles(theme => ({ type CohortSpecType = gapi.client.analyticsdata.CohortSpec interface CohortSpecProps { - aps: AccountProperty cohortSpec: CohortSpecType | undefined setCohortSpec: Dispatch dimensions: GA4Dimensions @@ -254,7 +252,6 @@ interface CohortSpecProps { removeDateRanges: () => void } const CohortSpec: React.FC = ({ - aps, cohortSpec, setCohortSpec, dimensions, @@ -361,11 +358,7 @@ const CohortSpec: React.FC = ({ > - + updateDateRange(idx, update)} diff --git a/src/components/ga4/QueryExplorer/Filter/Filter/index.tsx b/src/components/ga4/QueryExplorer/Filter/Filter/index.tsx index 0f85db1a..99afae06 100644 --- a/src/components/ga4/QueryExplorer/Filter/Filter/index.tsx +++ b/src/components/ga4/QueryExplorer/Filter/Filter/index.tsx @@ -125,7 +125,7 @@ const Filter: React.FC<{ removeExpression(path) }, [removeExpression, path]) - const { showAdvanced, aps } = React.useContext(UseFilterContext)! + const { showAdvanced } = React.useContext(UseFilterContext)! return ( {type === "metric" ? ( ) : ( @@ -58,7 +56,6 @@ export type AddExpressionFn = ( ) => void const Filter: React.FC = ({ - aps, showAdvanced, fields, setFilterExpression, @@ -106,7 +103,7 @@ const Filter: React.FC = ({ }, [expression]) return ( - +
{noFiltersConfigured} & { aps: AccountProperty }) | undefined + ReturnType | undefined >(undefined) type UseFilter = ( diff --git a/src/components/ga4/QueryExplorer/OrderBys.tsx b/src/components/ga4/QueryExplorer/OrderBys.tsx index b6d5e834..5b110eb7 100644 --- a/src/components/ga4/QueryExplorer/OrderBys.tsx +++ b/src/components/ga4/QueryExplorer/OrderBys.tsx @@ -18,7 +18,6 @@ import { import ExternalLink from "@/components/ExternalLink" import WithHelpText from "@/components/WithHelpText" import { ArrowDownward, ArrowUpward } from "@material-ui/icons" -import { AccountProperty } from "../StreamPicker/useAccountProperty" const orderBysLink = ( @@ -215,8 +214,7 @@ const MetricSort: React.FC<{ setMetric: ReturnType["setMetric"] className: string id: number - aps: AccountProperty -}> = ({ metricFilter, className, setMetric, id, aps }) => { +}> = ({ metricFilter, className, setMetric, id }) => { const [metric, setMetricLocal] = React.useState() React.useEffect(() => { @@ -225,7 +223,6 @@ const MetricSort: React.FC<{ return ( boolean setDimension: ReturnType["setDimension"] setDimensionOrderType: ReturnType["setDimensionOrderType"] @@ -243,7 +239,6 @@ const DimensionSort: React.FC<{ id: number orderType: SelectOption | undefined }> = ({ - aps, dimensionFilter, className, setDimension, @@ -261,7 +256,6 @@ const DimensionSort: React.FC<{ return ( <> - aps: AccountProperty className?: string } & PickedDimension & PickedMetric const OrderBys: React.FC = ({ - aps, orderBys, setOrderBys, className, @@ -366,7 +358,6 @@ const OrderBys: React.FC = ({ {selectedType?.value === "metric" && props.metric ? ( = ({ ) : null} {selectedType?.value === "dimension" && props.dimension ? ( > = props => { - console.debug("test component rendering.") - const aps = useAccountPropertyStream("a" as StorageKey, QueryParam, { - androidStreams: true, - iosStreams: true, - webStreams: true, - }) - return -} - describe("StreamPicker", () => { - describe("when autoFill is true", () => { - test("selects a property & stream after an account is picked.", async () => { - console.debug("hi") - const { gapi, wrapped } = withProviders() - - const { findByTestId } = renderer.render(wrapped) - - // // Await for the mocked accountSummaries methods to finish. - // await act(async () => { - // await gapi.client.analyticsadmin.accountSummaries.list({}) - // await gapi.client.analyticsadmin.accountSummaries.list({ - // pageToken: "1", - // }) - // }) - - const accountPicker = await findByTestId(Label.Account) - - // await act(async () => { - // const accountInput = within(accountPicker).getByRole("textbox") - // accountPicker.focus() - // renderer.fireEvent.change(accountInput, { target: { value: "" } }) - // renderer.fireEvent.keyDown(accountPicker, { key: "ArrowDown" }) - // renderer.fireEvent.keyDown(accountPicker, { key: "ArrowDown" }) - // renderer.fireEvent.keyDown(accountPicker, { key: "Enter" }) - // }) - - // // Await for the mocked stream methods to finish. - // await act(async () => { - // await gapi.client.analyticsadmin.properties.webDataStreams.list() - // await gapi.client.analyticsadmin.properties.iosAppDataStreams.list() - // await gapi.client.analyticsadmin.properties.androidAppDataStreams.list() - // }) - - expect(within(accountPicker).getByRole("textbox")).toHaveValue("hi") - }) + const DefaultPicker: React.FC = () => { + const ap = useAccountProperty("a" as StorageKey, QueryParam) + return + } + test("renders without error", async () => { + const { wrapped } = withProviders() + const { findByLabelText } = renderer.render(wrapped) + + const account = await findByLabelText(Label.Account) + expect(account).toBeVisible() + + const property = await findByLabelText(Label.Property) + expect(property).toBeVisible() }) - // describe("with defaults", () => { - // test("of { account } selects default", async () => { - // const account: AccountSummary = { - // name: "accountSummaries/def", - // account: "accounts/def456", - // displayName: "my second account", - // } - // const { gapi, wrapped } = withProviders() - // const { findByTestId } = renderer.render(wrapped) - - // await act(async () => { - // await gapi.client.analyticsadmin.accountSummaries.list({}) - // await gapi.client.analyticsadmin.accountSummaries.list({ - // pageToken: "1", - // }) - // }) - - // const accountPicker = await findByTestId(Label.Account) - // const accountInput = within(accountPicker).getByRole("textbox") - - // expect(accountInput).toHaveValue("my second account") - // }) - - // test("of { account, property } selects default", async () => { - // const account: AccountSummary = { - // name: "accountSummaries/def", - // account: "accounts/def456", - // displayName: "my second account", - // } - // const property: PropertySummary = { - // displayName: "my fourth property", - // property: "properties/4", - // } - // const { gapi, wrapped } = withProviders( - // - // ) - // const { findByTestId } = renderer.render(wrapped) - - // await act(async () => { - // await gapi.client.analyticsadmin.accountSummaries.list({}) - // await gapi.client.analyticsadmin.accountSummaries.list({ - // pageToken: "1", - // }) - // // await gapi.client.analyticsadmin.properties.webDataStreams.list({}) - // // await gapi.client.analyticsadmin.properties.iosAppDataStreams.list() - // // await gapi.client.analyticsadmin.properties.androidAppDataStreams.list() - // }) - - // const accountPicker = await findByTestId(Label.Account) - // const accountInput = within(accountPicker).getByRole("textbox") - - // expect(accountInput).toHaveValue("my second account") - // }) - // }) }) diff --git a/src/components/ga4/StreamPicker/useAccounts.spec.ts b/src/components/ga4/StreamPicker/useAccounts.spec.ts new file mode 100644 index 00000000..22ca588a --- /dev/null +++ b/src/components/ga4/StreamPicker/useAccounts.spec.ts @@ -0,0 +1,31 @@ +import "@testing-library/jest-dom" +import { renderHook } from "@testing-library/react-hooks" + +import useAccounts from "./useAccounts" +import { act } from "react-test-renderer" +import { RequestStatus } from "@/types" +import { wrapperFor } from "@/test-utils" + +describe("useAccounts", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + describe("when value not cached", () => { + test("sucessfully makes requests", async () => { + const { result, waitForNextUpdate } = renderHook( + () => { + return useAccounts() + }, + { wrapper: wrapperFor() } + ) + expect(result.current.status).toEqual(RequestStatus.InProgress) + + await act(async () => { + await waitForNextUpdate() + }) + + expect(result.current.status).toEqual(RequestStatus.Successful) + }) + }) +}) diff --git a/src/components/ga4/StreamPicker/useAccounts.ts b/src/components/ga4/StreamPicker/useAccounts.ts index d86f1d87..08864a42 100644 --- a/src/components/ga4/StreamPicker/useAccounts.ts +++ b/src/components/ga4/StreamPicker/useAccounts.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useEffect } from "react" +import { useCallback, useMemo } from "react" import { Requestable, RequestStatus } from "@/types" import { AccountSummaries } from "@/types/ga4/StreamPicker" @@ -6,7 +6,6 @@ import useCached from "@/hooks/useCached" import { StorageKey } from "@/constants" import moment from "moment" import usePaginatedCallback from "@/hooks/usePaginatedCallback" -import useRequestStatus from "@/hooks/useRequestStatus" import { useSelector } from "react-redux" type AccountSummariesResponse = gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListAccountSummariesResponse @@ -18,18 +17,17 @@ const getPageToken = (response: AccountSummariesResponse) => const useAccountSummaries = (): Requestable => { const gapi = useSelector((a: AppState) => a.gapi) const adminAPI = useMemo(() => gapi?.client.analyticsadmin, [gapi]) - const { status, setInProgress, setFailed, setSuccessful } = useRequestStatus() const requestReady = useMemo(() => adminAPI !== undefined, [adminAPI]) const paginatedRequest = useCallback( - (pageToken: string | undefined) => { + async (pageToken: string | undefined) => { if (adminAPI === undefined) { throw new Error( "invalid invariant. adminAPI cannot be undefined when this method is called." ) } - return adminAPI.accountSummaries.list({ pageToken }) + return await adminAPI.accountSummaries.list({ pageToken }) }, [adminAPI] ) @@ -39,40 +37,26 @@ const useAccountSummaries = (): Requestable => { "invalid invariant. adminAPI cannot be undefined when this method is called.", paginatedRequest, getAccountSummaries, - getPageToken, - setInProgress, - setFailed + getPageToken ) - const { value: accountSummaries } = useCached( + const accountSummariesRequest = useCached( StorageKey.ga4AccountSummaries, requestAccountSummaries, moment.duration(5, "minutes"), requestReady ) - useEffect(() => { - if (accountSummaries !== undefined) { - setSuccessful() - } - }, [accountSummaries, setSuccessful]) - - switch (status) { - case RequestStatus.Failed: - case RequestStatus.InProgress: - case RequestStatus.NotStarted: - return { status } + switch (accountSummariesRequest.status) { case RequestStatus.Successful: { - if (accountSummaries === undefined) { - throw new Error( - "Invalid invariant - accountSummaries should not be undefined." - ) - } + const accountSummaries = accountSummariesRequest.value || [] return { - status: RequestStatus.Successful, + status: accountSummariesRequest.status, accounts: accountSummaries, } } + default: + return { status: accountSummariesRequest.status } } } diff --git a/src/components/ga4/StreamPicker/useAccountsAndProperties.ts b/src/components/ga4/StreamPicker/useAccountsAndProperties.ts index 2daa6e2b..493a22e7 100644 --- a/src/components/ga4/StreamPicker/useAccountsAndProperties.ts +++ b/src/components/ga4/StreamPicker/useAccountsAndProperties.ts @@ -3,7 +3,6 @@ import { useMemo } from "react" import { Requestable, RequestStatus, successful } from "@/types" import { AccountSummary, PropertySummary } from "@/types/ga4/StreamPicker" import useAccounts from "./useAccounts" -import useStreams from "./useStreams" const useAccountsAndProperties = ( account: AccountSummary | undefined diff --git a/src/components/ga4/StreamPicker/useStreams.ts b/src/components/ga4/StreamPicker/useStreams.ts index d4e3b193..12ee4777 100644 --- a/src/components/ga4/StreamPicker/useStreams.ts +++ b/src/components/ga4/StreamPicker/useStreams.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react" +import { useCallback, useMemo } from "react" import { Requestable, RequestStatus } from "@/types" import { PropertySummary, Stream, StreamType } from "@/types/ga4/StreamPicker" @@ -6,7 +6,6 @@ import usePaginatedCallback from "@/hooks/usePaginatedCallback" import useCached from "@/hooks/useCached" import { StorageKey } from "@/constants" import moment from "moment" -import useRequestStatus from "@/hooks/useRequestStatus" import { useSelector } from "react-redux" type WebStreamsResponse = gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListWebDataStreamsResponse @@ -25,25 +24,11 @@ const getAndroidPageToken = (response: IOSStreamsResponse) => response.nextPageToken const useStreams = ( - property: PropertySummary | undefined, - streams: { - androidStreams?: boolean - webStreams?: boolean - iosStreams?: boolean - }, - onComplete?: () => void + property: PropertySummary | undefined ): Requestable<{ streams: Stream[] }> => { const gapi = useSelector((a: AppState) => a.gapi) const adminAPI = useMemo(() => gapi?.client.analyticsadmin, [gapi]) - const { - status: webStreamsStatus, - setInProgress: setWebStreamInProgress, - setSuccessful: setWebStreamSuccessful, - setNotStarted: setWebStreamNotStarted, - setFailed: setWebStreamFailed, - } = useRequestStatus() - const requestReady = useMemo( () => adminAPI !== undefined && property !== undefined, [adminAPI, property] @@ -69,9 +54,7 @@ const useStreams = ( "Invalid invariant - property & adminAPI must be defined.", paginatedWebStreamsRequest, getWebStreams, - getWebPageToken, - setWebStreamInProgress, - setWebStreamFailed + getWebPageToken ) const webStorageKey = useMemo( @@ -80,27 +63,13 @@ const useStreams = ( [property?.property] ) - const { value: webStreams } = useCached( + const webStreamsRequest = useCached( webStorageKey, requestWebStreams, moment.duration(5, "minutes"), - requestReady && !!streams.webStreams + requestReady ) - useEffect(() => { - if (webStreams !== undefined) { - setWebStreamSuccessful() - } - }, [setWebStreamSuccessful, webStreams]) - - const { - status: iosStreamsStatus, - setInProgress: setIOSStreamInProgress, - setSuccessful: setIOSStreamSuccessful, - setNotStarted: setIOSStreamNotStarted, - setFailed: setIOSStreamFailed, - } = useRequestStatus() - const paginatedIOSStreamsRequest = useCallback( (pageToken: string | undefined) => { if (adminAPI === undefined || property === undefined) { @@ -121,9 +90,7 @@ const useStreams = ( "Invalid invariant - property & adminAPI must be defined.", paginatedIOSStreamsRequest, getIOSStreams, - getIOSPageToken, - setIOSStreamInProgress, - setIOSStreamFailed + getIOSPageToken ) const iosStorageKey = useMemo( @@ -132,27 +99,13 @@ const useStreams = ( [property?.property] ) - const { value: iosStreams } = useCached( + const iosStreamsRequest = useCached( iosStorageKey, requestIOSStreams, moment.duration(5, "minutes"), - requestReady && !!streams.iosStreams + requestReady ) - useEffect(() => { - if (iosStreams !== undefined) { - setIOSStreamSuccessful() - } - }, [setIOSStreamSuccessful, iosStreams]) - - const { - status: androidStreamsStatus, - setInProgress: setAndroidStreamInProgress, - setSuccessful: setAndroidStreamSuccessful, - setNotStarted: setAndroidStreamNotStarted, - setFailed: setAndroidStreamFailed, - } = useRequestStatus() - const paginatedAndroidStreamsRequest = useCallback( (pageToken: string | undefined) => { if (adminAPI === undefined || property === undefined) { @@ -173,9 +126,7 @@ const useStreams = ( "Invalid invariant - property & adminAPI must be defined.", paginatedAndroidStreamsRequest, getAndroidStreams, - getAndroidPageToken, - setAndroidStreamInProgress, - setAndroidStreamFailed + getAndroidPageToken ) const androidStorageKey = useMemo( @@ -184,95 +135,58 @@ const useStreams = ( [property?.property] ) - const { value: androidStreams } = useCached( + const androidStreamsRequest = useCached( androidStorageKey, requestAndroidStreams, moment.duration(5, "minutes"), - requestReady && !!streams.androidStreams + requestReady ) - useEffect(() => { - if (androidStreams !== undefined) { - setAndroidStreamSuccessful() - } - }, [setAndroidStreamSuccessful, androidStreams]) - - useEffect(() => { + return useMemo(() => { if ( - (webStreams !== undefined || !streams.webStreams) && - (iosStreams !== undefined || !streams.iosStreams) && - (androidStreams !== undefined || !streams.androidStreams) + webStreamsRequest.status === RequestStatus.Successful && + androidStreamsRequest.status === RequestStatus.Successful && + iosStreamsRequest.status === RequestStatus.Successful ) { - onComplete && onComplete() - } - }, [onComplete, webStreams, iosStreams, androidStreams, streams]) - - useEffect(() => { - setWebStreamNotStarted() - setIOSStreamNotStarted() - setAndroidStreamNotStarted() - }, [ - property, - setWebStreamNotStarted, - setIOSStreamNotStarted, - setAndroidStreamNotStarted, - ]) - - if ( - (webStreamsStatus === RequestStatus.Successful || !streams.webStreams) && - (iosStreamsStatus === RequestStatus.Successful || !streams.iosStreams) && - (androidStreamsStatus === RequestStatus.Successful || - !streams.androidStreams) - ) { - if (webStreams === undefined && streams.webStreams) { - throw new Error("Invalid invariant - webStreams must be defined here.") + const webStreams = (webStreamsRequest.value || []).map(s => ({ + type: StreamType.WebDataStream, + value: s, + })) + const androidStreams = (androidStreamsRequest.value || []).map(s => ({ + type: StreamType.AndroidDataStream, + value: s, + })) + const iosStreams = (iosStreamsRequest.value || []).map(s => ({ + type: StreamType.IOSDataStream, + value: s, + })) + return { + status: RequestStatus.Successful, + streams: webStreams.concat(androidStreams).concat(iosStreams), + } } - if (iosStreams === undefined && streams.iosStreams) { - throw new Error("Invalid invariant - iosStreams must be defined here.") + if ( + webStreamsRequest.status === RequestStatus.InProgress || + androidStreamsRequest.status === RequestStatus.InProgress || + iosStreamsRequest.status === RequestStatus.InProgress + ) { + return { + status: RequestStatus.InProgress, + } } - if (androidStreams === undefined && streams.androidStreams) { - throw new Error( - "Invalid invariant - androidStreams must be defined here." - ) + if ( + webStreamsRequest.status === RequestStatus.Failed || + androidStreamsRequest.status === RequestStatus.Failed || + iosStreamsRequest.status === RequestStatus.Failed + ) { + return { + status: RequestStatus.Failed, + } } return { - status: RequestStatus.Successful, - streams: (streams.webStreams ? webStreams! : []) - .map(s => ({ - type: StreamType.WebDataStream, - value: s, - })) - .concat( - (streams.iosStreams ? iosStreams! : []).map(s => ({ - type: StreamType.IOSDataStream, - value: s, - })) - ) - .concat( - (streams.androidStreams ? androidStreams! : []).map(s => ({ - type: StreamType.AndroidDataStream, - value: s, - })) - ), + status: RequestStatus.NotStarted, } - } - - if ( - webStreamsStatus === RequestStatus.InProgress || - iosStreamsStatus === RequestStatus.InProgress || - androidStreamsStatus === RequestStatus.InProgress - ) { - return { status: RequestStatus.InProgress } - } - - if ( - webStreamsStatus === RequestStatus.Failed || - iosStreamsStatus === RequestStatus.Failed || - androidStreamsStatus === RequestStatus.Failed - ) { - return { status: RequestStatus.Failed } - } - return { status: RequestStatus.NotStarted } + }, [webStreamsRequest, androidStreamsRequest, iosStreamsRequest]) } export default useStreams diff --git a/src/hooks/index.spec.ts b/src/hooks/index.spec.ts new file mode 100644 index 00000000..e0fdc339 --- /dev/null +++ b/src/hooks/index.spec.ts @@ -0,0 +1,46 @@ +import "@testing-library/jest-dom" +import { renderHook } from "@testing-library/react-hooks" + +import { StorageKey } from "@/constants" +import { useState } from "react" +import { act } from "react-test-renderer" +import { usePersistantObject } from "." + +describe("usePersistantObject", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + describe("when value in cache", () => { + test("and switch to another value in cache, return other value", async () => { + const firstKey = "firstKey" as StorageKey + const firstValue = { + firstKey, + } + const secondKey = "secondKey" as StorageKey + const secondValue = { + secondKey, + } + + window.localStorage.setItem(firstKey, JSON.stringify(firstValue)) + window.localStorage.setItem(secondKey, JSON.stringify(secondValue)) + + const { result } = renderHook( + () => { + const [key, setKey] = useState(firstKey) + const sut = usePersistantObject(key) + return { sut, setKey } + }, + { initialProps: { key: firstKey } } + ) + + expect(result.current.sut[0]).toEqual(firstValue) + + act(() => { + result.current.setKey(secondKey) + }) + + expect(result.current.sut[0]).toEqual(secondValue) + }) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 21838854..c4470680 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -54,11 +54,18 @@ export const usePersistentString: UsePersistentString = ( initialValue, overwrite ) => { + if (IS_SSR) { + // This is okay to disable because this will _only_ be true during server + // side rendireng + // eslint-disable-next-line react-hooks/rules-of-hooks + return useState(initialValue) + } + // eslint-disable-next-line react-hooks/rules-of-hooks const [value, setValue] = useState(() => { if (overwrite !== undefined) { return overwrite } - const fromStorage = IS_SSR ? null : window.localStorage.getItem(key) + const fromStorage = window.localStorage.getItem(key) if (fromStorage === null) { return initialValue } @@ -68,6 +75,7 @@ export const usePersistentString: UsePersistentString = ( return JSON.parse(fromStorage).value }) + // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (IS_SSR) { return @@ -89,7 +97,7 @@ const getObjectFromLocalStorage = ( if (IS_SSR) { return undefined } - let asString = IS_SSR ? null : window.localStorage.getItem(key) + let asString = window.localStorage.getItem(key) if (asString === null || asString === "undefined") { return defaultValue } @@ -99,23 +107,52 @@ const getObjectFromLocalStorage = ( export const usePersistantObject = ( key: StorageKey, defaultValue?: T -): [T | undefined, React.Dispatch>] => { - const [value, setValue] = useState(() => { +): [T | undefined, Dispatch] => { + if (IS_SSR) { + // This is okay to disable because this will _only_ be true during server + // side rendireng + // eslint-disable-next-line react-hooks/rules-of-hooks + return useState(defaultValue) + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [localValue, setValueLocal] = useState(() => { return getObjectFromLocalStorage(key, defaultValue) }) - useEffect(() => { - setValue(getObjectFromLocalStorage(key, defaultValue)) - }, [key, setValue, defaultValue]) + // eslint-disable-next-line react-hooks/rules-of-hooks + const setValue: Dispatch = useCallback( + v => { + setValueLocal(old => { + let nu: T | undefined = undefined + if (v instanceof Function) { + nu = v(old) + } else { + nu = v + } + if (!IS_SSR) { + window.localStorage.setItem(key, JSON.stringify(nu)) + } + return nu + }) + }, + [key] + ) - useEffect(() => { - if (IS_SSR) { - return - } - window.localStorage.setItem(key, JSON.stringify(value)) - }, [value, key]) + // Note - This feels wrong, but I have to depend on localValue changing to + // catch changes that only happen when setValue is run. Don't remove + // localValue + // eslint-disable-next-line react-hooks/rules-of-hooks + const fromStorage = React.useMemo(() => { + // We want to depend on localValue so this stays up to date when it's + // updated, but ultimately we want to use the value from localStorage which + // we _know_ is going to be up to date when `key` changes. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + localValue + return getObjectFromLocalStorage(key, defaultValue) + }, [key, localValue, defaultValue]) - return [value, setValue] + return [fromStorage, setValue] } const uaToast = (tool: string) => `Redirecting to the UA ${tool}.` diff --git a/src/hooks/useCached.spec.ts b/src/hooks/useCached.spec.ts index f13274d7..b2e7784f 100644 --- a/src/hooks/useCached.spec.ts +++ b/src/hooks/useCached.spec.ts @@ -2,10 +2,11 @@ import "@testing-library/jest-dom" import { renderHook } from "@testing-library/react-hooks" import { StorageKey } from "@/constants" -import { useCallback, useMemo } from "react" +import { useCallback, useMemo, useState } from "react" import useCached from "./useCached" import moment from "moment" import { act } from "react-test-renderer" +import { inProgress, RequestStatus, successful } from "@/types" describe("useCached", () => { // The specific storage key shouldn't matter. @@ -18,6 +19,7 @@ describe("useCached", () => { describe("when value not in cache", () => { test("requests value exactly once", async () => { + const expected = "my value" let madeRequest = false const { result, waitForNextUpdate } = renderHook(() => { const makeRequest = useCallback(async () => { @@ -25,26 +27,32 @@ describe("useCached", () => { fail("This function should be called exactly once.") } else { madeRequest = true - return "my value" + return expected } }, []) const requestReady = useMemo(() => true, []) return useCached(key, makeRequest, expirey, requestReady) }) - // First render the value should be undefined while it's making the async request. - expect(result.current.value).toEqual(undefined) + // First render the status should be InProgress while it's making the + // async request. + expect(result.current.status).toEqual(RequestStatus.InProgress) await act(async () => { await waitForNextUpdate() }) - expect(result.current.value).toEqual("my value") + const actual = successful(result.current) + + // The second render the value should be defined. + expect(actual).not.toBeUndefined() + // and the value from the callback. + expect(actual!.value).toBe(expected) }) }) describe("when value in cache", () => { - test("uses cache value before expirey", () => { + test("returns cache value immedietly", () => { const expectedValue = { hi: "there", } @@ -61,7 +69,11 @@ describe("useCached", () => { const requestReady = useMemo(() => true, []) return useCached(key, makeRequest, expirey, requestReady) }) - expect(result.current.value).toEqual(expectedValue) + + const actual = successful(result.current) + + expect(actual).not.toBeUndefined() + expect(actual!.value).toEqual(expectedValue) }) test("re-requests value after expirey", async () => { @@ -88,13 +100,154 @@ describe("useCached", () => { return useCached(key, makeRequest, expirey, requestReady) }) - expect(result.current.value).toEqual("i am out of date") + expect(result.current.status).toEqual(RequestStatus.InProgress) + + await act(async () => { + await waitForNextUpdate() + }) + + const actual = successful(result.current) + + expect(actual).not.toBeUndefined() + expect(actual!.value).toEqual(expectedValue) + }) + + test("bustCache updatesValue", async () => { + const firstValue = { + hi: "there", + } + + const secondValue = { + alsoHi: "alsoThere", + } + + window.localStorage.setItem( + key, + JSON.stringify({ value: firstValue, "@@_lastFetched": moment.now() }) + ) + + const { result, waitForNextUpdate } = renderHook(() => { + const makeRequest = useCallback(async () => { + return new Promise(resolve => + setTimeout(() => resolve(secondValue), 500) + ) + }, []) + const requestReady = useMemo(() => true, []) + return useCached(key, makeRequest, expirey, requestReady) + }) + + // Value should be ready right away since it's a valid cache value. + expect(result.current.status).toEqual(RequestStatus.Successful) + + await act(async () => { + successful(result.current)!.bustCache() + }) + + expect(result.current.status).toEqual(RequestStatus.InProgress) + + await act(async () => { + await waitForNextUpdate() + }) + + const actual = successful(result.current) + + expect(actual).not.toBeUndefined() + expect(actual!.value).toEqual(secondValue) + }) + + test("and switch to another value in cache, return other value", async () => { + const firstKey = "firstKey" as StorageKey + const firstValue = { + hi: "there", + } + const secondKey = "secondKey" as StorageKey + const secondValue = { + alsoHi: "alsoThere", + } + + window.localStorage.setItem( + firstKey, + JSON.stringify({ + value: firstValue, + "@@_lastFetched": moment.now(), + "@@_cacheKey": firstKey, + }) + ) + + window.localStorage.setItem( + secondKey, + JSON.stringify({ + value: secondValue, + "@@_lastFetched": moment.now(), + "@@_cacheKey": secondKey, + }) + ) + + const { result } = renderHook(() => { + const [key, setKey] = useState(firstKey) + const makeRequest = useCallback(async () => { + return secondValue + }, []) + const requestReady = useMemo(() => true, []) + const sut = useCached(key, makeRequest, expirey, requestReady) + return { sut, setKey } + }) + + expect(successful(result.current.sut)).not.toBeUndefined() + expect(successful(result.current.sut)!.value).toEqual(firstValue) + + act(() => { + result.current.setKey(secondKey) + }) + + expect(successful(result.current.sut)).not.toBeUndefined() + expect(successful(result.current.sut)!.value).toEqual(secondValue) + }) + + test("and switch to another value not in cache, re-runs request and sets to InProgress", async () => { + const firstKey = "firstKey" as StorageKey + const firstValue = { + hi: "there", + } + const secondKey = "secondKey" as StorageKey + const secondValue = { + alsoHi: "alsoThere", + } + + window.localStorage.setItem( + firstKey, + JSON.stringify({ + value: firstValue, + "@@_lastFetched": moment.now(), + "@@_cacheKey": firstKey, + }) + ) + + const { result, waitForNextUpdate } = renderHook(() => { + const [key, setKey] = useState(firstKey) + const makeRequest = useCallback(async () => { + return secondValue + }, []) + const requestReady = useMemo(() => true, []) + const sut = useCached(key, makeRequest, expirey, requestReady) + return { sut, setKey } + }) + + expect(successful(result.current.sut)).not.toBeUndefined() + expect(successful(result.current.sut)!.value).toEqual(firstValue) + + act(() => { + result.current.setKey(secondKey) + }) + + expect(inProgress(result.current.sut)).not.toBeUndefined() await act(async () => { await waitForNextUpdate() }) - expect(result.current.value).toEqual(expectedValue) + expect(successful(result.current.sut)).not.toBeUndefined() + expect(successful(result.current.sut)!.value).toEqual(secondValue) }) }) }) diff --git a/src/hooks/useCached.ts b/src/hooks/useCached.ts index 300a9e8e..e606aff6 100644 --- a/src/hooks/useCached.ts +++ b/src/hooks/useCached.ts @@ -1,7 +1,9 @@ import { StorageKey } from "@/constants" +import { Requestable, RequestStatus } from "@/types" import moment from "moment" -import { useCallback, useEffect, useMemo } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { usePersistantObject } from "." +import useRequestStatus from "./useRequestStatus" interface Cached { "@@_lastFetched": number @@ -9,58 +11,112 @@ interface Cached { value: T } -const useCached = ( +const cacheValueValid = ( + cached: Cached | undefined, + maxAge: moment.Duration +): boolean => { + const now = moment() + const cacheTime = cached?.["@@_lastFetched"] + + if (cacheTime === undefined || now.isAfter(moment(cacheTime).add(maxAge))) { + return false + } + return true +} + +interface Successful { + value: T + bustCache: () => Promise +} + +interface Failed { + error: E | undefined +} + +const useCached = ( cacheKey: StorageKey, makeRequest: () => Promise, maxAge: moment.Duration, requestReady: boolean, - onError?: (e: any) => void -): { value: T | undefined; bustCache: () => Promise } => { + onSuccess?: () => void, + onFailure?: () => void +): Requestable, {}, {}, Failed> => { const [cached, setCached] = usePersistantObject>(cacheKey) + const { status, setInProgress, setFailed, setSuccessful } = useRequestStatus( + cacheValueValid(cached, maxAge) + ? RequestStatus.Successful + : RequestStatus.InProgress + ) + const [error, setError] = useState() const updateCachedValue = useCallback(async () => { if (requestReady === false) { return } try { + setInProgress() const t = await makeRequest() const now = moment.now() - setCached({ "@@_lastFetched": now, value: t, "@@_cacheKey": cacheKey }) + setCached({ "@@_lastFetched": now, "@@_cacheKey": cacheKey, value: t }) + setSuccessful() + onSuccess && onSuccess() } catch (e) { - onError - ? onError(e) - : console.error("An unhandled error has occured, ", e) + setError(e) + setFailed() + onFailure && onFailure() } - }, [makeRequest, setCached, onError, cacheKey, requestReady]) + }, [ + makeRequest, + setCached, + cacheKey, + requestReady, + setSuccessful, + onSuccess, + onFailure, + setFailed, + setInProgress, + ]) useEffect(() => { if (cached === undefined) { updateCachedValue() } else { - const cacheTime = cached["@@_lastFetched"] const now = moment() + const cacheTime = cached["@@_lastFetched"] if ( cacheTime === undefined || - now.isAfter(moment(cached["@@_lastFetched"]).add(maxAge)) + now.isAfter(moment(cacheTime).add(maxAge)) ) { updateCachedValue() } else { return } } - }, [cached, maxAge, onError, updateCachedValue]) + }, [requestReady, cached, setCached, makeRequest, maxAge, updateCachedValue]) const bustCache = useCallback(async () => { - await updateCachedValue() + return updateCachedValue() }, [updateCachedValue]) - return useMemo( - () => ({ - value: cached?.value, - bustCache, - }), - [cached, bustCache, maxAge] - ) + return useMemo(() => { + switch (status) { + case RequestStatus.NotStarted: + return { status } + case RequestStatus.InProgress: + return { status } + case RequestStatus.Failed: { + return { status, error } + } + case RequestStatus.Successful: { + // If the cache is undefined, but the status is Successful, that means + // the key was just changed. + if (cached === undefined) { + return { status: RequestStatus.InProgress } + } + return { status, value: cached.value, bustCache } + } + } + }, [status, cached, bustCache, error]) } export default useCached diff --git a/src/hooks/useGapi.ts b/src/hooks/useGapi.ts deleted file mode 100644 index 394f9439..00000000 --- a/src/hooks/useGapi.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useSelector } from "react-redux" - -export default () => { - const gapi = useSelector((state: AppState) => state.gapi) - return gapi -} diff --git a/src/hooks/usePaginatedCallback.ts b/src/hooks/usePaginatedCallback.ts index e554747e..60405bad 100644 --- a/src/hooks/usePaginatedCallback.ts +++ b/src/hooks/usePaginatedCallback.ts @@ -1,40 +1,40 @@ import { useCallback } from "react" +const MaxPages = 100 + const usePaginatedCallback = ( requestReady: boolean, errorMessage: string, - paginatedRequest: (pageToken: string | undefined) => gapi.client.Request, + paginatedRequest: ( + pageToken: string | undefined + ) => Promise>, getTs: (u: U) => T[] | undefined, getPageToken: (u: U) => string | undefined, - onBeforeRequest?: () => void, - onError?: () => void + onBeforeRequest?: () => void ): (() => Promise) => { return useCallback(async () => { - try { - if (!requestReady) { - throw new Error(errorMessage) - } - onBeforeRequest && onBeforeRequest() - let pageToken: string | undefined = undefined - let ts: T[] = [] - do { - pageToken = undefined - const u = await paginatedRequest(pageToken) - - const nextTs = getTs(u.result) + if (!requestReady) { + throw new Error(errorMessage) + } + onBeforeRequest && onBeforeRequest() + let pageToken: string | undefined = undefined + let ts: T[] = [] + let page = 0 + do { + const u = await paginatedRequest(pageToken) - ts = ts.concat(nextTs || []) + const nextTs = getTs(u.result) - const nextPageToken = getPageToken(u.result) + ts = ts.concat(nextTs || []) - if (nextPageToken) { - pageToken = nextPageToken - } - } while (pageToken !== undefined) - return ts - } catch (e) { - onError && onError() + const nextPageToken = getPageToken(u.result) + pageToken = nextPageToken + page++ + } while (pageToken !== undefined && page < MaxPages) + if (page >= MaxPages) { + throw new Error("went past max pagination.") } + return ts }, [ requestReady, errorMessage, @@ -42,7 +42,6 @@ const usePaginatedCallback = ( getTs, getPageToken, onBeforeRequest, - onError, ]) } diff --git a/src/test-utils/fakes/ga4/listAccountSummaries.ts b/src/test-utils/fakes/ga4/listAccountSummaries.ts new file mode 100644 index 00000000..ba9e8b92 --- /dev/null +++ b/src/test-utils/fakes/ga4/listAccountSummaries.ts @@ -0,0 +1,58 @@ +import { AccountSummary } from "@/types/ga4/StreamPicker" + +const page1Summaries: AccountSummary[] = [ + { + account: "accounts/def456", + displayName: "my second account", + name: "accountSummaries/def", + propertySummaries: [ + { + displayName: "my third property", + property: "properties/3", + }, + { + displayName: "my fourth property", + property: "properties/4", + }, + ], + }, +] +const page2Summaries: AccountSummary[] = [ + { + account: "accounts/abc123", + displayName: "my first account", + name: "accountSummaries/abc", + propertySummaries: [ + { + displayName: "my first property", + property: "properties/1", + }, + { + displayName: "my second property", + property: "properties/2", + }, + ], + }, +] + +const listAccountSummaries: typeof gapi.client.analyticsadmin.accountSummaries.list = ({ + pageToken, +} = {}) => { + switch (pageToken) { + case "page2": + return Promise.resolve({ + result: { + accountSummaries: page2Summaries, + }, + }) as any + case undefined: + return Promise.resolve({ + result: { + accountSummaries: page1Summaries, + nextPageToken: "page2", + }, + }) as any + } +} + +export default listAccountSummaries diff --git a/src/test-utils/fakes/ua/listAccountSummaries.ts b/src/test-utils/fakes/ua/listAccountSummaries.ts new file mode 100644 index 00000000..98f1a535 --- /dev/null +++ b/src/test-utils/fakes/ua/listAccountSummaries.ts @@ -0,0 +1,40 @@ +import { AccountSummary } from "@/types/ua" + +const accountSummaries: AccountSummary[] = [ + { + id: "account-id-1", + name: "Account Name 1", + webProperties: [ + { + id: "property-id-1-1", + name: "Property Name 1 1", + profiles: [ + { id: "view-id-1-1-1", name: "View Name 1 1 1" }, + { id: "view-id-1-1-2", name: "View Name 1 1 2" }, + ], + }, + { + id: "property-id-1-2", + name: "Property Name 1 2", + profiles: [{ id: "view-id-1-2-1", name: "View Name 1 2 1" }], + }, + ], + }, + { + id: "account-id-2", + name: "Account Name 2", + webProperties: [ + { + id: "property-id-2-1", + name: "Property Name 2 1", + profiles: [{ id: "view-id-2-1-1", name: "View Name 2 1 1" }], + }, + ], + }, +] + +const listAccountSummaries: typeof gapi.client.analytics.management.accountSummaries.list = () => { + return Promise.resolve({ result: { items: accountSummaries } }) as any +} + +export default listAccountSummaries diff --git a/src/test-utils.tsx b/src/test-utils/fakes/ua/listColumns.ts similarity index 54% rename from src/test-utils.tsx rename to src/test-utils/fakes/ua/listColumns.ts index bc423a0a..4ea1b45b 100644 --- a/src/test-utils.tsx +++ b/src/test-utils/fakes/ua/listColumns.ts @@ -1,149 +1,6 @@ -// Copyright 2020 Google Inc. All rights reserved. -// -// 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 { Column } from "@/types/ua" -import * as React from "react" -import { Provider } from "react-redux" -import { makeStore } from "../gatsby/wrapRootElement" -import { - createHistory, - createMemorySource, - LocationProvider, - History, -} from "@reach/router" -import { AccountSummary, Column } from "./api" -import { QueryParamProvider } from "use-query-params" -import { PartialDeep } from "type-fest" - -interface WithProvidersConfig { - path?: string - isLoggedIn?: boolean - clearStorage?: boolean -} - -export const wrapperFor: (options: { - path?: string - isLoggedIn?: boolean - setUp?: () => void - gapi?: PartialDeep - clearStorage?: boolean -}) => React.FC = ({ path, isLoggedIn, setUp, gapi, clearStorage }) => { - path = path || "/" - isLoggedIn = isLoggedIn === undefined ? true : isLoggedIn - - clearStorage && window.localStorage.clear() - setUp && setUp() - - const history = createHistory(createMemorySource(path)) - const store = makeStore() - - if (isLoggedIn) { - store.dispatch({ type: "setUser", user: {} }) - } else { - store.dispatch({ type: "setUser", user: undefined }) - } - if (gapi) { - store.dispatch({ type: "setGapi", gapi }) - } - - const Wrapper: React.FC = ({ children }) => ( - - - - {children} - - - - ) - return Wrapper -} -export const TestWrapper = wrapperFor({}) - -export const withProviders = ( - component: JSX.Element | null, - { path, isLoggedIn, clearStorage }: WithProvidersConfig = { - path: "/", - isLoggedIn: true, - } -): { - wrapped: JSX.Element - history: History - store: any - gapi?: PartialDeep -} => { - path = path || "/" - isLoggedIn = isLoggedIn === undefined ? true : isLoggedIn - - clearStorage && window.localStorage.clear() - - const history = createHistory(createMemorySource(path)) - const store = makeStore() - - if (isLoggedIn) { - store.dispatch({ type: "setUser", user: {} }) - } else { - store.dispatch({ type: "setUser", user: undefined }) - } - - const gapi = testGapi() - store.dispatch({ type: "setGapi", gapi }) - - const wrapped = ( - - - - {component} - - - - ) - return { wrapped, history, store, gapi } -} - -const testAccounts: AccountSummary[] = [ - { - id: "account-id-1", - name: "Account Name 1", - webProperties: [ - { - id: "property-id-1-1", - name: "Property Name 1 1", - profiles: [ - { id: "view-id-1-1-1", name: "View Name 1 1 1" }, - { id: "view-id-1-1-2", name: "View Name 1 1 2" }, - ], - }, - { - id: "property-id-1-2", - name: "Property Name 1 2", - profiles: [{ id: "view-id-1-2-1", name: "View Name 1 2 1" }], - }, - ], - }, - { - id: "account-id-2", - name: "Account Name 2", - webProperties: [ - { - id: "property-id-2-1", - name: "Property Name 2 1", - profiles: [{ id: "view-id-2-1-1", name: "View Name 2 1 1" }], - }, - ], - }, -] - -const testColumns: Column[] = [ +const columns: Column[] = [ { id: "ga:userType", kind: "analytics#column", @@ -425,121 +282,8 @@ const testColumns: Column[] = [ }, ] -const listPromise = Promise.resolve({ result: { items: testAccounts } }) -// TODO - add in real type. -const metadataColumnsPromise = Promise.resolve({ - result: { - items: testColumns, - etag: "abc123", - }, -}) +const listColumns: typeof gapi.client.analytics.metadata.columns.list = () => { + return Promise.resolve({ result: { items: columns } }) as any +} -export const testGapi = () => ({ - client: { - analyticsadmin: { - properties: { - iosAppDataStreams: { - list: (): Promise<{ - result: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListIosAppDataStreamsResponse - }> => - Promise.resolve({ - result: { - iosAppDataStreams: [ - { name: "iosStream", displayName: "My ios stream" }, - ], - }, - }), - }, - androidAppDataStreams: { - list: (): Promise<{ - result: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListAndroidAppDataStreamsResponse - }> => - Promise.resolve({ - result: { - androidAppDataStreams: [ - { name: "androidStream", displayName: "my android stream" }, - ], - }, - }), - }, - webDataStreams: { - list: (): Promise<{ - result: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListWebDataStreamsResponse - }> => - Promise.resolve({ - result: { - webDataStreams: [ - { name: "webStream", displayName: "my web stream" }, - ], - }, - }), - }, - }, - accountSummaries: { - list: ({ - pageToken, - }: { - pageToken?: string - }): Promise<{ - result: gapi.client.analyticsadmin.GoogleAnalyticsAdminV1alphaListAccountSummariesResponse - }> => { - if (pageToken === "1") { - return Promise.resolve({ - result: { - accountSummaries: [ - { - account: "accounts/def456", - displayName: "my second account", - name: "accountSummaries/def", - propertySummaries: [ - { - displayName: "my third property", - property: "properties/3", - }, - { - displayName: "my fourth property", - property: "properties/4", - }, - ], - }, - ], - }, - }) - } - return Promise.resolve({ - result: { - accountSummaries: [ - { - account: "accounts/abc123", - displayName: "my first account", - name: "accountSummaries/abc", - propertySummaries: [ - { - displayName: "my first property", - property: "properties/1", - }, - { - displayName: "my second property", - property: "properties/2", - }, - ], - }, - ], - nextPageToken: "1", - }, - }) - }, - }, - }, - analytics: { - management: { accountSummaries: { list: () => listPromise } }, - metadata: { - columns: { - list: () => { - return metadataColumnsPromise - }, - }, - }, - }, - }, -}) +export default listColumns diff --git a/src/test-utils/gapi.ts b/src/test-utils/gapi.ts new file mode 100644 index 00000000..062e8c68 --- /dev/null +++ b/src/test-utils/gapi.ts @@ -0,0 +1,52 @@ +import { PartialDeep } from "type-fest" +import ga4ListAccountSummariesFake from "./fakes/ga4/listAccountSummaries" +import uaListAccountSummariesFake from "./fakes/ua/listAccountSummaries" +import uaListColumnsFake from "./fakes/ua/listColumns" + +export interface GapiMocks { + ga4?: { + listAccountSummaries?: typeof gapi.client.analyticsadmin.accountSummaries.list + } + ua?: { + listAccountSummaries?: typeof gapi.client.analytics.management.accountSummaries.list + } +} + +export const testGapi = (mocks?: GapiMocks): PartialDeep => { + return { + client: { + analyticsadmin: { + properties: { + iosAppDataStreams: { + list: jest.fn(), + }, + androidAppDataStreams: { + list: jest.fn(), + }, + webDataStreams: { + list: jest.fn(), + }, + }, + accountSummaries: { + list: mocks?.ga4?.listAccountSummaries + ? mocks.ga4.listAccountSummaries + : ga4ListAccountSummariesFake, + }, + }, + analytics: { + management: { + accountSummaries: { + list: mocks?.ua?.listAccountSummaries + ? mocks.ua.listAccountSummaries + : uaListAccountSummariesFake, + }, + }, + metadata: { + columns: { + list: uaListColumnsFake, + }, + }, + }, + }, + } +} diff --git a/src/test-utils/index.tsx b/src/test-utils/index.tsx new file mode 100644 index 00000000..d45bf3f4 --- /dev/null +++ b/src/test-utils/index.tsx @@ -0,0 +1,110 @@ +// Copyright 2020 Google Inc. All rights reserved. +// +// 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 * as React from "react" +import { Provider } from "react-redux" +import { makeStore } from "../../gatsby/wrapRootElement" +import { + createHistory, + createMemorySource, + LocationProvider, + History, +} from "@reach/router" +import { QueryParamProvider } from "use-query-params" +import { GapiMocks, testGapi } from "./gapi" + +export { testGapi } from "./gapi" + +export interface WithProvidersConfig extends GapiMocks { + path?: string + isLoggedIn?: boolean + setUp?: () => void +} + +export const wrapperFor = ({ + path = "/", + isLoggedIn = true, + setUp, + ...gapiMocks +}: WithProvidersConfig = {}): React.FC => { + window.localStorage.clear() + setUp && setUp() + + const history = createHistory(createMemorySource(path)) + const store = makeStore() + + if (isLoggedIn) { + store.dispatch({ type: "setUser", user: {} }) + } else { + store.dispatch({ type: "setUser", user: undefined }) + } + + const gapi = testGapi(gapiMocks) + store.dispatch({ type: "setGapi", gapi }) + + const Wrapper: React.FC = ({ children }) => ( + + + + {children} + + + + ) + return Wrapper +} +export const TestWrapper = wrapperFor() + +export const withProviders = ( + component: JSX.Element | null, + { path, isLoggedIn, ...gapiMocks }: WithProvidersConfig = { + path: "/", + isLoggedIn: true, + ga4: {}, + ua: {}, + } +): { + wrapped: JSX.Element + history: History + store: any + gapi: ReturnType +} => { + path = path || "/" + isLoggedIn = isLoggedIn === undefined ? true : isLoggedIn + + window.localStorage.clear() + + const history = createHistory(createMemorySource(path)) + const store = makeStore() + + if (isLoggedIn) { + store.dispatch({ type: "setUser", user: {} }) + } else { + store.dispatch({ type: "setUser", user: undefined }) + } + + const gapi = testGapi(gapiMocks) + store.dispatch({ type: "setGapi", gapi }) + + const wrapped = ( + + + + {component} + + + + ) + return { wrapped, history, store, gapi } +} diff --git a/src/types/index.ts b/src/types/index.ts index 718357fa..b0a31859 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,10 +6,10 @@ export interface WithEtag { export type Dispatch = React.Dispatch> export enum RequestStatus { - Successful, - NotStarted, - InProgress, - Failed, + Successful = "successful", + NotStarted = "not-started", + InProgress = "in-progress", + Failed = "failed", } export type Requestable< diff --git a/src/types/ua/index.ts b/src/types/ua/index.ts new file mode 100644 index 00000000..a339e417 --- /dev/null +++ b/src/types/ua/index.ts @@ -0,0 +1,12 @@ +export type Column = gapi.client.analytics.Column +export type Segment = gapi.client.analytics.Segment + +export type AccountSummary = gapi.client.analytics.AccountSummary +export type WebPropertySummary = gapi.client.analytics.WebPropertySummary +export type ProfileSummary = gapi.client.analytics.ProfileSummary + +export interface UAAccountPropertyView { + account?: AccountSummary + property?: WebPropertySummary + view?: ProfileSummary +}