Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: new generator for React-Native with expo #379

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/generators.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import NextGenerator from "./generators/NextGenerator.js";
import NuxtGenerator from "./generators/NuxtGenerator.js";
import ReactGenerator from "./generators/ReactGenerator.js";
import ReactNativeGenerator from "./generators/ReactNativeGenerator.js";
import ReactNativeGeneratorV2 from "./generators/ReactNativeGeneratorV2.js";
import TypescriptInterfaceGenerator from "./generators/TypescriptInterfaceGenerator.js";
import VueGenerator from "./generators/VueGenerator.js";
import VuetifyGenerator from "./generators/VuetifyGenerator.js";
Expand All @@ -28,6 +29,8 @@ export default async function generators(generator = "react") {
return wrap(ReactGenerator);
case "react-native":
return wrap(ReactNativeGenerator);
case "react-native-v2":
return wrap(ReactNativeGeneratorV2);
case "typescript":
return wrap(TypescriptInterfaceGenerator);
case "vue":
Expand Down
177 changes: 177 additions & 0 deletions src/generators/ReactNativeGeneratorV2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import chalk from "chalk";
import handlebars from "handlebars";
import BaseGenerator from "./BaseGenerator.js";
import hbhComparison from "handlebars-helpers/lib/comparison.js";

export default class extends BaseGenerator {
constructor(params) {
super(params);

handlebars.registerHelper("ifNotResource", function (item, options) {
if (item === null) {
return options.fn(this);
}
return options.inverse(this);
});

this.registerTemplates(`react-native-v2/`, [
"app/(tabs)/foos.tsx",
"app/_layout.tsx.dist",
"lib/hooks/data.ts",
"lib/hooks/mercure.ts",
"lib/hooks/modal.ts",
"lib/hooks/notifications.ts",
"lib/types/ApiResource.ts",
"lib/types/HydraView.ts",
"lib/types/HydraResponse.ts",
"lib/types/foo.ts",
"lib/utils/Logs.ts",
"lib/utils/mercure.ts",
"lib/utils/icons.tsx",
"lib/api/fooApi.ts",
"components/Main.tsx",
"components/Navigation.tsx",
"components/ConfirmModal.tsx",
"components/foo/CreateEditModal.tsx",
"components/foo/Form.tsx",
"components/foo/LogsRenderer.tsx",
"components/foo/Context.ts",
]);

handlebars.registerHelper("compare", hbhComparison.compare);
}

help(resource) {
const titleLc = resource.title.toLowerCase();

console.log(
'Code for the "%s" resource type has been generated!',
resource.title
);

console.log(
"You should replace app/_layout.tsx by the generated one and add the following route:"
);
console.log(
chalk.green(`
<Tabs.Screen
name="(tabs)/${titleLc}s"
options={options.tabs.${titleLc}}
/>

tabs: {
...
${titleLc}: {
title: '${titleLc}',
headerShown: false,
tabBarIcon: ({ color }) => <TabBarIcon name="home" color={color} />,
},
}
`)
);
}

generate(api, resource, dir) {
const lc = resource.title.toLowerCase();
const titleUcFirst =
resource.title.charAt(0).toUpperCase() + resource.title.slice(1);
const fields = this.parseFields(resource);

const context = {
title: resource.title,
name: resource.name,
lc,
uc: resource.title.toUpperCase(),
fields,
formFields: this.buildFields(fields),
hydraPrefix: this.hydraPrefix,
ucf: titleUcFirst,
};

// Create directories
// These directories may already exist
[
`${dir}/app/(tabs)`,
`${dir}/config`,
`${dir}/components`,
`${dir}/components/${lc}`,
`${dir}/lib`,
`${dir}/lib/api`,
`${dir}/lib/types`,
`${dir}/lib/hooks`,
`${dir}/lib/utils`,
].forEach((dir) => this.createDir(dir, false));

// static files
[
"lib/types/ApiResource.ts",
"lib/hooks/data.ts",
"lib/hooks/mercure.ts",
"lib/hooks/modal.ts",
"lib/hooks/notifications.ts",
"lib/utils/Logs.ts",
"lib/utils/mercure.ts",
"lib/utils/icons.tsx",
"components/Main.tsx",
"components/Navigation.tsx",
"components/ConfirmModal.tsx",
].forEach((file) => this.createFile(file, `${dir}/${file}`));

// templated files ucFirst
["lib/types/%s.ts"].forEach((pattern) =>
this.createFileFromPattern(pattern, dir, [titleUcFirst], context)
);

// templated files lc
[
"app/(tabs)/%ss.tsx",
"app/_layout.tsx.dist",
"lib/api/%sApi.ts",
"components/%s/Context.ts",
"components/%s/CreateEditModal.tsx",
"components/%s/Form.tsx",
"components/%s/LogsRenderer.tsx",
"lib/types/HydraView.ts",
"lib/types/HydraResponse.ts",
].forEach((pattern) =>
this.createFileFromPattern(pattern, dir, [lc], context)
);

this.createEntrypoint(api.entrypoint, `${dir}/config/entrypoint.js`);
}

getDescription(field) {
return field.description ? field.description.replace(/"/g, "'") : "";
}

parseFields(resource) {
const fields = [
...resource.writableFields,
...resource.readableFields,
].reduce((list, field) => {
if (list[field.name]) {
return list;
}

const isReferences = Boolean(
field.reference && field.maxCardinality !== 1
);
const isEmbeddeds = Boolean(field.embedded && field.maxCardinality !== 1);

return {
...list,
[field.name]: {
...field,
type: this.getType(field),
description: this.getDescription(field),
readonly: false,
isReferences,
isEmbeddeds,
isRelations: isEmbeddeds || isReferences,
},
};
}, {});

return Object.values(fields);
}
}
67 changes: 67 additions & 0 deletions src/generators/ReactNativeGeneratorV2.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Api, Resource, Field } from "@api-platform/api-doc-parser";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import tmp from "tmp";
import ReactNativeGeneratorV2 from "./ReactNativeGeneratorV2.js";

const dirname = path.dirname(fileURLToPath(import.meta.url));

test("Generate a React Native V2 app", () => {
const generator = new ReactNativeGeneratorV2({
hydraPrefix: "hydra:",
templateDirectory: `${dirname}/../../templates`,
});
const tmpobj = tmp.dirSync({ unsafeCleanup: true });

const fields = [
new Field("bar", {
id: "http://schema.org/url",
range: "http://www.w3.org/2001/XMLSchema#string",
reference: null,
required: true,
description: "An URL",
}),
];
const resource = new Resource("abc", "http://example.com/foos", {
id: "abc",
title: "abc",
readableFields: fields,
writableFields: fields,
});
const api = new Api("http://example.com", {
entrypoint: "http://example.com:8080",
title: "My API",
resources: [resource],
});
generator.generate(api, resource, tmpobj.name);

[
"/lib/types/ApiResource.ts",
"/lib/types/HydraView.ts",
"/lib/types/HydraResponse.ts",
"/lib/utils/Logs.ts",
"/lib/utils/icons.tsx",
"/lib/utils/Logs.ts",
"/lib/utils/mercure.ts",
"/lib/hooks/data.ts",
"/lib/hooks/mercure.ts",
"/lib/hooks/modal.ts",
"/lib/hooks/notifications.ts",
"/config/entrypoint.js",
"/components/Main.tsx",
"/components/Navigation.tsx",
"/components/ConfirmModal.tsx",
"/app/_layout.tsx.dist",

"/app/(tabs)/abcs.tsx",
"/lib/api/abcApi.ts",
"/lib/types/Abc.ts",
"/components/abc/Context.ts",
"/components/abc/CreateEditModal.tsx",
"/components/abc/Form.tsx",
"/components/abc/LogsRenderer.tsx",
].forEach((file) => expect(fs.existsSync(tmpobj.name + file)).toBe(true));

tmpobj.removeCallback();
});
118 changes: 118 additions & 0 deletions templates/react-native-v2/app/(tabs)/foos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Main from "@/components/Main";
import Navigation from "@/components/Navigation";
import CreateEditModal from "@/components/{{{lc}}}/CreateEditModal";
import LogsRenderer from "@/components/{{{lc}}}/LogsRenderer";
import {{{ucf}}} from "@/lib/types/{{{ucf}}}";
import { useLocalSearchParams, Link } from "expo-router";
import { useEffect } from "react";
import { useQuery } from '@tanstack/react-query'
import { Pressable, ScrollView, Text, View } from "react-native";
import { getAll } from "@/lib/api/{{{lc}}}Api";
import { HydraResponse } from "@/lib/types/HydraResponse";
import { {{{ucf}}}Context, {{{ucf}}}ContextData } from "@/components/{{{lc}}}/Context";
import { useMercure } from "@/lib/hooks/mercure";
import { useData } from "@/lib/hooks/data";
import { useNotifications } from "@/lib/hooks/notifications";
import { useModal } from "@/lib/hooks/modal";
import { Icon } from "@/lib/utils/icons";

export default function Books() {
const { page = '1' } = useLocalSearchParams<{ page: string }>();
const { id = undefined } = useLocalSearchParams<{ id: Nullable<string> }>();

const { member, setMember, processMercureData, view, setView, currentData, setCurrentData } = useData<{{{ucf}}}>();
const { notifications, addNotification, clearNotifications } = useNotifications();
const { isModalEdit, isModalVisible, toggleEditModal, toggleCreateModal, setIsModalVisible } = useModal();

useMercure(['/{{{lc}}}s'], processMercureData);

const { isSuccess, data, isLoading, error } = useQuery<HydraResponse<{{{ucf}}}>>({
queryKey: ['getAll{{{ucf}}}s', page],
queryFn: () => getAll(page),
});

useEffect(() => {
if (isSuccess) {
setMember(data["{{{hydraPrefix}}}member"]);
setView(data['{{{hydraPrefix}}}view']);
}
}, [isSuccess, data]);

useEffect(() => {
if (!id) return;

const data = member.find(item => item["@id"].includes(id) == true);
if (data) {
setCurrentData(data);
toggleEditModal();
}
}, [member, id])

const providerValues: {{{ucf}}}ContextData = { notifications, addNotification, clearNotifications, isModalVisible, isModalEdit, setIsModalVisible, currentData };
const viewButtonStyle = { width: "5vw", minWidth: '100px', height: "100%" };

return (
<Main>
<View className="py-3 flex flex-row items-center justify-between">
<Text className="text-3xl">{{{ucf}}}s List</Text>
<Pressable onPress={() => toggleCreateModal()}>
<Text className="bg-cyan-500 cursor-pointer text-white text-sm font-bold py-2 px-4 rounded">Create</Text>
</Pressable>
</View>
<{{{ucf}}}Context.Provider value={providerValues}>
<ScrollView>
<LogsRenderer />
<View>
{
member && member.length < 1 &&
<View className="flex flex-row justify-between p-4 mb-4 text-sm rounded-lg bg-cyan-300" role="alert">
<Text className="text-1xl">{isLoading ? 'Loading data...' : 'No data found'}</Text>
</View>
}
{
error &&
<View className="flex flex-row justify-between p-4 mb-4 text-sm rounded-lg bg-red-300" role="alert">
<Text className="text-1xl">{error.message}</Text>
</View>
}
{
member && member.map((item: {{{ucf}}}) => (
!item.deleted &&
<View key={item['@id']} className="flex relative my-2 block max-w p-6 bg-white border border-gray-300 rounded shadow">
<View>
<Text>ID: {item['@id']}</Text>
{{#each fields}}
{{#if isReferences}}
<Text>{{{name}}}:</Text>
{item['{{{name}}}'].map((ref: any) => <Link className="text-blue-500" href={`/{{{reference.name}}}?id=${ref}`} key={ref}>{ref}</Link>)}
{{else if reference}}
<Text>{{{name}}}: <Link className="text-blue-500" href={`/{{{reference.name}}}?id=${item["{{{name}}}"]}`} key={item["{{{name}}}"]}>{item["{{{name}}}"]}</Link></Text>
{{else if isEmbeddeds}}
<Text>{{{name}}}:</Text>
{item['{{{name}}}'].map((emb: any) => <Link className="text-blue-500" href={`/{{{embedded.name}}}?id=${emb["@id"]}`} key={emb["@id"]}>{emb["@id"]}</Link>)}
{{else if embedded}}
<Text>{{{name}}}: <Link className="text-blue-500" href={`/{{{embedded.name}}}?id=${item["{{{name}}}"]["@id"]}`} key={item["{{{name}}}"]["@id"]}>{item["{{{name}}}"]["@id"]}</Link></Text>
{{else}}
<Text>{{{name}}}: {item["{{{name}}}"]}</Text>
{{/if}}
{{/each}}
</View>
<View className="flex justify-center items-center absolute bottom-0 right-0 bg-cyan-500" style={viewButtonStyle}>
<Pressable onPress={() => {
setCurrentData(item);
toggleEditModal();
}}>
<Icon name="eye" />
</Pressable>
</View>
</View>
))
}
</View>
<CreateEditModal />
</ScrollView>
</{{{ucf}}}Context.Provider>
<Navigation view={view} />
</Main >
);
}
Loading
Loading