Files
calendar/packages/app-store-cli/src/CliApp.tsx
T
271d4319b9 Introduce EventTypeAppCard in app-store and make it super easy to add it through CLI - Also adds Fathom app (#4727)
* Add OmniInstall button

* Make AppCards configurable by the app itself

* Make OmniInstallAppButton not redirect

* Fixes

* Add extendsFeature support to CLI

* Move to automatic file generation approach as dynamic import checking doesnt work correctly

* Use zod everywhere consistenly for metadata and fix all TS issues

* Fix viewer.eventTypes endpoint. Make prisma base select and _ prefixed models consistent in expecting scalars only

* Remove unnecessary zod parsing of event-types as it is making the scope of the PR huge

* Fix UI TS errors

* wip

* Add zod types support in EventTypeAppCard.tsx

* Fixes during PR review and other failing tests

* Remove unused app

* Fix stripe installation flow

* More fixes

* Fix apps and active apps count

* self review

* Add loading attribute to OmniInsall button

* Handle empty state

* Improve types

* Fix stripe app installation bug

* added fathom app (#4804)

* added fathom app wrapper, needs script injection to public booking page

* new logo

* Add Fathom script support on booking pages and add it as an eventTypeapp

* Add automation and analytics apps

* Add missing pieces for analytics category

* Rename BookingPageScripts to BookingPageTagManager

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Fix lint error

* Fix runtime error with legayAppData being undefined

* Remove duplicate automation key

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-10-14 10:24:43 -06:00

464 lines
15 KiB
TypeScript

import fs from "fs";
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import path from "path";
import React, { FC, useEffect, useState } from "react";
import execSync from "./execSync";
const slugify = (str: string) => {
// It is to be a valid dir name, a valid JS variable name and a valid URL path
return str.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
};
function getSlugFromAppName(appName: string | null): string | null {
if (!appName) {
return appName;
}
return slugify(appName);
}
function getAppDirPath(slug: any) {
return path.join(appStoreDir, `${slug}`);
}
const appStoreDir = path.resolve(__dirname, "..", "..", "app-store");
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
function absolutePath(appRelativePath) {
return path.join(appStoreDir, appRelativePath);
}
const updatePackageJson = ({ slug, appDescription, appDirPath }) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const BaseAppFork = {
create: function* ({
category,
subCategory,
editMode = false,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
}) {
const appDirPath = getAppDirPath(slug);
let message = !editMode ? "Forking base app" : "Updating app";
yield message;
if (!editMode) {
execSync(`mkdir -p ${appDirPath}`);
execSync(`cp -r ${absolutePath("_baseApp/*")} ${appDirPath}`);
}
updatePackageJson({ slug, appDirPath, appDescription });
const categoryToVariantMap = {
video: "conferencing",
};
const dataFromCategory =
category === "video"
? {
appData: {
location: {
type: `integrations:${slug}_video`,
label: `${appName}`,
},
},
}
: {};
const dataFromSubCategory =
category === "video" && subCategory === "static"
? {
appData: {
...dataFromCategory.appData,
location: {
...dataFromCategory.appData.location,
linkType: "static",
organizerInputPlaceholder: "https://anything.anything",
urlRegExp: "",
},
},
}
: {};
let config = {
"/*": "Don't modify slug - If required, do it using cli edit command",
name: appName,
// Plan to remove it. DB already has it and name of dir is also the same.
slug: slug,
type: `${slug}_${category}`,
imageSrc: `/api/app-store/${slug}/icon.svg`,
logo: `/api/app-store/${slug}/icon.svg`,
url: `https://cal.com/apps/${slug}`,
variant: categoryToVariantMap[category] || category,
categories: [category],
publisher: publisherName,
email: publisherEmail,
description: appDescription,
extendsFeature: extendsFeature,
// TODO: Use this to avoid edit and delete on the apps created outside of cli
__createdUsingCli: true,
...dataFromCategory,
...dataFromSubCategory,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
fs.writeFileSync(
`${appDirPath}/README.mdx`,
fs
.readFileSync(`${appDirPath}/README.mdx`)
.toString()
.replace(/_DESCRIPTION_/g, appDescription)
.replace(/_APP_DIR_/g, slug)
);
message = !editMode ? "Forked base app" : "Updated app";
yield message;
},
delete: function ({ slug }) {
const appDirPath = getAppDirPath(slug);
execSync(`rm -rf ${appDirPath}`);
},
};
const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: function ({ slug, category, noDbUpdate }) {
let configContent = "[]";
try {
if (fs.statSync(this.seedConfigPath)) {
configContent = fs.readFileSync(this.seedConfigPath).toString();
}
} catch (e) {}
const seedConfig = JSON.parse(configContent);
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [category],
slug: slug,
type: `${slug}_${category}`,
});
}
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
// Add the message as a property to first item so that it stays always at the top
seedConfig[0]["/*"] =
"This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually";
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`cd ${workspaceDir} && yarn db-seed`);
}
},
revert: async function ({ slug, noDbUpdate }) {
let seedConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
}
},
};
const generateAppFiles = () => {
execSync(`cd ${__dirname} && yarn ts-node --transpile-only src/app-store.ts`);
};
const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => {
// AppName
// Type of App - Other, Calendar, Video, Payment, Messaging, Web3
const [appInputData, setAppInputData] = useState({});
const [inputIndex, setInputIndex] = useState(0);
const fields = [
{ label: "App Title", name: "appName", type: "text", explainer: "Keep it very short" },
{
label: "App Description",
name: "appDescription",
type: "text",
explainer:
"A detailed description of your app. You can later modify README.mdx to add slider and other components",
},
{
label: "Category of App",
name: "appCategory",
type: "select",
options: [
{ label: "Calendar", value: "calendar" },
{
label:
"Static Link - Video(Apps like Ping.gg/Riverside/Whereby which require you to provide a link to join your room)",
value: "video_static",
},
{ label: "Other - Video", value: "video_other" },
{ label: "Payment", value: "payment" },
{ label: "Messaging", value: "messaging" },
{ label: "Web3", value: "web3" },
{ label: "Automation", value: "automation" },
{ label: "Analytics", value: "analytics" },
{ label: "Other", value: "other" },
],
explainer: "This is how apps are categorized in App Store.",
},
{
label: "What kind of app would you consider it?",
name: "extendsFeature",
options: [
{ label: "User", value: "User" },
{
label: "Event Type(Available for configuration in Apps tab for all Event Types)",
value: "EventType",
},
],
},
{ label: "Publisher Name", name: "publisherName", type: "text", explainer: "Let users know who you are" },
{
label: "Publisher Email",
name: "publisherEmail",
type: "text",
explainer: "Let users know how they can contact you.",
},
];
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
const fieldValue = appInputData[fieldName] || "";
const appName = appInputData["appName"];
const rawCategory = appInputData["appCategory"] || "";
const appDescription = appInputData["appDescription"];
const publisherName = appInputData["publisherName"];
const publisherEmail = appInputData["publisherEmail"];
let extendsFeature = appInputData["extendsFeature"] || [];
if (rawCategory === "analytics") {
// Analytics only means EventType Analytics as of now
extendsFeature = "EventType";
}
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
const allFieldsFilled = inputIndex === fields.length;
const [progressUpdate, setProgressUpdate] = useState("");
const category = rawCategory.split("_")[0];
const subCategory = rawCategory.split("_")[1];
if (!editMode) {
slug = getSlugFromAppName(appName);
}
useEffect(() => {
// When all fields have been filled
if (allFieldsFilled) {
const it = BaseAppFork.create({
category,
subCategory,
appDescription,
appName,
slug,
publisherName,
publisherEmail,
extendsFeature,
});
for (const item of it) {
setProgressUpdate(item);
}
Seed.update({ slug, category, noDbUpdate });
generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setStatus("done");
}
});
if (!slug && editMode) {
return <Text>--slug is required</Text>;
}
if (allFieldsFilled) {
return (
<Box flexDirection="column">
<Text>
{editMode
? `Editing app with slug ${slug}`
: `Creating app with name '${appName}' of type '${category}'`}
</Text>
<Text>{progressUpdate}</Text>
{status === "done" ? (
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
<Text bold italic>
Just wait for a few seconds for process to exit and then you are good to go. Your App code
exists at ${getAppDirPath(slug)}
Tip : Go and change the logo of your app by replacing {getAppDirPath(slug) + "/static/icon.svg"}
</Text>
<Text bold italic>
App Summary:
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text color="green">Slug: </Text>
<Text>{slug}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">App URL: </Text>
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Name: </Text>
<Text>{appName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Description: </Text>
<Text>{appDescription}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Category: </Text>
<Text>{category}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Name: </Text>
<Text>{publisherName}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Email: </Text>
<Text>{publisherEmail}</Text>
</Box>
</Box>
</Box>
) : (
<Text>Please wait...</Text>
)}
<Text italic color="gray">
Note: You should not rename app directory manually. Use cli only to do that as it needs to be
updated in DB as well
</Text>
</Box>
);
}
// Hack: using field.name == "appTitle" to identify that app Name has been submitted and not being edited.
if (!editMode && field.name === "appTitle" && slug && fs.existsSync(getAppDirPath(slug))) {
return (
<>
<Text>App with slug {slug} already exists. If you want to edit it, use edit command</Text>
</>
);
}
return (
<Box flexDirection="column">
<Box flexDirection="column">
<Box>
<Text color="green">{`${fieldLabel}:`}</Text>
{field.type == "text" ? (
<TextInput
value={fieldValue}
onSubmit={(value) => {
if (!value) {
return;
}
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field.options}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}
/>
)}
</Box>
<Box>
<Text color="gray" italic>
{field.explainer}
</Text>
</Box>
</Box>
</Box>
);
};
const DeleteApp = ({ noDbUpdate, slug }) => {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [allowDeletion, setAllowDeletion] = useState(false);
const [state, setState] = useState({ done: null, description: null });
useEffect(() => {
if (allowDeletion) {
BaseAppFork.delete({ slug });
Seed.revert({ slug });
generateAppFiles();
setState({ description: `App with slug ${slug} has been deleted`, done: true });
}
}, [allowDeletion, slug]);
return (
<>
<Text>
Confirm the slug of the app that you want to delete. Note, that it would cleanup the app directory,
App table and Credential table
</Text>
{!state.done && (
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState({ description: `Deletion started`, done: true });
setAllowDeletion(true);
} else {
setState({ description: `Slug doesn't match - Should have been ${slug}`, done: true });
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}
/>
)}
<Text>{state.description}</Text>
</>
);
};
const App: FC<{ noDbUpdate?: boolean; command: "create" | "delete"; slug?: string }> = ({
command,
noDbUpdate,
slug,
}) => {
if (command === "create") {
return <CreateApp noDbUpdate={noDbUpdate} />;
}
if (command === "delete") {
return <DeleteApp slug={slug} noDbUpdate={noDbUpdate} />;
}
if (command === "edit") {
return <CreateApp slug={slug} editMode={true} noDbUpdate={noDbUpdate} />;
}
};
module.exports = App;
export default App;