Refactors custom input form & dialog (#853)

This commit is contained in:
Omar López 2021-10-07 09:43:20 -06:00 committed by GitHub
parent 30f97117e8
commit 58de920951
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 266 additions and 205 deletions

View File

@ -7,6 +7,6 @@ module.exports = {
semi: true,
printWidth: 110,
arrowParens: "always",
importOrder: ["^@ee/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^[./]"],
importOrder: ["^@ee/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^@(server|trcp)/(.*)$", "^[./]"],
importOrderSeparation: true,
};

View File

@ -1,10 +1,12 @@
/* legacy and soon deprecated, please refactor to use <Dialog> only */
import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon, InformationCircleIcon } from "@heroicons/react/outline";
import { Fragment, ReactNode } from "react";
import classNames from "@lib/classNames";
/**
* @deprecated please refactor to use <Dialog> only
*/
export default function Modal(props: {
heading: ReactNode;
description: ReactNode;

View File

@ -0,0 +1,126 @@
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
import React, { FC } from "react";
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
import Select, { OptionTypeBase } from "react-select";
const inputOptions: OptionTypeBase[] = [
{ value: EventTypeCustomInputType.TEXT, label: "Text" },
{ value: EventTypeCustomInputType.TEXTLONG, label: "Multiline Text" },
{ value: EventTypeCustomInputType.NUMBER, label: "Number" },
{ value: EventTypeCustomInputType.BOOL, label: "Checkbox" },
];
interface Props {
onSubmit: SubmitHandler<IFormInput>;
onCancel: () => void;
selectedCustomInput?: EventTypeCustomInput;
}
type IFormInput = EventTypeCustomInput;
const CustomInputTypeForm: FC<Props> = (props) => {
const { selectedCustomInput } = props;
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
const { register, control, handleSubmit } = useForm<IFormInput>({
defaultValues,
});
const selectedInputType = useWatch({ name: "type", control });
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value)!;
const onCancel = () => {
props.onCancel();
};
return (
<form onSubmit={handleSubmit(props.onSubmit)}>
<div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
Input type
</label>
<Controller
name="type"
control={control}
render={({ field }) => (
<Select
id="type"
defaultValue={selectedInputOption}
options={inputOptions}
isSearchable={false}
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
onChange={(option) => field.onChange(option.value)}
value={selectedInputOption}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</div>
<div className="mb-2">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
Label
</label>
<div className="mt-1">
<input
type="text"
id="label"
required
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={selectedCustomInput?.label}
{...register("label", { required: true })}
/>
</div>
</div>
{(selectedInputType === EventTypeCustomInputType.TEXT ||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
<div className="mb-2">
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
Placeholder
</label>
<div className="mt-1">
<input
type="text"
id="placeholder"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
defaultValue={selectedCustomInput?.placeholder}
{...register("placeholder")}
/>
</div>
</div>
)}
<div className="flex items-center h-5">
<input
id="required"
type="checkbox"
className="w-4 h-4 mr-2 border-gray-300 rounded focus:ring-primary-500 text-primary-600"
defaultChecked={selectedCustomInput?.required ?? true}
{...register("required")}
/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
Is required
</label>
</div>
<input
type="hidden"
id="eventTypeId"
value={selectedCustomInput?.eventTypeId || -1}
{...register("eventTypeId", { valueAsNumber: true })}
/>
<input
type="hidden"
id="id"
value={selectedCustomInput?.id || -1}
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Save
</button>
<button onClick={onCancel} type="button" className="mr-2 btn btn-white">
Cancel
</button>
</div>
</form>
);
};
export default CustomInputTypeForm;

View File

@ -1,8 +1,10 @@
// Type-only import:
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
import type { AppRouter } from "@server/routers/_app";
import { createReactQueryHooks } from "@trpc/react";
import type { inferProcedureOutput, inferProcedureInput } from "@trpc/server";
import superjson from "superjson";
import type { AppRouter } from "@server/routers/_app";
/**
* A set of strongly-typed React hooks from your `AppRouter` type signature with `createReactQueryHooks`.
@ -10,7 +12,7 @@ import type { inferProcedureOutput, inferProcedureInput } from "@trpc/server";
*/
export const trpc = createReactQueryHooks<AppRouter>();
// export const transformer = superjson;
export const transformer = superjson;
/**
* This is a helper method to infer the output of a query resolver
* @example type HelloOutput = inferQueryOutput<'hello'>

View File

@ -86,6 +86,7 @@
"react-use-intercom": "1.4.0",
"short-uuid": "^4.2.0",
"stripe": "^8.168.0",
"superjson": "1.7.5",
"tsdav": "1.0.6",
"tslog": "^3.2.1",
"uuid": "^8.3.2",

View File

@ -1,5 +1,4 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { ssg } from "@server/ssg";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import Link from "next/link";
import React from "react";
@ -13,6 +12,8 @@ import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import { HeadSeo } from "@components/seo/head-seo";
import Avatar from "@components/ui/Avatar";
import { ssg } from "@server/ssg";
export default function User(props: inferSSRProps<typeof getStaticProps>) {
const { username } = props;
// data of query below will be will be prepopulated b/c of `getStaticProps`

View File

@ -1,4 +1,3 @@
import type { AppRouter } from "@server/routers/_app";
import { httpBatchLink } from "@trpc/client/links/httpBatchLink";
import { loggerLink } from "@trpc/client/links/loggerLink";
import { withTRPC } from "@trpc/next";
@ -7,10 +6,13 @@ import { Maybe } from "@trpc/server";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo";
import type { AppProps as NextAppProps } from "next/app";
import superjson from "superjson";
import AppProviders from "@lib/app-providers";
import { seoConfig } from "@lib/config/next-seo.config";
import type { AppRouter } from "@server/routers/_app";
import "../styles/globals.css";
// Workaround for https://github.com/vercel/next.js/issues/8592
@ -77,6 +79,10 @@ export default withTRPC<AppRouter>({
},
},
},
/**
* @link https://trpc.io/docs/data-transformers
*/
transformer: superjson,
};
},
/**

View File

@ -1,10 +1,50 @@
import { EventTypeCustomInput, Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
if (!customInputs || customInputs?.length) return undefined;
const cInputsIdsToDelete = customInputs.filter((input) => input.id > 0).map((e) => e.id);
const cInputsToCreate = customInputs
.filter((input) => input.id < 0)
.map((input) => ({
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
}));
const cInputsToUpdate = customInputs
.filter((input) => input.id > 0)
.map((input) => ({
data: {
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
},
where: {
id: input.id,
},
}));
return {
deleteMany: {
eventTypeId,
NOT: {
id: { in: cInputsIdsToDelete },
},
},
createMany: {
data: cInputsToCreate,
},
update: cInputsToUpdate,
};
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
const session = await getSession({ req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
@ -41,7 +81,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.method == "PATCH" || req.method == "POST") {
const data = {
const data: Prisma.EventTypeUpdateInput = {
title: req.body.title,
slug: req.body.slug.trim(),
description: req.body.description,
@ -51,39 +91,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
disableGuests: req.body.disableGuests,
locations: req.body.locations,
eventName: req.body.eventName,
customInputs: !req.body.customInputs
? undefined
: {
deleteMany: {
eventTypeId: req.body.id,
NOT: {
id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) },
},
},
createMany: {
data: req.body.customInputs
.filter((input) => !input.id)
.map((input) => ({
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
})),
},
update: req.body.customInputs
.filter((input) => !!input.id)
.map((input) => ({
data: {
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
},
where: {
id: input.id,
},
})),
},
customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id),
periodType: req.body.periodType,
periodDays: req.body.periodDays,
periodStartDate: req.body.periodStartDate,

View File

@ -1,9 +1,10 @@
/**
* This file contains tRPC's HTTP response handler
*/
import * as trpcNext from "@trpc/server/adapters/next";
import { createContext } from "@server/createContext";
import { appRouter } from "@server/routers/_app";
import * as trpcNext from "@trpc/server/adapters/next";
export default trpcNext.createNextApiHandler({
router: appRouter,

View File

@ -1,10 +1,11 @@
import { resizeBase64Image } from "@server/lib/resizeBase64Image";
import { pick } from "lodash";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { resizeBase64Image } from "@server/lib/resizeBase64Image";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });

View File

@ -14,7 +14,7 @@ import {
UserAddIcon,
UsersIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInput, EventTypeCustomInputType, Prisma, SchedulingType } from "@prisma/client";
import { EventTypeCustomInput, Prisma, SchedulingType } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
@ -48,10 +48,11 @@ import { defaultAvatarSrc } from "@lib/profile";
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
import Modal from "@components/Modal";
import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
import Button from "@components/ui/Button";
import { Scheduler } from "@components/ui/Scheduler";
import Switch from "@components/ui/Switch";
@ -86,13 +87,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const router = useRouter();
const [successModalOpen, setSuccessModalOpen] = useState(false);
const inputOptions: OptionTypeBase[] = [
{ value: EventTypeCustomInputType.TEXT, label: "Text" },
{ value: EventTypeCustomInputType.TEXTLONG, label: "Multiline Text" },
{ value: EventTypeCustomInputType.NUMBER, label: "Number" },
{ value: EventTypeCustomInputType.BOOL, label: "Checkbox" },
];
const updateMutation = useMutation(updateEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types");
@ -121,12 +115,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [enteredAvailability, setEnteredAvailability] = useState();
const [showLocationModal, setShowLocationModal] = useState(false);
const [showAddCustomModal, setShowAddCustomModal] = useState(false);
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
const [selectedInputOption, setSelectedInputOption] = useState<OptionTypeBase>(inputOptions[0]);
const [locations, setLocations] = useState(eventType.locations || []);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
eventType.customInputs.sort((a, b) => a.id - b.id) || []
);
@ -217,12 +210,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
setShowLocationModal(false);
};
const closeAddCustomModal = () => {
setSelectedInputOption(inputOptions[0]);
setShowAddCustomModal(false);
setSelectedCustomInput(undefined);
};
const closeSuccessModal = () => {
setSuccessModalOpen(false);
};
@ -252,12 +239,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
setLocations(locations.filter((location) => location.type !== selectedLocation.type));
};
const openEditCustomModel = (customInput: EventTypeCustomInput) => {
setSelectedCustomInput(customInput);
setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)!);
setShowAddCustomModal(true);
};
const LocationOptions = () => {
if (!selectedLocation) {
return null;
@ -293,29 +274,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return null;
};
const updateCustom = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const customInput: EventTypeCustomInput = {
id: -1,
eventTypeId: -1,
label: e.currentTarget.label.value,
placeholder: e.currentTarget.placeholder?.value,
required: e.currentTarget.required.checked,
type: e.currentTarget.type.value,
};
if (selectedCustomInput) {
selectedCustomInput.label = customInput.label;
selectedCustomInput.placeholder = customInput.placeholder;
selectedCustomInput.required = customInput.required;
selectedCustomInput.type = customInput.type;
} else {
setCustomInputs(customInputs.concat(customInput));
}
closeAddCustomModal();
};
const removeCustom = (index: number) => {
customInputs.splice(index, 1);
setCustomInputs([...customInputs]);
@ -422,7 +380,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id="length"
required
placeholder="15"
defaultValue={eventType.length}
defaultValue={eventType.length || 15}
/>
</div>
<hr />
@ -679,12 +637,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
<div className="flex">
<button
type="button"
onClick={() => openEditCustomModel(customInput)}
className="mr-2 text-sm text-primary-600">
<Button
onClick={() => {
setSelectedCustomInput(customInput);
setSelectedCustomInputModalOpen(true);
}}
color="minimal"
type="button">
Edit
</button>
</Button>
<button type="button" onClick={() => removeCustom(idx)}>
<XIcon className="w-6 h-6 pl-1 border-l-2 hover:text-red-500 " />
</button>
@ -693,15 +654,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</li>
))}
<li>
<button
<Button
onClick={() => {
setSelectedCustomInput(undefined);
setSelectedCustomInputModalOpen(true);
}}
color="secondary"
type="button"
className="flex px-3 py-2 rounded-sm bg-neutral-100"
onClick={() => setShowAddCustomModal(true)}>
<PlusIcon className="h-4 w-4 mt-0.5 text-neutral-900" />
<span className="ml-1 text-sm font-medium text-neutral-700">
Add an input
</span>
</button>
StartIcon={PlusIcon}>
Add an input
</Button>
</li>
</ul>
</div>
@ -1035,111 +997,51 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
)}
{showAddCustomModal && (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
/>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-secondary-100 sm:mx-0 sm:h-10 sm:w-10">
<PlusIcon className="w-6 h-6 text-primary-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Add new custom input field
</h3>
<div>
<p className="text-sm text-gray-400">
This input will be shown when booking this event
</p>
</div>
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
<DialogContent asChild>
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-secondary-100 sm:mx-0 sm:h-10 sm:w-10">
<PlusIcon className="w-6 h-6 text-primary-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Add new custom input field
</h3>
<div>
<p className="text-sm text-gray-400">This input will be shown when booking this event</p>
</div>
</div>
<form onSubmit={updateCustom}>
<div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
Input type
</label>
<Select
name="type"
defaultValue={selectedInputOption}
options={inputOptions}
isSearchable={false}
required
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
onChange={setSelectedInputOption}
/>
</div>
<div className="mb-2">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
Label
</label>
<div className="mt-1">
<input
type="text"
name="label"
id="label"
required
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={selectedCustomInput?.label}
/>
</div>
</div>
{(selectedInputOption.value === EventTypeCustomInputType.TEXT ||
selectedInputOption.value === EventTypeCustomInputType.TEXTLONG) && (
<div className="mb-2">
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
Placeholder
</label>
<div className="mt-1">
<input
type="text"
name="placeholder"
id="placeholder"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
defaultValue={selectedCustomInput?.placeholder}
/>
</div>
</div>
)}
<div className="flex items-center h-5">
<input
id="required"
name="required"
type="checkbox"
className="w-4 h-4 mr-2 border-gray-300 rounded focus:ring-primary-500 text-primary-600"
defaultChecked={selectedCustomInput?.required ?? true}
/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
Is required
</label>
</div>
<input type="hidden" name="id" id="id" value={selectedCustomInput?.id} />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Save
</button>
<button onClick={closeAddCustomModal} type="button" className="mr-2 btn btn-white">
Cancel
</button>
</div>
</form>
</div>
<CustomInputTypeForm
selectedCustomInput={selectedCustomInput}
onSubmit={(values) => {
const customInput: EventTypeCustomInput = {
id: -1,
eventTypeId: -1,
label: values.label,
placeholder: values.placeholder,
required: values.required,
type: values.type,
};
if (selectedCustomInput) {
selectedCustomInput.label = customInput.label;
selectedCustomInput.placeholder = customInput.placeholder;
selectedCustomInput.required = customInput.required;
selectedCustomInput.type = customInput.type;
} else {
setCustomInputs(customInputs.concat(customInput));
}
setSelectedCustomInputModalOpen(false);
}}
onCancel={() => {
setSelectedCustomInputModalOpen(false);
}}
/>
</div>
</div>
)}
</DialogContent>
</Dialog>
</Shell>
</div>
);

View File

@ -520,6 +520,7 @@ const CreateNewEventDialog = ({
required
className="block w-full pr-20 border-gray-300 rounded-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
placeholder="15"
defaultValue={15}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400">
minutes

View File

@ -1,6 +1,8 @@
/**
* This file contains the root router of your tRPC-backend
*/
import superjson from "superjson";
import { createRouter } from "../createRouter";
import { bookingRouter } from "./booking";
import { viewerRouter } from "./viewer";
@ -16,7 +18,7 @@ export const appRouter = createRouter()
* Add data transformers
* @link https://trpc.io/docs/data-transformers
*/
// .transformer(superjson)
.transformer(superjson)
/**
* Optionally do custom error (type safe!) formatting
* @link https://trpc.io/docs/error-formatting

View File

@ -7911,6 +7911,14 @@ stylis@^4.0.3:
version "4.0.10"
resolved "https://registry.npmjs.org/stylis/-/stylis-4.0.10.tgz"
superjson@1.7.5:
version "1.7.5"
resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.7.5.tgz#596d080fd3c010f6d991c53a292c03704f160649"
integrity sha512-AHuFroOcMTK6LdG/irwXIHwH6Gof5nh42iywnhhf7hMZ6UJqFDRtJ82ViJg14UX3AG8vWRf4Dh3oPIJcqu16Nw==
dependencies:
debug "^4.3.1"
lodash.clonedeep "^4.5.0"
supports-color@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz"