diff --git a/src/generators.js b/src/generators.js
index 91247c1d..6e6cbdf4 100644
--- a/src/generators.js
+++ b/src/generators.js
@@ -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";
@@ -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":
diff --git a/src/generators/ReactNativeGeneratorV2.js b/src/generators/ReactNativeGeneratorV2.js
new file mode 100644
index 00000000..3d934619
--- /dev/null
+++ b/src/generators/ReactNativeGeneratorV2.js
@@ -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: {
+ ...
+ ${titleLc}: {
+ title: '${titleLc}',
+ headerShown: false,
+ tabBarIcon: ({ 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);
+ }
+}
diff --git a/src/generators/ReactNativeGeneratorV2.test.js b/src/generators/ReactNativeGeneratorV2.test.js
new file mode 100644
index 00000000..1c0626b9
--- /dev/null
+++ b/src/generators/ReactNativeGeneratorV2.test.js
@@ -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();
+});
diff --git a/templates/react-native-v2/app/(tabs)/foos.tsx b/templates/react-native-v2/app/(tabs)/foos.tsx
new file mode 100644
index 00000000..9b6effe5
--- /dev/null
+++ b/templates/react-native-v2/app/(tabs)/foos.tsx
@@ -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 }>();
+
+ 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>({
+ 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 (
+
+
+ {{{ucf}}}s List
+ toggleCreateModal()}>
+ Create
+
+
+ <{{{ucf}}}Context.Provider value={providerValues}>
+
+
+
+ {
+ member && member.length < 1 &&
+
+ {isLoading ? 'Loading data...' : 'No data found'}
+
+ }
+ {
+ error &&
+
+ {error.message}
+
+ }
+ {
+ member && member.map((item: {{{ucf}}}) => (
+ !item.deleted &&
+
+
+ ID: {item['@id']}
+ {{#each fields}}
+ {{#if isReferences}}
+ {{{name}}}:
+ {item['{{{name}}}'].map((ref: any) => {ref})}
+ {{else if reference}}
+ {{{name}}}: {item["{{{name}}}"]}
+ {{else if isEmbeddeds}}
+ {{{name}}}:
+ {item['{{{name}}}'].map((emb: any) => {emb["@id"]})}
+ {{else if embedded}}
+ {{{name}}}: {item["{{{name}}}"]["@id"]}
+ {{else}}
+ {{{name}}}: {item["{{{name}}}"]}
+ {{/if}}
+ {{/each}}
+
+
+ {
+ setCurrentData(item);
+ toggleEditModal();
+ }}>
+
+
+
+
+ ))
+ }
+
+
+
+ {{{ucf}}}Context.Provider>
+
+
+ );
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/app/_layout.tsx.dist b/templates/react-native-v2/app/_layout.tsx.dist
new file mode 100644
index 00000000..8d0825ca
--- /dev/null
+++ b/templates/react-native-v2/app/_layout.tsx.dist
@@ -0,0 +1,44 @@
+import React from 'react';
+import { FontAwesome } from "@expo/vector-icons";
+import "../global.css";
+import { Tabs } from "expo-router";
+import {
+ QueryClient,
+ QueryClientProvider,
+} from '@tanstack/react-query'
+
+function TabBarIcon(props: {
+ name: React.ComponentProps['name'];
+ color: string;
+}) {
+ return ;
+}
+const iconMargin = { marginBottom: -3 }
+
+export default function Layout() {
+ const queryClient = new QueryClient();
+
+ return (
+
+
+
+
+
+ )
+}
+
+const options = {
+ tabsContainer: {
+ headerShown: false,
+ tabBarShowLabel: false,
+ },
+ tabs: {
+ home: {
+ title: 'Accueil',
+ tabBarIcon: ({ color }) => ,
+ },
+ }
+};
\ No newline at end of file
diff --git a/templates/react-native-v2/components/ConfirmModal.tsx b/templates/react-native-v2/components/ConfirmModal.tsx
new file mode 100644
index 00000000..de16ac1e
--- /dev/null
+++ b/templates/react-native-v2/components/ConfirmModal.tsx
@@ -0,0 +1,29 @@
+import { Modal, Pressable, Text, View } from "react-native";
+
+export default function ConfirmModal(props: { isVisible: boolean, onDecline: Function, onAccept: Function }) {
+ const { isVisible, onDecline, onAccept } = props;
+
+ return (
+
+
+
+ Are you sure ?
+
+ onAccept()}>
+ Yes
+
+ onDecline()}>
+ Cancel
+
+
+
+
+
+ )
+}
+
+const containerStyle = { height: '100%', width: '100%' }
\ No newline at end of file
diff --git a/templates/react-native-v2/components/Main.tsx b/templates/react-native-v2/components/Main.tsx
new file mode 100644
index 00000000..fa5fcb14
--- /dev/null
+++ b/templates/react-native-v2/components/Main.tsx
@@ -0,0 +1,16 @@
+import { View } from "react-native";
+
+export default function Main({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+const styles = {
+ container: {
+ position: 'relative',
+ marginHorizontal: '3%',
+ }
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/components/Navigation.tsx b/templates/react-native-v2/components/Navigation.tsx
new file mode 100644
index 00000000..ff65f510
--- /dev/null
+++ b/templates/react-native-v2/components/Navigation.tsx
@@ -0,0 +1,64 @@
+import { HydraView } from "@/lib/types/HydraView";
+import { FontAwesome } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { Pressable, View } from "react-native";
+
+function NavigationIcon(props: {
+ name: React.ComponentProps['name'];
+ color: string;
+}) {
+ return ;
+}
+const iconMargin = { marginBottom: -3 }
+
+export default function Navigation(props: { view: HydraView }) {
+ const view = props.view;
+ if (!view) {
+ return null;
+ }
+
+ const router = useRouter();
+
+ const {
+ "{{{hydraPrefix}}}first": first,
+ "{{{hydraPrefix}}}previous": previous,
+ "{{{hydraPrefix}}}next": next,
+ "{{{hydraPrefix}}}last": last,
+ } = view;
+
+ return (
+
+ {
+ if (first) router.navigate(first)
+ }}>
+
+
+
+ {
+ if (previous) router.navigate(previous)
+ }}>
+
+
+
+ {
+ if (next) router.navigate(next)
+ }}>
+
+
+
+ {
+ if (last) router.navigate(last)
+ }}>
+
+
+
+ );
+}
+
+const styles = {
+ container: {
+ position: 'absolute',
+ bottom: 5,
+ minWidth: '100%'
+ }
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/components/foo/Context.ts b/templates/react-native-v2/components/foo/Context.ts
new file mode 100644
index 00000000..bd3f7f24
--- /dev/null
+++ b/templates/react-native-v2/components/foo/Context.ts
@@ -0,0 +1,15 @@
+import {{{ucf}}} from "@/lib/types/{{{ucf}}}";
+import { Log, addNotificationFunction, clearNotificationsFunction } from "@/lib/utils/Logs";
+import { createContext } from "react";
+
+export type {{{ucf}}}ContextData = {
+ notifications: Log[];
+ addNotification: addNotificationFunction,
+ clearNotifications: clearNotificationsFunction,
+ isModalVisible: boolean;
+ isModalEdit: boolean;
+ setIsModalVisible: (visible: boolean) => void;
+ currentData?: {{{ucf}}};
+}
+
+export const {{{ucf}}}Context = createContext<{{{ucf}}}ContextData>(null);
\ No newline at end of file
diff --git a/templates/react-native-v2/components/foo/CreateEditModal.tsx b/templates/react-native-v2/components/foo/CreateEditModal.tsx
new file mode 100644
index 00000000..f0cad875
--- /dev/null
+++ b/templates/react-native-v2/components/foo/CreateEditModal.tsx
@@ -0,0 +1,82 @@
+import { Modal, Pressable, Text, View } from "react-native";
+import Form from "./Form";
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import {{{ucf}}} from "@/lib/types/{{{ucf}}}";
+import { remove } from "@/lib/api/{{{lc}}}Api";
+import { useContext, useEffect, useState } from "react";
+import ConfirmModal from "../ConfirmModal";
+import { {{{ucf}}}Context } from "./Context";
+
+export default function CreateEditModal() {
+ const [requestDelete, setRequestDelete] = useState(false);
+ const queryClient = useQueryClient();
+
+ const context = useContext({{{ucf}}}Context);
+ const { addNotification, setIsModalVisible, isModalEdit, isModalVisible, currentData: data } = context;
+
+ const deleteMutation = useMutation({
+ mutationFn: (data: {{{ucf}}}) => remove(data),
+ onError: (error: string) => {
+ addNotification('error', error.toString());
+ },
+ onSuccess: (data) => {
+ if (data.ok) {
+ addNotification('success', 'The {{{lc}}} has been deleted');
+ } else {
+ addNotification('error', `An error occured while deleting the {{{lc}}} (${data.statusText})`);
+ }
+ queryClient.invalidateQueries({ queryKey: ['getAll{{{ucf}}}s'] });
+ },
+ });
+
+ useEffect(() => {
+ if (data && data.deleted) {
+ addNotification('error', `${data["@id"]} has been deleted by another user`);
+ setIsModalVisible(false);
+ setRequestDelete(false);
+ }
+ }, [JSON.stringify(data)])
+
+ const onAccept = () => {
+ deleteMutation.mutate(data);
+ setIsModalVisible(false);
+ setRequestDelete(false);
+ }
+
+ const onDecline = () => {
+ setRequestDelete(false);
+ }
+
+ return (
+
+
+
+
+ {isModalEdit ? `Edit {{{ucf}}}` : 'Create a new {{{ucf}}}'} ({ data && data['@id'] })
+
+ {
+ isModalEdit &&
+ setRequestDelete(true)}>
+ Delete
+
+ }
+ setIsModalVisible(false)}>
+ Close
+
+
+
+
+ )
+}
+
+const styles = {
+ container: { height: '80%', width: '100%', backgroundColor: '#e3e9e5' },
+ closeButton: { position: 'absolute', right: 5, top: 5 }
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/components/foo/Form.tsx b/templates/react-native-v2/components/foo/Form.tsx
new file mode 100644
index 00000000..cc91008f
--- /dev/null
+++ b/templates/react-native-v2/components/foo/Form.tsx
@@ -0,0 +1,109 @@
+import {{{ucf}}} from "@/lib/types/{{{ucf}}}";
+import { useContext, useState } from "react";
+import { Controller, SubmitErrorHandler, useForm } from "react-hook-form";
+import { Pressable, Text, TextInput, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { create, update } from "@/lib/api/{{{lc}}}Api";
+import { {{{ucf}}}Context } from "./Context";
+
+export default function Form() {
+ const [errors, setErrors] = useState([]);
+ const queryClient = useQueryClient();
+
+ const context = useContext({{{ucf}}}Context);
+ const { addNotification, isModalEdit, setIsModalVisible, currentData: data } = context;
+
+ const queryFn = isModalEdit ? update : create;
+
+ const mutation = useMutation({
+ mutationFn: (data: {{{ucf}}}) => queryFn(data),
+ onError: (error) => {
+ addNotification('error', error.toString());
+ },
+ onSuccess: (data) => {
+ if (data.ok) {
+ addNotification('success', `The {{{lc}}} has been ${isModalEdit ? 'updated' : 'created'}`);
+ } else {
+ addNotification('error', `An error occured while ${isModalEdit ? 'updating' : 'creating'} the {{{lc}}} (${data.statusText})`);
+ }
+ queryClient.invalidateQueries({ queryKey: ['getAll'] });
+ }
+ });
+
+ let initValues: {{{ucf}}} = (isModalEdit && data) ? data : {
+ '@id': '',
+ {{#each formFields}}
+ {{{name}}}: {{#if (compare type "==" "number")}}0{{else}}''{{/if}},
+ {{/each}}
+ }
+
+ const { control, handleSubmit, reset } = useForm<{{{ucf}}}>({
+ defaultValues: initValues
+ });
+
+ const onSubmit = (data: {{{ucf}}}) => {
+ intParser(data);
+ mutation.mutate(data);
+ setIsModalVisible(false);
+ reset();
+ };
+
+ const intParser = (data: {{{ucf}}}) => {
+ Object.keys(data).forEach(key => {
+ if ((typeof initValues[key] == "number") && !isNaN(parseInt(data[key]))) {
+ data[key] = parseInt(data[key]);
+ }
+ });
+ }
+
+ const onError: SubmitErrorHandler<{{{ucf}}}> = (errors, e) => {
+ setErrors(Object.keys(errors));
+ }
+
+ return (
+
+
+ {errors.length > 0 &&
+
+
+ Field{errors.length > 1 ? "s" : ""} "{errors.join(', ')}" {errors.length > 1 ? "are" : "is"} empty
+
+
+ }
+ {{#each formFields}}
+ (
+
+ {{{name}}} :
+
+
+ )}
+ name="{{{name}}}"
+ {{#if required}}rules={fieldRequired}{{/if}}
+ />
+ {{/each}}
+
+ {isModalEdit ? 'Edit' : 'Create'}
+
+
+
+ );
+}
+
+const fieldRequired = {
+ required: true
+}
+
+const styles = {
+ textInput: { minWidth: 200 }
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/components/foo/LogsRenderer.tsx b/templates/react-native-v2/components/foo/LogsRenderer.tsx
new file mode 100644
index 00000000..41d9d69c
--- /dev/null
+++ b/templates/react-native-v2/components/foo/LogsRenderer.tsx
@@ -0,0 +1,47 @@
+import { Log, LogType } from "@/lib/utils/Logs";
+import { useContext, useMemo } from "react";
+import { Pressable, Text, View } from "react-native";
+import { {{{ucf}}}Context } from "./Context";
+
+export default function LogsRenderer() {
+ const context = useContext({{{ucf}}}Context);
+ const { notifications, clearNotifications } = context;
+
+ const filterLogs = (type: keyof LogType) => {
+ return notifications.filter(log => log.type == type);
+ }
+
+ const errors = useMemo(() => filterLogs("error"), [notifications]);
+ const successes = useMemo(() => filterLogs("success"), [notifications]);
+
+ return (
+
+ {
+ errors.length > 0 &&
+
+
+ {errors.map((error: Log, index: number) => (
+ - {error.message}
+ ))}
+
+ clearNotifications('error')}>
+ X
+
+
+ }
+ {
+ successes.length > 0 &&
+
+
+ {successes.map((success: Log, index: number) => (
+ - {success.message}
+ ))}
+
+ clearNotifications('success')}>
+ X
+
+
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/api/fooApi.ts b/templates/react-native-v2/lib/api/fooApi.ts
new file mode 100644
index 00000000..7865ca6a
--- /dev/null
+++ b/templates/react-native-v2/lib/api/fooApi.ts
@@ -0,0 +1,49 @@
+import { ENTRYPOINT } from '@/config/entrypoint';
+import {{{ucf}}} from '../types/{{{ucf}}}';
+
+const ENDPOINT = `{{{lc}}}s`;
+
+export function getAll(pageId: string) {
+ let page = parseInt(pageId);
+
+ if (page < 1 || isNaN(page)) {
+ page = 1;
+ }
+
+ return fetch(`${ENTRYPOINT}/${ENDPOINT}?page=${page}`).then(res => res.json());
+};
+
+export function update(data: {{{ucf}}}): Promise {
+ return fetch(
+ `${ENTRYPOINT}${data['@id']}`,
+ {
+ method: 'PUT',
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data)
+ }
+ )
+}
+
+export function create(data: {{{ucf}}}): Promise {
+ return fetch(
+ `${ENTRYPOINT}/${ENDPOINT}`,
+ {
+ method: 'POST',
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data)
+ }
+ )
+}
+
+export function remove(data: {{{ucf}}}): Promise {
+ return fetch(
+ `${ENTRYPOINT}${data['@id']}`,
+ {
+ method: 'DELETE',
+ }
+ )
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/hooks/data.ts b/templates/react-native-v2/lib/hooks/data.ts
new file mode 100644
index 00000000..90c09a8d
--- /dev/null
+++ b/templates/react-native-v2/lib/hooks/data.ts
@@ -0,0 +1,36 @@
+import { useCallback, useState } from "react";
+import ApiResource from "../types/ApiResource";
+import { HydraView } from "../types/HydraView";
+
+type useDataType = {
+ member: T[];
+ setMember: (member: T[]) => void,
+ processMercureData: (member: T) => void,
+ view: HydraView;
+ setView: (view: HydraView) => void;
+ currentData: Nullable;
+ setCurrentData: (data: T) => void;
+}
+
+export const useData = (): useDataType => {
+ const [member, setMember] = useState([]);
+ const [view, setView] = useState({});
+ const [currentData, setCurrentData] = useState>(undefined);
+
+ const processMercureData = useCallback((data: T) => {
+ const currentMember = member.find(item => item["@id"] == data["@id"]);
+
+ if (Object.keys(data).length == 1) {
+ data.deleted = true;
+ }
+
+ if (currentMember) {
+ Object.assign(currentMember, data);
+ setMember([...member]); // force re-render
+ } else {
+ setMember([...member, data]);
+ }
+ }, [member]);
+
+ return { member, setMember, processMercureData, view, setView, currentData, setCurrentData };
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/hooks/mercure.ts b/templates/react-native-v2/lib/hooks/mercure.ts
new file mode 100644
index 00000000..06cdb4b4
--- /dev/null
+++ b/templates/react-native-v2/lib/hooks/mercure.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState } from "react";
+import ApiResource from "../types/ApiResource";
+import { ENTRYPOINT } from "@/config/entrypoint";
+import { extractHubURL, mercureSubscribe } from "../utils/mercure";
+
+type useMercureType = {
+ hubURL: Nullable;
+ eventSource: Nullable;
+}
+
+export const useMercure = (topics: string[], setData: (data: T) => void): useMercureType => {
+ const [hubURL, setHubURL] = useState>(undefined);
+ const [eventSource, setEventSource] = useState>(undefined);
+ if (topics.length < 1) return { hubURL, eventSource };
+
+ useEffect(() => {
+ fetch(`${ENTRYPOINT}${topics[0]}`)
+ .then(res => {
+ const extractedUrl = extractHubURL(res);
+ if (extractedUrl) {
+ setHubURL(extractedUrl.href);
+ }
+ });
+
+ if (hubURL) {
+ setEventSource(mercureSubscribe(new URL(hubURL), topics, setData));
+ }
+
+ return () => eventSource && eventSource.close();
+ }, [hubURL, setData]);
+
+ return { hubURL, eventSource };
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/hooks/modal.ts b/templates/react-native-v2/lib/hooks/modal.ts
new file mode 100644
index 00000000..d9ee8957
--- /dev/null
+++ b/templates/react-native-v2/lib/hooks/modal.ts
@@ -0,0 +1,27 @@
+import { useState } from "react";
+
+type useModalType = {
+ isModalVisible: boolean;
+ isModalEdit: boolean;
+ toggleEditModal: () => void;
+ toggleCreateModal: () => void;
+ setIsModalEdit: (state: boolean) => void;
+ setIsModalVisible: (state: boolean) => void;
+}
+
+export const useModal = (): useModalType => {
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [isModalEdit, setIsModalEdit] = useState(false);
+
+ const toggleEditModal = () => {
+ setIsModalVisible(true);
+ setIsModalEdit(true);
+ };
+
+ const toggleCreateModal = () => {
+ setIsModalVisible(true);
+ setIsModalEdit(false);
+ }
+
+ return { isModalEdit, isModalVisible, toggleCreateModal, toggleEditModal, setIsModalEdit, setIsModalVisible }
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/hooks/notifications.ts b/templates/react-native-v2/lib/hooks/notifications.ts
new file mode 100644
index 00000000..d9505052
--- /dev/null
+++ b/templates/react-native-v2/lib/hooks/notifications.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react";
+import { Log, LogType, addNotificationFunction, clearNotificationsFunction } from "../utils/Logs";
+
+type useNotificationsType = {
+ notifications: Log[];
+ setNotifications: (notifications: Log[]) => void;
+ addNotification: addNotificationFunction;
+ clearNotifications: clearNotificationsFunction;
+}
+
+export const useNotifications = (): useNotificationsType => {
+ const [notifications, setNotifications] = useState([]);
+ const TIMEOUT_MS = 5000; // 5 secondes
+
+ useEffect(() => {
+ const timeoutId = setTimeout(() => setNotifications([]), TIMEOUT_MS);
+
+ return () => clearTimeout(timeoutId);
+ }, [notifications]);
+
+
+ const addNotification = (type: keyof LogType, message: string) => {
+ setNotifications([...notifications, new Log(type, message)]);
+ };
+
+ const clearNotifications = (type: keyof LogType) => {
+ setNotifications([...notifications.filter(log => log.type !== type)]);
+ };
+
+ return { notifications, setNotifications, addNotification, clearNotifications };
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/types/ApiResource.ts b/templates/react-native-v2/lib/types/ApiResource.ts
new file mode 100644
index 00000000..aff6788e
--- /dev/null
+++ b/templates/react-native-v2/lib/types/ApiResource.ts
@@ -0,0 +1,4 @@
+export default interface ApiResource {
+ "@id": string;
+ deleted?: boolean;
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/types/HydraResponse.ts b/templates/react-native-v2/lib/types/HydraResponse.ts
new file mode 100644
index 00000000..5a4f1119
--- /dev/null
+++ b/templates/react-native-v2/lib/types/HydraResponse.ts
@@ -0,0 +1,7 @@
+import ApiResource from "./ApiResource";
+import { HydraView } from "./HydraView";
+
+export interface HydraResponse {
+ '{{{hydraPrefix}}}member'?: Array;
+ '{{{hydraPrefix}}}view'?: HydraView;
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/types/HydraView.ts b/templates/react-native-v2/lib/types/HydraView.ts
new file mode 100644
index 00000000..5c874e2c
--- /dev/null
+++ b/templates/react-native-v2/lib/types/HydraView.ts
@@ -0,0 +1,6 @@
+export interface HydraView {
+ '{{{hydraPrefix}}}first'?: string;
+ '{{{hydraPrefix}}}last'?: string;
+ '{{{hydraPrefix}}}previous'?: string;
+ '{{{hydraPrefix}}}next'?: string;
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/types/foo.ts b/templates/react-native-v2/lib/types/foo.ts
new file mode 100644
index 00000000..ebb9682a
--- /dev/null
+++ b/templates/react-native-v2/lib/types/foo.ts
@@ -0,0 +1,7 @@
+import ApiResource from "./ApiResource";
+
+export default interface {{{ucf}}} extends ApiResource {
+ {{#each fields}}
+ {{#if readonly}}readonly{{/if}} {{{name}}}?: {{#if (compare type "==" "Date")}}string{{else}}{{{type}}}{{/if}};
+ {{/each}}
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/utils/Logs.ts b/templates/react-native-v2/lib/utils/Logs.ts
new file mode 100644
index 00000000..5dec4394
--- /dev/null
+++ b/templates/react-native-v2/lib/utils/Logs.ts
@@ -0,0 +1,17 @@
+export interface LogType {
+ "error": string;
+ "success": string;
+}
+
+export class Log {
+ type: keyof LogType;
+ message: string;
+
+ constructor(type: keyof LogType, message: string) {
+ this.type = type;
+ this.message = message;
+ }
+}
+
+export type addNotificationFunction = (type: keyof LogType, message: string) => void;
+export type clearNotificationsFunction = (type: keyof LogType) => void;
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/utils/icons.tsx b/templates/react-native-v2/lib/utils/icons.tsx
new file mode 100644
index 00000000..0ec42e62
--- /dev/null
+++ b/templates/react-native-v2/lib/utils/icons.tsx
@@ -0,0 +1,13 @@
+import { FontAwesome } from "@expo/vector-icons";
+
+type IconProps = {
+ size?: number;
+ name: React.ComponentProps['name'];
+ color?: string;
+}
+
+export function Icon({ size = 32, name, color = "black" }: IconProps) {
+ const iconMargin = { marginBottom: -3 }
+
+ return ;
+}
\ No newline at end of file
diff --git a/templates/react-native-v2/lib/utils/mercure.ts b/templates/react-native-v2/lib/utils/mercure.ts
new file mode 100644
index 00000000..9c159b6d
--- /dev/null
+++ b/templates/react-native-v2/lib/utils/mercure.ts
@@ -0,0 +1,33 @@
+import { ENTRYPOINT } from "@/config/entrypoint";
+import type ApiResource from "@/lib/types/ApiResource";
+
+export const mercureSubscribe = (
+ hubURL: URL,
+ topics: string[],
+ setData: (data: T) => void
+): EventSource => {
+ const url = new URL(hubURL);
+
+ topics.forEach((topic) =>
+ url.searchParams.append("topic", new URL(topic, ENTRYPOINT).toString())
+ );
+
+ const eventSource = new EventSource(url.toString());
+
+ eventSource.addEventListener("message", (event) => {
+ setData(JSON.parse(event.data));
+ });
+
+ return eventSource;
+};
+
+export const extractHubURL = (response: Response): URL | undefined => {
+ const linkHeader = response.headers.get("Link");
+ if (!linkHeader) return undefined;
+
+ const matches = linkHeader.match(
+ /<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/
+ );
+
+ return matches && matches[1] ? new URL(matches[1], ENTRYPOINT) : undefined;
+};
\ No newline at end of file