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(); + }}> + + + + + )) + } + + + + + +
+ ); +} \ 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