* Remove unused code in InstalledAppsLayout * Add new app categories "crm", "conferencing" and "messaging" * Sort getAppCategories entries alphabetically * Fix 404s on new category pages (and remove hardcoded category lists) * Fix admin apps list not showing "no available apps" for new categories * Recategorise apps * Sync seed-app-store categories with config files * Replace unnecessary seed-app-store.config.json with appStoreMetadata * Copy video.svg to conferencing.svg * Add messaging.svg * Remove web3 from getAppCategories (used by installed apps, admin apps) * Fix app-store-cli categories - Add conferencing - Add CRM - Remove video - Remove web3 * Remove outdated web3 comment in seed-app-store * Update apps/web/public/static/locales/en/common.json * Add cron script to keep db apps in sync with app metadata * Add redirect for app category "video" to "conferencing" * Fix up "video" category overrides to apply to conferencing * Fix conferencing apps not showing as a location for non-team users * Restore "installed_app" string for conferencing apps * Make linter happier * Remove my "installed_app_conferencing_description" as this was fixed upstream * Quick tidy up * Add dry-run to syncAppMeta via CRON_ENABLE_APP_SYNC env * Replace console.log with logger in syncAppMeta --------- Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Omar López <zomars@me.com>
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import fs from "fs";
|
|
import { Box, Newline, Text, useApp } from "ink";
|
|
import SelectInput from "ink-select-input";
|
|
import TextInput from "ink-text-input";
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
import type { AppMeta } from "@calcom/types/App";
|
|
|
|
import { getSlugFromAppName, BaseAppFork, generateAppFiles, getAppDirPath } from "../core";
|
|
import { getApp } from "../utils/getApp";
|
|
import Templates from "../utils/templates";
|
|
import Label from "./Label";
|
|
import { Message } from "./Message";
|
|
|
|
export const AppForm = ({
|
|
template: cliTemplate = "",
|
|
slug: givenSlug = "",
|
|
action,
|
|
}: {
|
|
template?: string;
|
|
slug?: string;
|
|
action: "create" | "edit" | "create-template" | "edit-template";
|
|
}) => {
|
|
cliTemplate = Templates.find((t) => t.value === cliTemplate)?.value || "";
|
|
const { exit } = useApp();
|
|
const isTemplate = action === "create-template" || action === "edit-template";
|
|
const isEditAction = action === "edit" || action === "edit-template";
|
|
let initialConfig = {
|
|
template: cliTemplate,
|
|
name: "",
|
|
description: "",
|
|
category: "",
|
|
publisher: "",
|
|
email: "",
|
|
};
|
|
|
|
const [app] = useState(() => getApp(givenSlug, isTemplate));
|
|
|
|
if ((givenSlug && action === "edit-template") || action === "edit")
|
|
try {
|
|
const config = JSON.parse(
|
|
fs.readFileSync(`${getAppDirPath(givenSlug, isTemplate)}/config.json`).toString()
|
|
) as AppMeta;
|
|
initialConfig = {
|
|
...config,
|
|
category: config.categories[0],
|
|
template: config.__template,
|
|
};
|
|
} catch (e) {}
|
|
|
|
const fields = [
|
|
{
|
|
label: "App Title",
|
|
name: "name",
|
|
type: "text",
|
|
explainer: "Keep it short and sweet like 'Google Meet'",
|
|
optional: false,
|
|
defaultValue: "",
|
|
},
|
|
{
|
|
label: "App Description",
|
|
name: "description",
|
|
type: "text",
|
|
explainer:
|
|
"A detailed description of your app. You can later modify DESCRIPTION.mdx to add markdown as well",
|
|
optional: false,
|
|
defaultValue: "",
|
|
},
|
|
// You can't edit the base template of an App or Template - You need to start fresh for that.
|
|
cliTemplate || isEditAction
|
|
? null
|
|
: {
|
|
label: "Choose a base Template",
|
|
name: "template",
|
|
type: "select",
|
|
options: Templates,
|
|
optional: false,
|
|
defaultValue: "",
|
|
},
|
|
{
|
|
optional: false,
|
|
label: "Category of App",
|
|
name: "category",
|
|
type: "select",
|
|
|
|
// TODO: Refactor and reuse getAppCategories or type as Record<AppCategories,> to enforce consistency
|
|
options: [
|
|
// Manually sorted alphabetically
|
|
{ label: "Analytics", value: "analytics" },
|
|
{ label: "Automation", value: "automation" },
|
|
{ label: "Calendar", value: "calendar" },
|
|
{ label: "Conferencing", value: "conferencing" },
|
|
{ label: "CRM", value: "crm" },
|
|
{ label: "Messaging", value: "messaging" },
|
|
{ label: "Payment", value: "payment" },
|
|
{ label: "Other", value: "other" },
|
|
],
|
|
defaultValue: "",
|
|
explainer: "This is how apps are categorized in App Store.",
|
|
},
|
|
{
|
|
optional: true,
|
|
label: "Publisher Name",
|
|
name: "publisher",
|
|
type: "text",
|
|
explainer: "Let users know who you are",
|
|
defaultValue: "Your Name",
|
|
},
|
|
{
|
|
optional: true,
|
|
label: "Publisher Email",
|
|
name: "email",
|
|
type: "text",
|
|
explainer: "Let users know how they can contact you.",
|
|
defaultValue: "email@example.com",
|
|
},
|
|
].filter((f) => f);
|
|
const [appInputData, setAppInputData] = useState(initialConfig);
|
|
const [inputIndex, setInputIndex] = useState(0);
|
|
const [slugFinalized, setSlugFinalized] = useState(false);
|
|
|
|
const field = fields[inputIndex];
|
|
const fieldLabel = field?.label || "";
|
|
const fieldName = field?.name || "";
|
|
let fieldValue = appInputData[fieldName as keyof typeof appInputData] || "";
|
|
let validationResult: Parameters<typeof Message>[0]["message"] | null = null;
|
|
const { name, category, description, publisher, email, template } = appInputData;
|
|
|
|
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
|
|
const formCompleted = inputIndex === fields.length;
|
|
if (field?.name === "appCategory") {
|
|
// Use template category as the default category
|
|
fieldValue = Templates.find((t) => t.value === appInputData["template"])?.category || "";
|
|
}
|
|
const slug = getSlugFromAppName(name) || givenSlug;
|
|
|
|
useEffect(() => {
|
|
// When all fields have been filled
|
|
(async () => {
|
|
if (formCompleted) {
|
|
await BaseAppFork.create({
|
|
category,
|
|
description,
|
|
name,
|
|
slug,
|
|
publisher,
|
|
email,
|
|
template,
|
|
editMode: isEditAction,
|
|
isTemplate,
|
|
oldSlug: givenSlug,
|
|
});
|
|
|
|
await 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");
|
|
}
|
|
})();
|
|
}, [formCompleted]);
|
|
|
|
if (action === "edit" || action === "edit-template") {
|
|
if (!slug) {
|
|
return <Text>--slug is required</Text>;
|
|
}
|
|
if (!app) {
|
|
return (
|
|
<Message
|
|
message={{
|
|
text: `App with slug ${givenSlug} not found`,
|
|
type: "error",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (status === "done") {
|
|
// HACK: This is a hack to exit the process manually because due to some reason cli isn't automatically exiting
|
|
setTimeout(() => {
|
|
exit();
|
|
}, 500);
|
|
}
|
|
|
|
if (formCompleted) {
|
|
return (
|
|
<Box flexDirection="column">
|
|
{status !== "done" && (
|
|
<Message
|
|
key="progressHeading"
|
|
message={{
|
|
text: isEditAction
|
|
? `Editing app with slug ${slug}`
|
|
: `Creating ${
|
|
action === "create-template" ? "template" : "app"
|
|
} with name '${name}' categorized in '${category}' using template '${template}'`,
|
|
type: "info",
|
|
showInProgressIndicator: true,
|
|
}}
|
|
/>
|
|
)}
|
|
{status === "done" && (
|
|
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
|
|
<Text bold>
|
|
Just wait for a few seconds for process to exit and then you are good to go. Your{" "}
|
|
{isTemplate ? "Template" : "App"} code exists at {getAppDirPath(slug, isTemplate)}
|
|
</Text>
|
|
<Text>
|
|
Tip : Go and change the logo of your {isTemplate ? "template" : "app"} by replacing{" "}
|
|
{getAppDirPath(slug, isTemplate) + "/static/icon.svg"}
|
|
</Text>
|
|
<Newline />
|
|
<Text bold underline color="blue">
|
|
App Summary:
|
|
</Text>
|
|
<Box flexDirection="column">
|
|
<Box flexDirection="row">
|
|
<Text color="green">Slug: </Text>
|
|
<Text>{slug}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">{isTemplate ? "Template" : "App"} URL: </Text>
|
|
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">Name: </Text>
|
|
<Text>{name}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">Description: </Text>
|
|
<Text>{description}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">Category: </Text>
|
|
<Text>{category}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">Publisher Name: </Text>
|
|
<Text>{publisher}</Text>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Text color="green">Publisher Email: </Text>
|
|
<Text>{email}</Text>
|
|
</Box>
|
|
<Text bold>
|
|
Next Step: Enable the app from http://localhost:3000/settings/admin/apps as admin user (Email:
|
|
admin@example.com, Pass: ADMINadmin2022!)
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|
|
if (slug && slug !== givenSlug && fs.existsSync(getAppDirPath(slug, isTemplate))) {
|
|
validationResult = {
|
|
text: `${
|
|
action === "create" ? "App" : "Template"
|
|
} with slug ${slug} already exists. If you want to edit it, use edit command`,
|
|
type: "error",
|
|
};
|
|
|
|
if (slugFinalized) {
|
|
return <Message message={validationResult} />;
|
|
}
|
|
}
|
|
const selectedOptionIndex =
|
|
field?.type === "select" ? field?.options?.findIndex((o) => o.value === fieldValue) : 0;
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Box flexDirection="column">
|
|
{isEditAction ? (
|
|
<Message
|
|
message={{
|
|
text: `\nLet's edit your ${isTemplate ? "Template" : "App"}! We have prefilled the details.\n`,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Message
|
|
message={{
|
|
text: `\nLet's create your ${
|
|
isTemplate ? "Template" : "App"
|
|
}! Start by providing the information that's asked\n`,
|
|
}}
|
|
/>
|
|
)}
|
|
<Box>
|
|
<Label>{`${fieldLabel}`}</Label>
|
|
{field?.type == "text" ? (
|
|
<TextInput
|
|
value={fieldValue}
|
|
placeholder={field?.defaultValue}
|
|
onSubmit={(value) => {
|
|
if (!value && !field.optional) {
|
|
return;
|
|
}
|
|
setSlugFinalized(true);
|
|
setInputIndex((index) => {
|
|
return index + 1;
|
|
});
|
|
}}
|
|
onChange={(value) => {
|
|
setAppInputData((appInputData) => {
|
|
return {
|
|
...appInputData,
|
|
[fieldName]: value,
|
|
};
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
<SelectInput<string>
|
|
items={field?.options}
|
|
itemComponent={(item) => {
|
|
const myItem = item as { value: string; label: string };
|
|
return (
|
|
<Box justifyContent="space-between">
|
|
<Box flexShrink={0} flexGrow={1}>
|
|
<Text color="blue">{myItem.value}: </Text>
|
|
</Box>
|
|
<Text>{item.label}</Text>
|
|
</Box>
|
|
);
|
|
}}
|
|
key={fieldName}
|
|
initialIndex={selectedOptionIndex === -1 ? 0 : selectedOptionIndex}
|
|
onSelect={(item) => {
|
|
setAppInputData((appInputData) => {
|
|
return {
|
|
...appInputData,
|
|
[fieldName]: item.value,
|
|
};
|
|
});
|
|
setInputIndex((index) => {
|
|
return index + 1;
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Box>
|
|
{validationResult ? (
|
|
<Message message={validationResult} />
|
|
) : (
|
|
<Text color="gray" italic>
|
|
{field?.explainer}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|