feat: Hidden fields and metadata for integrations (#2752)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
This commit is contained in:
Dhruwang Jariwala
2024-06-13 16:44:43 +05:30
committed by GitHub
parent afe01a61ae
commit 543d85eb28
12 changed files with 307 additions and 115 deletions

View File

@@ -2,6 +2,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessIntegration } from "@formbricks/lib/integration/auth";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
import { AuthorizationError } from "@formbricks/types/errors";
@@ -11,6 +12,12 @@ export const createOrUpdateIntegrationAction = async (
environmentId: string,
integrationData: TIntegrationInput
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authenticated");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createOrUpdateIntegration(environmentId, integrationData);
};

View File

@@ -20,6 +20,7 @@ import {
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys";
import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
@@ -46,6 +47,8 @@ export type IntegrationModalInputs = {
table: string;
survey: string;
questions: string[];
includeHiddenFields: boolean;
includeMetadata: boolean;
};
const NoBaseFoundError = () => {
@@ -72,12 +75,24 @@ export const AddIntegrationModal = ({
const [tables, setTables] = useState<TIntegrationAirtableTables["tables"]>([]);
const [isLoading, setIsLoading] = useState(false);
const { handleSubmit, control, watch, setValue, reset } = useForm<IntegrationModalInputs>();
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const airtableIntegrationData: TIntegrationAirtableInput = {
type: "airtable",
config: {
key: airtableIntegration?.config?.key,
data: airtableIntegration.config.data ?? [],
email: airtableIntegration?.config?.email,
},
};
useEffect(() => {
if (isEditMode) {
const { index: _index, ...rest } = defaultData;
reset(rest);
fetchTable(defaultData.base);
setIncludeHiddenFields(defaultData.includeHiddenFields);
setIncludeMetadata(defaultData.includeMetadata);
} else {
reset();
}
@@ -104,15 +119,6 @@ export const AddIntegrationModal = ({
throw new Error("Please select at least one question");
}
const airtableIntegrationData: TIntegrationAirtableInput = {
type: "airtable",
config: {
key: airtableIntegration?.config?.key,
data: airtableIntegration.config.data ?? [],
email: airtableIntegration?.config?.email,
},
};
const currentTable = tables.find((item) => item.id === data.table);
const integrationData: TIntegrationAirtableConfigData = {
surveyId: selectedSurvey.id,
@@ -124,6 +130,8 @@ export const AddIntegrationModal = ({
baseId: data.base,
tableId: data.table,
tableName: currentTable?.name ?? "",
includeHiddenFields,
includeMetadata,
};
if (isEditMode) {
@@ -165,10 +173,10 @@ export const AddIntegrationModal = ({
const handleDelete = async (index: number) => {
try {
const integrationCopy = { ...airtableIntegration };
integrationCopy.config.data.splice(index, 1);
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
await createOrUpdateIntegrationAction(environmentId, integrationCopy);
await createOrUpdateIntegrationAction(environmentId, integrationData);
handleClose();
router.refresh();
@@ -280,42 +288,52 @@ export const AddIntegrationModal = ({
) : null}
{survey && selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
)
)}
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(
field.value?.filter((value) => value !== question.id)
);
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
)
)}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
/>
</div>
)}

View File

@@ -112,6 +112,8 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
includeHiddenFields: !!data.includeHiddenFields,
includeMetadata: !!data.includeMetadata,
index,
});
setIsModalOpen(true);

View File

@@ -17,6 +17,9 @@ export async function getSpreadsheetNameByIdAction(
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getSpreadsheetNameById(googleSheetIntegration, spreadsheetId);
const integrationData = structuredClone(googleSheetIntegration);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
return await getSpreadsheetNameById(integrationData, spreadsheetId);
}

View File

@@ -19,6 +19,7 @@ import {
TIntegrationGoogleSheetsInput,
} from "@formbricks/types/integration/googleSheet";
import { TSurvey } from "@formbricks/types/surveys";
import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
@@ -45,7 +46,7 @@ export const AddIntegrationModal = ({
selectedIntegration,
attributeClasses,
}: AddIntegrationModalProps) => {
const integrationData = {
const integrationData: TIntegrationGoogleSheetsConfigData = {
spreadsheetId: "",
spreadsheetName: "",
surveyId: "",
@@ -61,6 +62,8 @@ export const AddIntegrationModal = ({
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const existingIntegrationData = googleSheetIntegration?.config?.data;
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const googleSheetIntegrationData: TIntegrationGoogleSheetsInput = {
type: "googleSheets",
config: {
@@ -71,7 +74,7 @@ export const AddIntegrationModal = ({
};
useEffect(() => {
if (selectedSurvey) {
if (selectedSurvey && !selectedIntegration) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
setSelectedQuestions(questionIds);
}
@@ -86,6 +89,8 @@ export const AddIntegrationModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
} else {
setSpreadsheetUrl("");
@@ -95,7 +100,7 @@ export const AddIntegrationModal = ({
const linkSheet = async () => {
try {
if (isValidGoogleSheetsUrl(spreadsheetUrl)) {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
throw new Error("Please enter a valid spreadsheet url");
}
if (!selectedSurvey) {
@@ -110,6 +115,7 @@ export const AddIntegrationModal = ({
environmentId,
spreadsheetId
);
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
@@ -121,6 +127,8 @@ export const AddIntegrationModal = ({
? "All questions"
: "Selected questions";
integrationData.createdAt = new Date();
integrationData.includeHiddenFields = includeHiddenFields;
integrationData.includeMetadata = includeMetadata;
if (selectedIntegration) {
// update action
googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData;
@@ -148,12 +156,16 @@ export const AddIntegrationModal = ({
};
const setOpenWithStates = (isOpen: boolean) => {
resetForm();
setOpen(isOpen);
};
const resetForm = () => {
setSpreadsheetUrl("");
setIsLinkingSheet(false);
setSelectedSurvey(null);
setIncludeHiddenFields(false);
setIncludeMetadata(false);
};
const deleteLink = async () => {
@@ -214,33 +226,41 @@ export const AddIntegrationModal = ({
</div>
</div>
{selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)
)}
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)
)}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
/>
</div>
)}
</div>

View File

@@ -14,7 +14,5 @@ export const constructGoogleSheetsUrl = (spreadsheetId: string): string => {
};
export const isValidGoogleSheetsUrl = (url: string): boolean => {
// Regular expression to match Google Sheets URL format
const googleSheetsUrlRegex = /^https:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9-_]+\/?$/;
return googleSheetsUrlRegex.test(url);
return url.startsWith("https://docs.google.com/spreadsheets/d/");
};

View File

@@ -121,11 +121,19 @@ export const AddIntegrationModal = ({
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: fId,
name: `Hidden field : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
return [...questions, ...hiddenFields];
const Metadata = [
{
id: "metadata",
name: `Metadata`,
type: TSurveyQuestionTypeEnum.OpenText,
},
];
return [...questions, ...hiddenFields, ...Metadata];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);

View File

@@ -14,6 +14,7 @@ import {
TIntegrationSlackInput,
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys";
import { AdditionalIntegrationSettings } from "@formbricks/ui/AdditionalIntegrationSettings";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { DropdownSelector } from "@formbricks/ui/DropdownSelector";
@@ -48,6 +49,8 @@ export const AddChannelMappingModal = ({
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [includeHiddenFields, setIncludeHiddenFields] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const existingIntegrationData = slackIntegration?.config?.data;
const slackIntegrationData: TIntegrationSlackInput = {
type: "slack",
@@ -78,6 +81,8 @@ export const AddChannelMappingModal = ({
})!
);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
return;
}
resetForm();
@@ -107,6 +112,8 @@ export const AddChannelMappingModal = ({
? "All questions"
: "Selected questions",
createdAt: new Date(),
includeHiddenFields,
includeMetadata,
};
if (selectedIntegration) {
// update action
@@ -222,30 +229,40 @@ export const AddChannelMappingModal = ({
</div>
{selectedSurvey && (
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)
)}
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map(
(question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)
)}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
/>
</div>
)}
</div>

View File

@@ -10,8 +10,42 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSh
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TPipelineInput } from "@formbricks/types/pipelines";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
if (metadata.source) result.push(`Source: ${metadata.source}`);
if (metadata.url) result.push(`URL: ${metadata.url}`);
if (metadata.userAgent?.browser) result.push(`Browser: ${metadata.userAgent.browser}`);
if (metadata.userAgent?.os) result.push(`OS: ${metadata.userAgent.os}`);
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
if (metadata.country) result.push(`Country: ${metadata.country}`);
if (metadata.action) result.push(`Action: ${metadata.action}`);
// Join all the elements in the result array with a newline for formatting
return result.join("\n");
};
const processDataForIntegration = async (
data: TPipelineInput,
survey: TSurvey,
includeMetadata: boolean,
includeHiddenFields: boolean,
questionIds: string[]
): Promise<string[][]> => {
const ids =
includeHiddenFields && survey.hiddenFields.fieldIds
? [...questionIds, ...survey.hiddenFields.fieldIds]
: questionIds;
const values = await extractResponses(data, ids, survey);
if (includeMetadata) {
values[0].push(convertMetaObjectToString(data.response.meta));
values[1].push("Metadata");
}
return values;
};
export const handleIntegrations = async (
integrations: TIntegration[],
data: TPipelineInput,
@@ -43,8 +77,13 @@ const handleAirtableIntegration = async (
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const values = await extractResponses(data, element.questionIds as string[], survey);
const values = await processDataForIntegration(
data,
survey,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds
);
await airtableWriteData(integration.config.key, element, values);
}
}
@@ -59,11 +98,18 @@ const handleGoogleSheetsIntegration = async (
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const values = await extractResponses(data, element.questionIds, survey);
const values = await processDataForIntegration(
data,
survey,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds
);
const integrationData = structuredClone(integration);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await writeData(integrationData, element.spreadsheetId, values);
}
}
@@ -78,7 +124,13 @@ const handleSlackIntegration = async (
if (integration.config.data.length > 0) {
for (const element of integration.config.data) {
if (element.surveyId === data.surveyId) {
const values = await extractResponses(data, element.questionIds as string[], survey);
const values = await processDataForIntegration(
data,
survey,
!!element.includeMetadata,
!!element.includeHiddenFields,
element.questionIds
);
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
}
}
@@ -86,7 +138,7 @@ const handleSlackIntegration = async (
};
const extractResponses = async (
data: TPipelineInput,
pipelineData: TPipelineInput,
questionIds: string[],
survey: TSurvey
): Promise<string[][]> => {
@@ -94,12 +146,18 @@ const extractResponses = async (
const questions: string[] = [];
for (const questionId of questionIds) {
//check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
responses.push(processResponseData(pipelineData.response.data[questionId]));
questions.push(questionId);
continue;
}
const question = survey?.questions.find((q) => q.id === questionId);
if (!question) {
continue;
}
const responseValue = data.response.data[questionId];
const responseValue = pipelineData.response.data[questionId];
if (responseValue !== undefined) {
let answer: typeof responseValue;
@@ -162,11 +220,16 @@ const buildNotionPayloadProperties = (
});
mapping.forEach((map) => {
const value = responses[map.question.id];
properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, processResponseData(value)),
};
if (map.question.id === "metadata") {
properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)),
};
} else {
const value = responses[map.question.id];
properties[map.column.name] = {
[map.column.type]: getValue(map.column.type, processResponseData(value)),
};
}
});
return properties;

View File

@@ -10,6 +10,8 @@ export const ZIntegrationBase = z.object({
export const ZIntegrationBaseSurveyData = z.object({
createdAt: z.date(),
questionIds: z.array(z.string()),
includeHiddenFields: z.boolean().optional(),
includeMetadata: z.boolean().optional(),
questions: z.string(),
surveyId: z.string(),
surveyName: z.string(),

View File

@@ -0,0 +1,54 @@
import { Checkbox } from "../Checkbox";
import { Label } from "../Label";
interface AdditionalIntegrationSettingsProps {
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (includeHiddenFields: boolean) => void;
setIncludeMetadata: (includeHiddenFields: boolean) => void;
}
export const AdditionalIntegrationSettings = ({
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
}: AdditionalIntegrationSettingsProps) => {
return (
<div className="mt-4">
<Label htmlFor="Surveys">Additional Setings</Label>
<div className="text-sm">
<div className="my-1 flex items-center space-x-2">
<label htmlFor={"includeHiddenFields"} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={"includeHiddenFields"}
value={"includeHiddenFields"}
className="bg-white"
checked={includeHiddenFields}
onCheckedChange={() => {
setIncludeHiddenFields(!includeHiddenFields);
}}
/>
<span className="ml-2 w-[30rem] truncate">Include Hidden Fields</span>
</label>
</div>
<div className="my-1 flex items-center space-x-2">
<label htmlFor={"includeMetadata"} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={"includeMetadata"}
value={"includeMetadata"}
className="bg-white"
checked={includeMetadata}
onCheckedChange={() => {
setIncludeMetadata(!includeMetadata);
}}
/>
<span className="ml-2 w-[30rem] truncate">Include Metadata (Browser, Country, etc.)</span>
</label>
</div>
</div>
</div>
);
};

View File

@@ -53,7 +53,7 @@ export const DropdownSelector = ({
className="z-50 max-h-64 min-w-[220px] max-w-[90%] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{items
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => a.name?.localeCompare(b.name))
.map((item) => (
<DropdownMenuItem
key={item.id}