Add data-importer app

This commit is contained in:
Lukasz Ostrowski 2023-02-07 19:11:39 +01:00
parent f867f2e2db
commit 073ce83e30
48 changed files with 134114 additions and 0 deletions

View file

@ -0,0 +1,2 @@
# https://getnuvo.com/
NEXT_PUBLIC_NUVO_LICENSE_KEY=

View file

@ -0,0 +1,3 @@
{
"extends": ["next", "prettier"]
}

View file

@ -0,0 +1,20 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1 @@
strict-peer-dependencies=false

View file

@ -0,0 +1 @@
v16.13.0

View file

@ -0,0 +1,5 @@
.next
saleor/api.tsx
pnpm-lock.yaml
graphql/schema.graphql
generated

View file

@ -0,0 +1,4 @@
{
"singleQuote": false,
"printWidth": 100
}

View file

@ -0,0 +1 @@
* @saleor/appstore

View file

@ -0,0 +1,37 @@
BSD 3-Clause License
Copyright (c) 2020-2022, Saleor Commerce
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------
Unless stated otherwise, artwork included in this distribution is licensed
under the Creative Commons Attribution 4.0 International License.
You can learn more about the permitted use by visiting
https://creativecommons.org/licenses/by/4.0/

View file

@ -0,0 +1,144 @@
![Saleor App Template](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>Saleor App Template</h1>
</div>
<div align="center">
<p>Bare-bones boilerplate for writing Saleor Apps with Next.js.</p>
</div>
<div align="center">
<a href="https://saleor.io/">🏠 Website</a>
<span></span>
<a href="https://docs.saleor.io/docs/3.x/">📚 Docs</a>
<span></span>
<a href="https://saleor.io/blog/">📰 Blog</a>
<span></span>
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
</div>
<div align="center">
<a href="https://githubbox.com/saleor/saleor-app-template">🔎 Explore Code</a>
</div>
## About
### What is Saleor App
Saleor App is the fastest way of extending Saleor with custom logic using [asynchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks) and [synchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks) webhooks (and vast Saleor's API). In most cases, creating an App consists of two tasks:
- Writing webhook's code executing your custom logic.
- Developing configuration UI to be displayed in Saleor Dashboard via specialized view (designated in the App's manifest).
### What's included?
- 🚀 Communication between Saleor instance and Saleor App
- 📖 Manifest with webhooks using custom query
### Why Next.js
You can use any preferred technology to create Saleor Apps, but Next.js is among the most efficient for two reasons. The first is the simplicity of maintaining your API endpoints/webhooks and your apps' configuration React front-end in a single, well-organized project. The second reason is the ease and quality of local development and deployment.
### Learn more about Apps
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
[Configuring apps in dashboard](https://docs.saleor.io/docs/3.x/dashboard/apps)
## Development
### Requirements
Before you start, make sure you have installed:
- [Node.js](https://nodejs.org/en/)
- [pnpm](https://pnpm.io/)
- [Saleor CLI](https://docs.saleor.io/docs/3.x/cli) - optional, but recommended
### With CLI
The easiest way to set up a Saleor app is by using the Saleor CLI.
[Saleor CLI](https://github.com/saleor/saleor-cli) is designed to save you from the repetitive chores around Saleor development, including creating Apps. It will take the burden of spawning new apps locally, connecting them with Saleor environments, and establishing a tunnel for local development in seconds.
[Full Saleor CLI reference](https://docs.saleor.io/docs/3.x/developer/cli)
If you don't have a (free developer) Saleor Cloud account, create one with the following command:
```
saleor register
```
Now you're ready to create your first App:
```
saleor app create [your-app-name]
```
In this step, Saleor CLI will:
- clone this repository to the specified folder
- install dependencies
- ask you whether you'd like to install the app in the selected Saleor environment
- create `.env` file
- start the app in development mode
Having your app ready, the final thing you want to establish is a tunnel with your Saleor environment. Go to your app's directory first and run:
```
saleor app tunnel
```
Your local application should be available now to the outside world (Saleor instance) for accepting all the events via webhooks.
A quick note: the next time you come back to your project, it is enough to launch your app in a standard way (and then launch your tunnel as described earlier):
```
pnpm dev
```
### Without CLI
1. Install the dependencies by running:
```
pnpm install
```
2. Start the local server with:
```
pnpm dev
```
3. Expose local environment using tunnel:
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
4. Install aplication at your dashboard:
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
```
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
```
This template host manifest at `/api/manifest`
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
### Generated schema and typings
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
### Storing registration data - APL
During registration process Saleor API pass the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
To store this data, app-template use a different [APL interfaces](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. Can't be used for multi tenant-apps or be deployed (not intended for production)
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. Can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
- `vercel`: used by deployments from the Marketplace. It's single-tenant only and only supported by Vercel deployments done with Saleor CLI. Requires `SALEOR_REGISTER_APP_URL` and `SALEOR_DEPLOYMENT_TOKEN` environment variables to be set (handled automatically by the Saleor CLI)
If you want to use your own database, you can implement your own APL. [Check the documentation to read more.](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
mutation CustomerCreate($input: UserCreateInput!) {
customerCreate(input: $input) {
user {
id
}
errors {
message
}
}
}

View file

@ -0,0 +1,9 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

File diff suppressed because it is too large Load diff

5
apps/data-importer/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -0,0 +1,11 @@
/**
* TODO This should be solved using transpilePackages option
*/
const withTM = require("next-transpile-modules")(["nuvo-react"]);
module.exports = withTM({
reactStrictMode: true,
experimental: {
esmExternals: true,
},
});

View file

@ -0,0 +1,67 @@
{
"name": "saleor-app-data-importer",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"build": "pnpm generate && next build",
"start": "next start",
"lint": "next lint",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.7"
},
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.26.1",
"@saleor/macaw-ui": "^0.7.2",
"@urql/exchange-auth": "^1.0.0",
"@vitejs/plugin-react": "^3.0.0",
"clsx": "^1.2.1",
"dot-object": "^2.1.4",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"jose": "^4.11.2",
"jsdom": "^20.0.3",
"next": "12.3.4",
"nuvo-react": "^1.20.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",
"vite": "^4.0.1",
"vitest": "^0.25.8",
"zod": "^3.20.2"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.3",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-urql": "^3.7.0",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.1",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/dot-object": "^2.1.2",
"@types/node": "^18.8.1",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"eslint": "8.25.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "^8.5.0",
"next-transpile-modules": "^10.0.0",
"prettier": "^2.7.1",
"typescript": "4.8.4"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import {APL, FileAPL, SaleorCloudAPL, UpstashAPL, VercelAPL} from "@saleor/app-sdk/APL";
/**
* By default auth data are stored in the `.auth-data.json` (FileAPL).
* For multi-tenant applications and deployments please use UpstashAPL.
*
* To read more about storing auth data, read the
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
*/
export let apl: APL;
switch (process.env.APL) {
case "vercel":
apl = new VercelAPL();
break;
case "upstash":
// Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
apl = new UpstashAPL();
break;
case "saleor-cloud": {
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
}
apl = new SaleorCloudAPL({
resourceUrl: process.env.REST_APL_ENDPOINT,
token: process.env.REST_APL_TOKEN,
});
break;
}
default:
apl = new FileAPL();
}
export const saleorApp = new SaleorApp({
apl,
});

View file

@ -0,0 +1,48 @@
import { AuthConfig, authExchange } from "@urql/exchange-auth";
import {
cacheExchange,
createClient as urqlCreateClient,
dedupExchange,
fetchExchange,
} from "urql";
interface IAuthState {
token: string;
}
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
});

View file

@ -0,0 +1,7 @@
export function isInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -0,0 +1,28 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
}
}, [appBridgeState?.theme, setTheme, themeType]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -0,0 +1,31 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect, useState } from "react";
import * as jose from "jose";
/**
* TODO test
* TODO extract to app-sdk
*/
export function useAuthorizedToken(requirePermission: string) {
const [authorized, setAuthorized] = useState<boolean | undefined>();
const { appBridgeState } = useAppBridge();
useEffect(() => {
if (appBridgeState?.token) {
const decodedToken = jose.decodeJwt(appBridgeState.token);
try {
const userPermissions = decodedToken.user_permissions as string[];
if (userPermissions.includes(requirePermission)) {
setAuthorized(true);
}
} catch (e) {
setAuthorized(false);
}
}
}, [appBridgeState, requirePermission]);
return authorized;
}

View file

@ -0,0 +1,124 @@
import { ColumnAPI } from "nuvo-react";
import { z } from "zod";
const customerColumns: ColumnAPI[] = [
{
label: "Customer first name",
key: "customerCreate.firstName",
columnType: "string",
},
{
label: "Customer last name",
key: "customerCreate.lastName",
columnType: "string",
},
{
label: "Customer email",
key: "customerCreate.email",
columnType: "email",
validations: [
{
validate: "required",
},
{
validate: "unique",
},
],
},
{
label: "Note",
key: "customerCreate.note",
},
{
label: "External reference",
key: "customerCreate.externalReference",
description: "ID from another service",
},
];
const generateAddressColumns = (labelNamespace: string, keyNamespace: string): ColumnAPI[] => [
{
label: `${labelNamespace} -> First name`,
key: `customerCreate.${keyNamespace}.firstName`,
},
{
label: `${labelNamespace} -> Last name`,
key: `customerCreate.${keyNamespace}.lastName`,
},
{
label: `${labelNamespace} -> Company name`,
key: `customerCreate.${keyNamespace}.companyName`,
},
{
label: `${labelNamespace} -> Street address 1`,
key: `customerCreate.${keyNamespace}.streetAddress1`,
},
{
label: `${labelNamespace} -> Street address 2`,
key: `customerCreate.${keyNamespace}.streetAddress2`,
},
{
label: `${labelNamespace} -> City`,
key: `customerCreate.${keyNamespace}.city`,
},
{
label: `${labelNamespace} -> City Area`,
key: `customerCreate.${keyNamespace}.cityArea`,
},
{
label: `${labelNamespace} -> Postal code`,
key: `customerCreate.${keyNamespace}.postalCode`,
},
{
label: `${labelNamespace} -> Country`,
key: `customerCreate.${keyNamespace}.country`,
},
{
label: `${labelNamespace} -> Country Area`,
key: `customerCreate.${keyNamespace}.countryArea`,
},
{
label: `${labelNamespace} -> Phone`,
key: `customerCreate.${keyNamespace}.phone`,
},
];
// TODO - enable address columns when mapped
const allColumns: ColumnAPI[] = [
...customerColumns,
// ...generateAddressColumns("Default Billing Address", "defaultBillingAddress"),
// ...generateAddressColumns("Default Shipping Address", "defaultShippingAddress"),
];
export const getCustomersModelColumns = () => allColumns;
const zodAddressSchema = z
.object({
firstName: z.string().nullish(),
lastName: z.string().nullish(),
companyName: z.string().nullish(),
streetAddress1: z.string().nullish(),
streetAddress2: z.string().nullish(),
city: z.string().nullish(),
cityArea: z.string().nullish(),
postalCode: z.string().nullish(),
country: z.string().nullish(),
countryArea: z.string().nullish(),
phone: z.string().nullish(),
})
.nullable();
export const getResultModelSchema = () =>
z.object({
customerCreate: z.object({
firstName: z.string().nullish(),
lastName: z.string().nullish(),
email: z.string(),
note: z.string().nullish(),
externalReference: z.string().nullish(),
// defaultBillingAddress: zodAddressSchema,
// defaultShippingAddress: zodAddressSchema,
}),
});
export type CustomerColumnSchema = z.infer<ReturnType<typeof getResultModelSchema>>;

View file

@ -0,0 +1,131 @@
import React, { useCallback, useState } from "react";
import dynamic from "next/dynamic";
import { ConfigureAPI, OnResults, SettingsAPI } from "nuvo-react";
import {
CustomerColumnSchema,
getCustomersModelColumns,
getResultModelSchema,
} from "./customers-columns-model";
import dotObject from "dot-object";
import { useAuthorizedToken } from "../../authorization/use-authorized-token";
import { Alert, Button } from "@saleor/macaw-ui";
import { CustomersImportingResults } from "../customers-results/customers-importing-results";
import { LinearProgress } from "@material-ui/core";
import { CloudUpload } from "@material-ui/icons";
let PassSubmitResult: any;
let RejectSubmitResult: any;
const NuvoImporter = dynamic<ConfigureAPI>(
async () => {
return import("nuvo-react").then((item) => {
PassSubmitResult = item.PassSubmitResult;
RejectSubmitResult = item.RejectSubmitResult;
return item.NuvoImporter;
});
},
{
ssr: false,
loading() {
return <LinearProgress />;
},
}
);
const columns = getCustomersModelColumns();
const nuvoSettings: SettingsAPI = {
columns,
developerMode: true, //todo
identifier: "customers",
modal: false,
style: {
buttons: {
primary: {
background: "black",
color: "#fff",
},
},
loader:{
loadAnimationColor: '#000'
},
header: {
description: {
display: "none",
},
root: {
// display: "none",
},
},
progressBar: {
root: {
display: "none",
},
},
dropzone: {
icon: {
box: {
filter: "grayscale(1)",
},
},
root: {
background: "#fff",
border: "1px dashed #ddd",
},
},
globals: { fontFamily: "Inter", backgroundColor: "transparent" },
},
title: "Upload customers to Saleor",
disableExcelTemplate: true,
disableTemplates: true,
allowManualInput: true,
};
const licenseKey = process.env.NEXT_PUBLIC_NUVO_LICENSE_KEY as string;
export const CustomersImporterView = () => {
const authorized = useAuthorizedToken("MANAGE_USERS");
const [importedLines, setImportedLines] = useState<CustomerColumnSchema[] | null>(null);
const handleResults: OnResults = useCallback((resultArray) => {
const parsedResult = resultArray.map((row) =>
getResultModelSchema().parse(dotObject.object(row))
);
setImportedLines(parsedResult);
}, []);
if (authorized === undefined) {
return <div>Authorizing</div>;
}
if (authorized === false) {
return <Alert variant="error">To use this importer you need MANAGER_USERS permission</Alert>;
}
if (importedLines) {
return <CustomersImportingResults importedLines={importedLines} />;
}
return (
<NuvoImporter
renderUploadButton={({ launch }) => {
return (
<Button
size="large"
startIcon={<CloudUpload />}
variant="primary"
color="primary"
onClick={launch}
>
Upload file
</Button>
);
}}
onResults={handleResults}
licenseKey={licenseKey}
settings={nuvoSettings}
/>
);
};

View file

@ -0,0 +1,103 @@
import { Box, TableCell, TableRow, Typography } from "@material-ui/core";
import { Done, Error, HourglassEmpty } from "@material-ui/icons";
import React, { memo, useCallback, useEffect, useState } from "react";
import { useCustomerCreateMutation } from "../../../../generated/graphql";
import { CustomerColumnSchema } from "../customers-importer-nuvo/customers-columns-model";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button } from "@saleor/macaw-ui";
type Props = {
importedModel: CustomerColumnSchema;
doImport: boolean;
};
const ImportedStatus = ({ id }: { id: string }) => {
const { appBridge } = useAppBridge();
return (
<Box style={{ gap: 20, display: "flex", alignItems: "center" }}>
<Done color="primary" width={30} />
<span
style={{ cursor: "pointer" }}
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
// newContext: true, // open in new context but dashboard has a bug here
to: `/customers/${id}`,
})
);
}}
>
Imported with ID <code>{id}</code>
</span>
</Box>
);
};
const ErrorStatus = ({ message, onRetry }: { message: string; onRetry(): void }) => {
return (
<Box style={{ gap: 20, display: "flex", alignItems: "center" }}>
<Error width={30} color="error" />
<span color="error">Error importing: {message}</span>
<Button onClick={onRetry}>Retry</Button>
</Box>
);
};
const PendingStatus = () => (
<Box style={{ gap: 20, display: "flex", alignItems: "center" }}>
<HourglassEmpty width={30} />
<span>Importing...</span>
</Box>
);
export const CustomerImportingRow = (props: Props) => {
const [mutationResult, mutate] = useCustomerCreateMutation();
const triggerMutation = useCallback(() => {
mutate({
input: {
...props.importedModel.customerCreate,
// todo map address
defaultShippingAddress: null,
defaultBillingAddress: null,
isActive: false,
},
});
}, [props.importedModel, mutate]);
useEffect(() => {
if (
props.doImport &&
!mutationResult.data &&
!mutationResult.error &&
!mutationResult.fetching
) {
triggerMutation();
}
}, [props.doImport, mutate, mutationResult, triggerMutation]);
const renderStatus = () => {
if (mutationResult.data?.customerCreate?.user?.id) {
return <ImportedStatus id={mutationResult.data?.customerCreate?.user?.id} />;
}
if (mutationResult.data?.customerCreate?.errors) {
return (
<ErrorStatus
onRetry={triggerMutation}
message={mutationResult.data?.customerCreate?.errors[0].message ?? "Error importing"}
/>
);
}
if (mutationResult.fetching) {
return <PendingStatus />;
}
};
return (
<TableRow>
<TableCell>{props.importedModel.customerCreate.email}</TableCell>
<TableCell>{renderStatus()}</TableCell>
</TableRow>
);
};

View file

@ -0,0 +1,49 @@
import { Button } from "@saleor/macaw-ui";
import { Table, TableBody, Typography } from "@material-ui/core";
import { CustomerImportingRow } from "./customer-importing-row";
import React, { useState } from "react";
import { CustomerColumnSchema } from "../customers-importer-nuvo/customers-columns-model";
export const CustomersImportingResults = ({
importedLines,
}: {
importedLines: CustomerColumnSchema[];
}) => {
const [importingStarted, setImportingStarted] = useState(false);
return (
<div>
<Typography paragraph variant="h3">Customers rows from imported file</Typography>
<Typography paragraph>
Lines will be imported one by one. Failed imports can be retried, but performed operations
must be reverted manually. Users will be set to inactive.
</Typography>
<Typography paragraph>
Customers will <strong>not</strong> be informed or notified by this operation.
</Typography>
{!importingStarted && (
<Button
style={{ margin: "20px 0" }}
variant="primary"
onClick={() => setImportingStarted(true)}
>
Start importing
</Button>
)}
<Table style={{marginTop: 50}}>
<TableBody>
{importedLines.map((row) => (
<CustomerImportingRow
doImport={importingStarted}
key={row.customerCreate.email}
importedModel={row}
/>
))}
</TableBody>
</Table>
</div>
);
};

View file

@ -0,0 +1,28 @@
import { Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles({
appIconContainer: {
background: "rgb(58, 86, 199)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
color: "#fff",
width: 50,
height: 50,
},
});
export function AppIcon() {
const styles = useStyles();
return (
<div className={styles.appIconContainer}>
<div>
<Typography variant="h2">DI</Typography>
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { Paper, PaperProps } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import clsx from "clsx";
import { ReactNode } from "react";
const useStyles = makeStyles((theme) => ({
root: {
height: 96,
padding: "0 32px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${theme.palette.grey.A100} `,
},
leftColumn: {
marginRight: "auto",
},
rightColumn: {},
iconColumn: {
marginRight: 24,
},
appName: { fontSize: 24, margin: 0 },
appAuthor: {
fontSize: 12,
textTransform: "uppercase",
color: theme.palette.text.secondary,
fontWeight: 500,
margin: 0,
},
bottomMargin: {
marginBottom: 32,
},
}));
type Props = {
name: string;
author: string;
rightColumnContent?: ReactNode;
icon?: ReactNode;
bottomMargin?: boolean;
} & PaperProps;
export function MainBar({
name,
author,
rightColumnContent,
className,
icon,
bottomMargin,
}: Props) {
const styles = useStyles();
return (
<Paper
elevation={0}
className={clsx(styles.root, className, {
[styles.bottomMargin]: bottomMargin,
})}
>
{icon && <div className={styles.iconColumn}>{icon}</div>}
<div className={styles.leftColumn}>
<h1 className={styles.appName}>{name}</h1>
<h1 className={styles.appAuthor}>{author}</h1>
</div>
<div className={styles.rightColumn}>{rightColumnContent}</div>
</Paper>
);
}

View file

@ -0,0 +1,8 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -0,0 +1,57 @@
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeProvider as MacawUIThemeProvider } from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app";
import GraphQLProvider from "../providers/GraphQLProvider";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../no-ssr-wrapper";
const themeOverrides: Partial<Theme> = {
/**
* You can override MacawUI theme here
*/
};
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean }>
>;
function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<ThemeProvider overrides={themeOverrides} ssr>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default NextApp;

View file

@ -0,0 +1,32 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json";
export default createManifestHandler({
async manifestFactory(context) {
const manifest: AppManifest = {
name: packageJson.name,
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl,
permissions: ["MANAGE_USERS"],
id: "saleor.app",
version: packageJson.version,
webhooks: [
/**
* Configure webhooks here. They will be created in Saleor during installation
* Read more
* https://docs.saleor.io/docs/3.x/developer/api-reference/objects/webhook
*/
],
extensions: [
/**
* Optionally, extend Dashboard with custom UIs
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
*/
],
};
return manifest;
},
});

View file

@ -0,0 +1,9 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../../saleor-app";
/**
* Required endpoint, called by Saleor to install app.
* It will exchange tokens with app, so saleorApp.apl will contain token
*/
export default createAppRegisterHandler(saleorApp);

View file

@ -0,0 +1,86 @@
import { NextPage } from "next";
import React, { ComponentProps } from "react";
import { Container, Divider, Typography } from "@material-ui/core";
import { Button, makeStyles, PageTab, PageTabs, SaleorTheme } from "@saleor/macaw-ui";
import { CustomersImporterView } from "../modules/customers/customers-importer-nuvo/customers-importer-view";
import GraphQLProvider from "../providers/GraphQLProvider";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { MainBar } from "../modules/ui/main-bar";
import { AppIcon } from "../modules/ui/app-icon";
type Tab = "customers";
const useStyles = makeStyles((theme: SaleorTheme) => ({
wrapper: {
border: `1px solid ${theme.palette.divider}`,
minHeight: `calc(100vh - 100px)`,
},
}));
const ImporterPage: NextPage = () => {
const [activeTab, setActiveTab] = React.useState<Tab>("customers");
const styles = useStyles();
const { appBridge } = useAppBridge();
const openInNewTab = (url: string) => {
appBridge?.dispatch(
actions.Redirect({
to: url,
newContext: true,
})
);
};
return (
<div className={styles.wrapper}>
<MainBar
bottomMargin
icon={<AppIcon />}
name="Data Importer"
author="By Saleor Commerce"
rightColumnContent={
<div style={{ display: "flex", gap: 10 }}>
<Button
onClick={() => {
openInNewTab("https://github.com/saleor/saleor-app-data-importer");
}}
variant="tertiary"
>
Repository
</Button>
<Button
onClick={() => {
openInNewTab("https://github.com/saleor/apps/discussions");
}}
variant="tertiary"
>
Request a feature
</Button>
</div>
}
/>
<Container>
<PageTabs
style={{ marginBottom: 20, marginTop: 20 }}
value={activeTab}
onChange={(e) => setActiveTab(e as Tab)}
>
<PageTab value="customers" label="Customers" />
<PageTab disabled value="orders" label="Orders (coming soon)" />
<PageTab disabled value="products" label="Products (coming soon)" />
</PageTabs>
<Divider style={{ marginBottom: 50 }} />
{activeTab === "customers" && <CustomersImporterView />}
</Container>
</div>
);
};
const WrappedPage = (props: ComponentProps<NextPage>) => (
<GraphQLProvider>
<ImporterPage {...props} />
</GraphQLProvider>
);
export default WrappedPage;

View file

@ -0,0 +1,33 @@
import { NextPage } from "next";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect } from "react";
import { useIsMounted } from "usehooks-ts";
import { useRouter } from "next/router";
import { isInIframe } from "../lib/is-in-iframe";
import { LinearProgress } from "@material-ui/core";
const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
const isMounted = useIsMounted();
const { replace } = useRouter();
useEffect(() => {
if (isMounted() && appBridgeState?.ready) {
replace("/importer");
}
}, [isMounted, appBridgeState?.ready, replace]);
if (isInIframe()) {
return <LinearProgress />;
}
return (
<div>
<h1>Saleor Data Importer</h1>
<p>This is Saleor App that allows importing data from CSV</p>
<p>Install app in your Saleor instance and open in with Dashboard</p>
</div>
);
};
export default IndexPage;

View file

@ -0,0 +1,17 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createClient } from "../lib/graphql";
function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const client = createClient(appBridgeState?.saleorApiUrl!, async () =>
Promise.resolve({ token: appBridgeState?.token! })
);
return <Provider value={client} {...props} />;
}
export default GraphQLProvider;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {}

View file

@ -0,0 +1,17 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #111;
padding: 1rem 2rem;
}
code {
background: #f6f8fa;
border: 1px solid #eaeaea;
border-radius: 5px;
display: inline-block;
margin-top: 10px;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
},
});