Skip to content

Commit bc8d96e

Browse files
feat(products): improve products page (#722)
1 parent 6cc0126 commit bc8d96e

39 files changed

+433
-73
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useQueryClient } from "react-query";
2+
3+
import type { AsError, UnitDetail } from "@squonk/account-server-client";
4+
import {
5+
getGetProductsQueryKey,
6+
useCreateUnitProduct,
7+
} from "@squonk/account-server-client/product";
8+
9+
import { Box, Button } from "@mui/material";
10+
import { captureException } from "@sentry/nextjs";
11+
import { Field, Form, Formik } from "formik";
12+
import { TextField } from "formik-mui";
13+
import * as yup from "yup";
14+
15+
import { useEnqueueError } from "../hooks/useEnqueueStackError";
16+
import { useGetStorageCost } from "../hooks/useGetStorageCost";
17+
import { coinsFormatter } from "../utils/app/coins";
18+
import { getBillingDay } from "../utils/app/products";
19+
import { getErrorMessage } from "../utils/next/orvalError";
20+
21+
export interface CreateDatasetStorageSubscriptionProps {
22+
unit: UnitDetail;
23+
}
24+
25+
const initialValues = {
26+
allowance: 1000,
27+
billingDay: getBillingDay(),
28+
name: "Dataset Storage",
29+
};
30+
31+
export const CreateDatasetStorageSubscription = ({
32+
unit,
33+
}: CreateDatasetStorageSubscriptionProps) => {
34+
const { mutateAsync: createProduct } = useCreateUnitProduct();
35+
const { enqueueError, enqueueSnackbar } = useEnqueueError<AsError>();
36+
const queryClient = useQueryClient();
37+
const cost = useGetStorageCost();
38+
return (
39+
<Formik
40+
initialValues={initialValues}
41+
validationSchema={yup.object().shape({
42+
name: yup.string().trim().required("A name is required"),
43+
limit: yup.number().min(1).integer().required("A limit is required"),
44+
allowance: yup.number().min(1).integer().required("An allowance is required"),
45+
billingDay: yup.number().min(1).max(28).integer().required("A billing day is required"),
46+
})}
47+
onSubmit={async ({ allowance, billingDay, name }) => {
48+
try {
49+
await createProduct({
50+
unitId: unit.id,
51+
data: {
52+
allowance,
53+
billing_day: billingDay,
54+
limit: allowance, // TODO: we will implement this properly later
55+
name,
56+
type: "DATA_MANAGER_STORAGE_SUBSCRIPTION",
57+
},
58+
});
59+
enqueueSnackbar("Created product", { variant: "success" });
60+
queryClient.invalidateQueries(getGetProductsQueryKey());
61+
} catch (error) {
62+
enqueueError(getErrorMessage(error));
63+
captureException(error);
64+
}
65+
}}
66+
>
67+
{({ submitForm, isSubmitting, isValid, values }) => {
68+
return (
69+
<Form>
70+
<Box alignItems="baseline" display="flex" flexWrap="wrap" gap={2}>
71+
<Field
72+
autoFocus
73+
component={TextField}
74+
label="Name"
75+
name="name"
76+
sx={{ maxWidth: 150 }}
77+
/>
78+
<Field
79+
component={TextField}
80+
label="Billing Day"
81+
max={28}
82+
min={1}
83+
name="billingDay"
84+
sx={{ maxWidth: 80 }}
85+
type="number"
86+
/>
87+
<Field
88+
component={TextField}
89+
label="Allowance"
90+
min={1}
91+
name="allowance"
92+
sx={{ maxWidth: 100 }}
93+
type="number"
94+
/>
95+
{cost && (
96+
<span>Cost: {coinsFormatter.format(cost * values.allowance).slice(1)}C</span>
97+
)}
98+
<Button disabled={isSubmitting || !isValid} onClick={submitForm}>
99+
Create
100+
</Button>
101+
</Box>
102+
</Form>
103+
);
104+
}}
105+
</Formik>
106+
);
107+
};

components/LocalTime/LocalTime.tsx renamed to components/LocalTime.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { HTMLProps } from "react";
22

3-
import { toLocalTimeString } from "./utils";
3+
import { toLocalTimeString } from "../utils/app/datetime";
44

55
export interface BaseLocalTimeProps extends HTMLProps<HTMLSpanElement> {
66
/**

components/LocalTime/index.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

components/labels/NewLabelButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const NewLabelButton = ({ datasetId }: NewLabelButtonProps) => {
7474
>
7575
{({ submitForm, isSubmitting, isValid }) => (
7676
<Form>
77-
<Box alignItems="baseline" display="flex" gap={(theme) => theme.spacing(1)}>
77+
<Box alignItems="baseline" display="flex" gap={1}>
7878
<Field autoFocus component={LowerCaseTextField} label="Name" name="label" />
7979
<Field component={TextField} label="Value" name="value" />
8080
<Button disabled={isSubmitting || !isValid} onClick={submitForm}>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState } from "react";
2+
import { useQueryClient } from "react-query";
3+
4+
import type { ProductDetail } from "@squonk/account-server-client";
5+
import {
6+
getGetProductQueryKey,
7+
getGetProductsQueryKey,
8+
usePatchProduct,
9+
} from "@squonk/account-server-client/product";
10+
11+
import EditIcon from "@mui/icons-material/Edit";
12+
import { Box, IconButton } from "@mui/material";
13+
import { Field } from "formik";
14+
import { TextField } from "formik-mui";
15+
16+
import { useEnqueueError } from "../../hooks/useEnqueueStackError";
17+
import { useGetStorageCost } from "../../hooks/useGetStorageCost";
18+
import { coinsFormatter } from "../../utils/app/coins";
19+
import { getErrorMessage } from "../../utils/next/orvalError";
20+
import { FormikModalWrapper } from "../modals/FormikModalWrapper";
21+
22+
export interface AdjustProjectProductProps {
23+
product: ProductDetail;
24+
allowance: number;
25+
}
26+
27+
export const AdjustProjectProduct = ({ product, allowance }: AdjustProjectProductProps) => {
28+
const [open, setOpen] = useState(false);
29+
const cost = useGetStorageCost();
30+
const { mutateAsync: adjustProduct } = usePatchProduct();
31+
const { enqueueError, enqueueSnackbar } = useEnqueueError();
32+
const queryClient = useQueryClient();
33+
34+
const initialValues = { name: product.name, allowance };
35+
36+
return (
37+
<>
38+
<IconButton onClick={() => setOpen(true)}>
39+
<EditIcon />
40+
</IconButton>
41+
<FormikModalWrapper
42+
id={`adjust-${product.id}`}
43+
initialValues={initialValues}
44+
open={open}
45+
submitText="Submit"
46+
title={`Adjust Product - ${product.name}`}
47+
onClose={() => setOpen(false)}
48+
onSubmit={async (values) => {
49+
try {
50+
await adjustProduct({ productId: product.id, data: values });
51+
await Promise.allSettled([
52+
queryClient.invalidateQueries(getGetProductsQueryKey()),
53+
queryClient.invalidateQueries(getGetProductQueryKey(product.id)),
54+
]);
55+
enqueueSnackbar("Updated product", { variant: "success" });
56+
} catch (error) {
57+
enqueueError(getErrorMessage(error));
58+
}
59+
}}
60+
>
61+
{({ values }) => (
62+
<Box alignItems="baseline" display="flex" flexWrap="wrap" gap={2} m={2}>
63+
<Field
64+
autoFocus
65+
component={TextField}
66+
label="Name"
67+
name="name"
68+
sx={{ maxWidth: 150 }}
69+
/>
70+
<Field
71+
component={TextField}
72+
label="Allowance"
73+
min={1}
74+
name="allowance"
75+
sx={{ maxWidth: 100 }}
76+
type="number"
77+
/>
78+
{cost && <span>Cost: {coinsFormatter.format(cost * values.allowance).slice(1)}C</span>}
79+
</Box>
80+
)}
81+
</FormikModalWrapper>
82+
</>
83+
);
84+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useQueryClient } from "react-query";
2+
3+
import type { ProductDetail } from "@squonk/account-server-client";
4+
import {
5+
getGetProductQueryKey,
6+
getGetProductsQueryKey,
7+
useDeleteProduct,
8+
} from "@squonk/account-server-client/product";
9+
10+
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
11+
import { IconButton, Tooltip } from "@mui/material";
12+
13+
import { useEnqueueError } from "../../hooks/useEnqueueStackError";
14+
import { getErrorMessage } from "../../utils/next/orvalError";
15+
import { WarningDeleteButton } from "../WarningDeleteButton";
16+
17+
export interface DeleteProductButtonProps {
18+
product: ProductDetail;
19+
disabled?: boolean;
20+
tooltip: string;
21+
}
22+
23+
export const DeleteProductButton = ({
24+
product,
25+
disabled = false,
26+
tooltip,
27+
}: DeleteProductButtonProps) => {
28+
const { mutateAsync: deleteProduct } = useDeleteProduct();
29+
const { enqueueError, enqueueSnackbar } = useEnqueueError();
30+
const queryClient = useQueryClient();
31+
return (
32+
<Tooltip title={tooltip}>
33+
<span>
34+
<WarningDeleteButton
35+
modalId={`delete-${product.id}`}
36+
title="Delete Product"
37+
onDelete={async () => {
38+
try {
39+
await deleteProduct({ productId: product.id });
40+
await Promise.allSettled([
41+
queryClient.invalidateQueries(getGetProductsQueryKey()),
42+
queryClient.invalidateQueries(getGetProductQueryKey(product.id)),
43+
]);
44+
enqueueSnackbar("Product deleted", { variant: "success" });
45+
} catch (error) {
46+
enqueueError(getErrorMessage(error));
47+
}
48+
}}
49+
>
50+
{({ openModal }) => (
51+
<IconButton disabled={disabled} onClick={openModal}>
52+
<DeleteForeverIcon />
53+
</IconButton>
54+
)}
55+
</WarningDeleteButton>
56+
</span>
57+
</Tooltip>
58+
);
59+
};

features/ProjectStats/ProjectActions/CopyProjectURL.tsx renamed to components/projects/CopyProjectURL.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import ShareIcon from "@mui/icons-material/Share";
44
import { IconButton, Tooltip } from "@mui/material";
55
import { useSnackbar } from "notistack";
66

7+
import { projectURL } from "../../utils/app/routes";
8+
79
export interface CopyProjectURLProps {
810
/**
911
* Project to copy the permalink to the clipboard
@@ -20,12 +22,7 @@ export const CopyProjectURL = ({ project }: CopyProjectURLProps) => {
2022
sx={{ p: "1px" }}
2123
onClick={async () => {
2224
project.project_id &&
23-
(await navigator.clipboard.writeText(
24-
window.location.origin +
25-
(process.env.NEXT_PUBLIC_BASE_PATH ?? "") +
26-
"/project?" +
27-
new URLSearchParams([["project", project.project_id]]).toString(),
28-
));
25+
(await navigator.clipboard.writeText(projectURL(project.project_id)));
2926
enqueueSnackbar("Copied URL to clipboard", { variant: "info" });
3027
}}
3128
>

components/projects/CreateProjectButton.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export const CreateProjectButton = ({
2424

2525
return (
2626
<>
27-
<Button onClick={() => setOpen(true)}>{buttonText}</Button>
27+
<Button variant="outlined" onClick={() => setOpen(true)}>
28+
{buttonText}
29+
</Button>
2830

2931
<CreateProjectForm
3032
modal={{
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { ProjectDetail } from "@squonk/data-manager-client";
55
import EditIcon from "@mui/icons-material/Edit";
66
import { IconButton, Tooltip, Typography } from "@mui/material";
77

8-
import { ModalWrapper } from "../../../../components/modals/ModalWrapper";
9-
import { useKeycloakUser } from "../../../../hooks/useKeycloakUser";
8+
import { useKeycloakUser } from "../../../hooks/useKeycloakUser";
9+
import { ModalWrapper } from "../../modals/ModalWrapper";
1010
import { PrivateProjectToggle } from "./PrivateProjectToggle";
1111
import { ProjectEditors } from "./ProjectEditors";
1212

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {
88

99
import { FormControlLabel, Switch } from "@mui/material";
1010

11-
import type { ProjectId } from "../../../../hooks/projectHooks";
12-
import { useEnqueueError } from "../../../../hooks/useEnqueueStackError";
13-
import { getErrorMessage } from "../../../../utils/next/orvalError";
11+
import type { ProjectId } from "../../../hooks/projectHooks";
12+
import { useEnqueueError } from "../../../hooks/useEnqueueStackError";
13+
import { getErrorMessage } from "../../../utils/next/orvalError";
1414

1515
export interface PrivateProjectToggleProps {
1616
projectId: ProjectId;

0 commit comments

Comments
 (0)