diff --git a/App.jsx b/App.jsx index a2bfb66..5da8698 100644 --- a/App.jsx +++ b/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter } from "react-router-dom"; import { NavigationMenu } from "@shopify/app-bridge-react"; import Routes from "./Routes"; +import React from "react"; import { AppBridgeProvider, @@ -21,8 +22,12 @@ export default function App() { diff --git a/components/ProductTagsInput.jsx b/components/ProductTagsInput.jsx new file mode 100644 index 0000000..d02734c --- /dev/null +++ b/components/ProductTagsInput.jsx @@ -0,0 +1,144 @@ +import { + LegacyStack, + Tag, + Listbox, + Combobox, + Icon, + TextContainer, +} from "@shopify/polaris"; + +import { SearchMinor } from "@shopify/polaris-icons"; +import React from "react"; + +import { useState, useCallback } from "react"; + +export default function ProductTagsInput({ + getTags, + tagsToUpdate, + setTagsToUpdate, +}) { + const [deselectedOptions, setDeselectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + async function getProductTags() { + // only fetch the tags once + if (deselectedOptions.length > 0) { + return; + } + + setLoading(true); + const tags = await getTags(); + + const options = tags.map((tag) => { + return { value: tag, label: tag }; + }); + + setDeselectedOptions(options); + setOptions(options); + setLoading(false); + } + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === "") { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, "i"); + const resultOptions = deselectedOptions.filter((option) => + option.label.match(filterRegex) + ); + setOptions(resultOptions); + }, + [deselectedOptions] + ); + + const updateSelection = useCallback( + (selected) => { + if (tagsToUpdate.includes(selected)) { + setTagsToUpdate(tagsToUpdate.filter((option) => option !== selected)); + } else { + setTagsToUpdate([...tagsToUpdate, selected]); + } + + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + + updateText(""); + }, + [options, tagsToUpdate, updateText] + ); + + const removeTag = useCallback( + (tag) => () => { + const options = [...tagsToUpdate]; + options.splice(options.indexOf(tag), 1); + setTagsToUpdate(options); + }, + [tagsToUpdate] + ); + + const tagsMarkup = tagsToUpdate.map((option) => ( + + {option} + + )); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const { label, value } = option; + + return ( + + {label} + + ); + }) + : null; + + const loadingMarkup = loading ? : null; + + const listboxMarkup = + optionsMarkup || loadingMarkup ? ( + + {optionsMarkup && !loading ? optionsMarkup : null} + {loadingMarkup} + + ) : null; + + return ( +
+
+ {tagsMarkup} +
+ } + onChange={updateText} + label="Search tags to add or remove from products" + labelHidden + value={inputValue} + placeholder="Search tags to add or remove from products" + onFocus={getProductTags} + /> + } + > + {listboxMarkup} + +
+ ); +} diff --git a/components/ProductsCard.jsx b/components/ProductsCard.jsx deleted file mode 100644 index e61c656..0000000 --- a/components/ProductsCard.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useState } from "react"; -import { - Card, - Heading, - TextContainer, - DisplayText, - TextStyle, -} from "@shopify/polaris"; -import { Toast } from "@shopify/app-bridge-react"; -import { useAppQuery, useAuthenticatedFetch } from "../hooks"; - -export function ProductsCard() { - const emptyToastProps = { content: null }; - const [isLoading, setIsLoading] = useState(true); - const [toastProps, setToastProps] = useState(emptyToastProps); - const fetch = useAuthenticatedFetch(); - - const { - data, - refetch: refetchProductCount, - isLoading: isLoadingCount, - isRefetching: isRefetchingCount, - } = useAppQuery({ - url: "/api/products/count", - reactQueryOptions: { - onSuccess: () => { - setIsLoading(false); - }, - }, - }); - - const toastMarkup = toastProps.content && !isRefetchingCount && ( - setToastProps(emptyToastProps)} /> - ); - - const handlePopulate = async () => { - setIsLoading(true); - const response = await fetch("/api/products/create"); - - if (response.ok) { - await refetchProductCount(); - setToastProps({ content: "5 products created!" }); - } else { - setIsLoading(false); - setToastProps({ - content: "There was an error creating products", - error: true, - }); - } - }; - - return ( - <> - {toastMarkup} - - -

- Sample products are created with a default title and price. You can - remove them at any time. -

- - TOTAL PRODUCTS - - - {isLoadingCount ? "-" : data.count} - - - -
-
- - ); -} diff --git a/components/ProductsTable.jsx b/components/ProductsTable.jsx new file mode 100644 index 0000000..147bcde --- /dev/null +++ b/components/ProductsTable.jsx @@ -0,0 +1,86 @@ +import { + IndexTable, + LegacyCard, + LegacyStack, + useIndexResourceState, + Text, + Badge, + EmptySearchResult, +} from "@shopify/polaris"; +import React from "react"; + +export default function ProductsTable({ + productsArray, + addTags, + removeTags, + isLoading = false, + tagsToUpdate, +}) { + const { selectedResources, allResourcesSelected, handleSelectionChange } = + useIndexResourceState(productsArray); + + const promotedBulkActions = [ + { + content: "Add selected tags", + onAction: () => addTags(selectedResources), + }, + { + content: "Remove selected tags", + onAction: () => removeTags(selectedResources), + }, + ]; + + const rowMarkup = productsArray.map(({ id, title, tags }, index) => ( + + + + {title} + + + + + {tags.map((tag) => ( + {tag} + ))} + + + + )); + + return ( + + } + > + {rowMarkup} + + + ); +} + +function EmptyState() { + return ( + + ); +} diff --git a/components/index.js b/components/index.js index f58187d..5532383 100644 --- a/components/index.js +++ b/components/index.js @@ -1,2 +1 @@ -export { ProductsCard } from "./ProductsCard"; export * from "./providers"; diff --git a/package.json b/package.json index 288666c..9990e57 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@shopify/app-bridge": "^3.1.0", "@shopify/app-bridge-react": "^3.1.0", "@shopify/app-bridge-utils": "^3.1.0", - "@shopify/polaris": "^9.11.0", + "@shopify/polaris": "^10.35.0", "@vitejs/plugin-react": "1.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/pages/index.jsx b/pages/index.jsx index a7d2635..006fff3 100644 --- a/pages/index.jsx +++ b/pages/index.jsx @@ -1,5 +1,5 @@ import { - Card, + LegacyCard, Page, Layout, TextContainer, @@ -8,77 +8,94 @@ import { Link, Heading, } from "@shopify/polaris"; -import { TitleBar } from "@shopify/app-bridge-react"; +import { TitleBar, useNavigate } from "@shopify/app-bridge-react"; import { trophyImage } from "../assets"; -import { ProductsCard } from "../components"; +import React from "react"; + +function IntroCard() { + const navigate = useNavigate(); + + return ( + + + + + Nice work on building a Shopify app 🎉 +

+ Your app is ready to explore! It contains everything you need to + get started including the{" "} + + Polaris design system + + ,{" "} + + Shopify Admin API + + , and{" "} + + App Bridge + {" "} + UI library and components. +

+

+ Ready to go? Explore the{" "} + { + navigate("/product-tagger"); + }} + > + product tagger + {" "} + to see a sample of how to build an app. +

+

+ Learn more about building your app in{" "} + + this Shopify tutorial + {" "} + 📚{" "} +

+
+
+ +
+ Nice work on building a Shopify app +
+
+
+
+ ); +} export default function HomePage() { + const navigate = useNavigate(); + return ( - + navigate("/product-tagger"), + }} + /> - - - - - Nice work on building a Shopify app 🎉 -

- Your app is ready to explore! It contains everything you - need to get started including the{" "} - - Polaris design system - - ,{" "} - - Shopify Admin API - - , and{" "} - - App Bridge - {" "} - UI library and components. -

-

- Ready to go? Start populating your app with some sample - products to view and test in your store.{" "} -

-

- Learn more about building out your app in{" "} - - this Shopify tutorial - {" "} - 📚{" "} -

-
-
- -
- Nice work on building a Shopify app -
-
-
-
-
- - +
diff --git a/pages/product-tagger.jsx b/pages/product-tagger.jsx new file mode 100644 index 0000000..e2e6d30 --- /dev/null +++ b/pages/product-tagger.jsx @@ -0,0 +1,110 @@ +import { Page, Layout } from "@shopify/polaris"; +import { TitleBar, ResourcePicker, useToast } from "@shopify/app-bridge-react"; + +import { useAuthenticatedFetch } from "../hooks/useAuthenticatedFetch"; +import ProductsTable from "../components/ProductsTable"; +import ProductTagsInput from "../components/ProductTagsInput"; + +import React, { useState } from "react"; + +export default function HomePage() { + // Custom implementation of fetch that adds Shopify auth + const fetch = useAuthenticatedFetch(); + const { show } = useToast(); + const [productSelectorOpen, setProductSelectorOpen] = useState(false); + const [productsTableData, setProductsTableData] = useState([]); + const [tagsToUpdate, setTagsToUpdate] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + function handleProductSelection(selectPayload) { + setProductsTableData(selectPayload.selection); + setProductSelectorOpen(false); + } + + async function addTags(params) { + setIsLoading(true); + const response = await fetch("/api/producttags", { + method: "POST", + body: JSON.stringify({ products: params, tags: tagsToUpdate }), + headers: { "Content-Type": "application/json" }, + }); + setIsLoading(false); + + if (response?.ok) { + show("Tags added successfully, they may not show immediately"); + } else { + show("Error adding tags, please try again", { isError: true }); + console.error("error response", response); + } + } + + async function removeTags(params) { + setIsLoading(true); + const response = await fetch("/api/producttags", { + method: "DELETE", + body: JSON.stringify({ products: params, tags: tagsToUpdate }), + headers: { "Content-Type": "application/json" }, + }); + setIsLoading(false); + + if (response?.ok) { + show("Tags removed successfully, they may still show for a while"); + } else { + show("Error removing tags, please try again", { isError: true }); + console.error("error response", response); + } + } + + async function getTags() { + const response = await fetch("/api/producttags", { + method: "GET", + }); + + if (response?.ok) { + const data = await response.json(); + return data?.tags || []; + } else { + show("Error getting tags", { isError: true }); + console.error("error response", response); + } + } + + return ( + + setProductSelectorOpen(true), + }} + /> + + + + + + + + + + {/* Resource picker opens as a modal via App Bridge */} + setProductSelectorOpen(false)} + onSelection={handleProductSelection} + /> + + ); +}