diff --git a/Signum.Engine/CodeGeneration/CodeGenerator.cs b/Signum.Engine/CodeGeneration/CodeGenerator.cs index b5fc250051..897fed3397 100644 --- a/Signum.Engine/CodeGeneration/CodeGenerator.cs +++ b/Signum.Engine/CodeGeneration/CodeGenerator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -12,6 +12,7 @@ public static class CodeGenerator public static EntityCodeGenerator Entities = new EntityCodeGenerator(); public static LogicCodeGenerator Logic = new LogicCodeGenerator(); public static ReactCodeGenerator React = new ReactCodeGenerator(); + public static ReactHookConverter Hook = new ReactHookConverter(); public static void GenerateCodeConsole() { @@ -22,6 +23,7 @@ public static void GenerateCodeConsole() {"E", Entities.GenerateEntitiesFromDatabaseTables, "Entities (from Database tables)"}, {"L", Logic.GenerateLogicFromEntities, "Logic (from entites)"}, {"React", React.GenerateReactFromEntities, "React (from entites)"}, + {"Hook", Hook.ConvertFilesToHooks, "Hook (from entites)"}, }.Choose(); if (action == null) diff --git a/Signum.Engine/CodeGeneration/ReactHookConverter.cs b/Signum.Engine/CodeGeneration/ReactHookConverter.cs new file mode 100644 index 0000000000..d419addc54 --- /dev/null +++ b/Signum.Engine/CodeGeneration/ReactHookConverter.cs @@ -0,0 +1,172 @@ +using Signum.Utilities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Signum.Engine.CodeGeneration +{ + public class ReactHookConverter + { + public void ConvertFilesToHooks() + { + while (true) + { + IEnumerable files = GetFiles(); + + if (files == null) + return; + + foreach (var file in files) + { + Console.Write(file + "..."); + + var content = File.ReadAllText(file); + + var converted = SimplifyFile(content); + + File.WriteAllText(file, converted); + } + } + } + + public virtual IEnumerable GetFiles() + { + string folder = GetFolder(); + + var files = Directory.EnumerateFiles(folder, "*.tsx", SearchOption.AllDirectories); + + return SelectFiles(folder, files); + } + + public virtual string GetFolder() + { + CodeGenerator.GetSolutionInfo(out var solutionFolder, out var solutionName); + + var folder = $@"{solutionFolder}\{solutionName}.React\App"; + return folder; + } + + public virtual IEnumerable SelectFiles(string folder, IEnumerable files) + { + var result = files.Select(a => a.After(folder)).ChooseConsoleMultiple(); + + if (result.IsEmpty()) + return null; + + return result.Select(a => folder + a); + } + + public virtual string SimplifyFile(string content) + { + HashSet hookImports = new HashSet(); + + + var componentStarts = Regex.Matches(content, @"^(?export )?(?default )?class (?\w+) extends React\.Component<(?.*?)>\s*{\s*\r\n", RegexOptions.Multiline).Cast(); + + foreach (var m in componentStarts.Reverse()) + { + var endMatch = new Regex(@"^}\s*$", RegexOptions.Multiline).Match(content, m.EndIndex()); + + var simplifiedContent = SimplifyClass(content.Substring(m.EndIndex(), endMatch.Index - m.EndIndex()), hookImports); + + string newComponent = m.Groups["export"].Value + m.Groups["default"].Value + "function " + m.Groups["className"].Value + "(p : " + m.Groups["props"].Value + "){\r\n" + + simplifiedContent + + endMatch.Value; + + + content = content.Substring(0, m.Index) + newComponent + content.Substring(endMatch.EndIndex()); + } + + + if (hookImports.Any()) + { + var lastImport = Regex.Matches(content, "^import.*\r\n", RegexOptions.Multiline).Cast().Last(); + + return content.Substring(0, lastImport.EndIndex()) + + $"import {{ {hookImports.ToString(", ")} }} from '@framework/Hooks'\r\n" + + content.Substring(lastImport.EndIndex()); + } + else + { + + return content; + } + } + + public virtual string SimplifyClass(string content, HashSet hookImports) + { + HashSet hooks = new HashSet(); + + var matches = Regex.Matches(content, @"^ (?\w.+)\s*\r\n", RegexOptions.Multiline).Cast().ToList(); + var endMatch = new Regex(@"^ };?\s*$", RegexOptions.Multiline).Matches(content).Cast().ToList(); + + var pairs = matches.Select(m => new { isStart = true, m }) + .Concat(endMatch.Select(m => new { isStart = false, m })) + .OrderBy(p => p.m.Index) + .BiSelect((start, end) => new { start, end }) + .Where(a => a.start.isStart && !a.end.isStart) + .Select(a => new { start = a.start.m, end = a.end.m }) + .ToList(); + + string render = null; + + foreach (var p in pairs.AsEnumerable().Reverse()) + { + var methodContent = content.Substring(p.start.EndIndex(), p.end.Index - p.start.EndIndex()); + + var simplifiedContent = SimplifyMethod(methodContent, hooks, hookImports); + + if (p.start.Value.Contains("render()")) + { + render = simplifiedContent.Lines().Select(l => l.StartsWith(" ") ? l.Substring(2) : l).ToString("\r\n"); + + content = content.Substring(0, p.start.Index) + content.Substring(p.end.EndIndex()); + } + else + { + string newComponent = ConvertToFunction(p.start.Value) + simplifiedContent + p.end.Value; + + content = content.Substring(0, p.start.Index) + newComponent + content.Substring(p.end.EndIndex()); + } + } + + return hooks.ToString(s => s + ";\r\n", "").Indent(2) + content + render; + + } + + public virtual string ConvertToFunction(string value) + { + { + var lambda = Regex.Match(value, @"(? *)(?\w+) *= *\((?.*)\) *=> *{"); + if (lambda.Success) + return $"{lambda.Groups["ident"].Value}function {lambda.Groups["name"].Value}({lambda.Groups["params"].Value}) {{\r\n"; + } + + { + var method = Regex.Match(value, @"(? *)(?\w+) *\((?.*)\) *{"); + if (method.Success) + return $"{method.Groups["ident"].Value}function {method.Groups["name"].Value}({method.Groups["params"].Value}) {{\r\n"; + } + return value; + } + + public virtual string SimplifyMethod(string methodBody, HashSet hooks, HashSet hooksImports) + { + methodBody = methodBody.Replace("this.props", "p"); + + if(methodBody.Contains("this.forceUpdate")) + { + hooksImports.Add("useForceUpdate"); + hooks.Add("const forceUpdate = useForceUpdate()"); + methodBody = methodBody.Replace("this.forceUpdate", "forceUpdate"); + } + methodBody = methodBody.Replace("this.state.", ""); + methodBody = methodBody.Replace("this.", ""); + + return methodBody; + } + } +} diff --git a/Signum.React/Scripts/AsyncImport.tsx b/Signum.React/Scripts/AsyncImport.tsx index 28c8efd49f..0b1d1a34f3 100644 --- a/Signum.React/Scripts/AsyncImport.tsx +++ b/Signum.React/Scripts/AsyncImport.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; +import * as React from "react"; import { match, RouterChildContext, matchPath } from "react-router-dom"; import * as H from "history"; import * as PropTypes from "prop-types"; export interface ComponentModule { - default: React.ComponentClass; + default: React.ComponentClass | React.FunctionComponent; } interface ImportComponentProps { diff --git a/Signum.React/Scripts/Finder.tsx b/Signum.React/Scripts/Finder.tsx index 4c94297746..da83132a4c 100644 --- a/Signum.React/Scripts/Finder.tsx +++ b/Signum.React/Scripts/Finder.tsx @@ -4,7 +4,7 @@ import * as numbro from "numbro" import * as QueryString from "query-string" import * as Navigator from "./Navigator" import { Dic, classes } from './Globals' -import { ajaxGet, ajaxPost, useAPI } from './Services'; +import { ajaxGet, ajaxPost } from './Services'; import { QueryDescription, QueryValueRequest, QueryRequest, QueryEntitiesRequest, FindOptions, @@ -979,16 +979,6 @@ export function getQueryDescription(queryName: PseudoType | QueryKey): Promise - getQueryDescription(fo.queryName) - .then(qd => parseFindOptions(fo, qd)) - .then(fop => API.executeQuery(getQueryRequest(fop), signal))); - } -} - export module API { export function fetchQueryDescription(queryKey: string): Promise { diff --git a/Signum.React/Scripts/Hooks.ts b/Signum.React/Scripts/Hooks.ts new file mode 100644 index 0000000000..629317401f --- /dev/null +++ b/Signum.React/Scripts/Hooks.ts @@ -0,0 +1,72 @@ +import * as React from 'react' +import { FindOptions, ResultTable } from './Search'; +import * as Finder from './Finder'; +import * as Navigator from './Navigator'; +import { Entity, Lite, liteKey } from './Signum.Entities'; +import { EntityBase } from './Lines/EntityBase'; +import { Type } from './Reflection'; + +export function useForceUpdate(): () => void { + return React.useState()[1] as () => void; +} + +export function useAPI(defaultValue: T, key: ReadonlyArray | undefined, makeCall: (signal: AbortSignal) => Promise): T { + + const [data, updateData] = React.useState(defaultValue) + + React.useEffect(() => { + var abortController = new AbortController(); + + updateData(defaultValue); + + makeCall(abortController.signal) + .then(result => !abortController.signal.aborted && updateData(result)) + .done(); + + return () => { + abortController.abort(); + } + }, key); + + return data; +} + +export function useQuery(fo: FindOptions | null): ResultTable | undefined | null { + return useAPI(undefined, [fo && Finder.findOptionsPath(fo)], signal => + fo == null ? Promise.resolve(null) : + Finder.getQueryDescription(fo.queryName) + .then(qd => Finder.parseFindOptions(fo!, qd)) + .then(fop => Finder.API.executeQuery(Finder.getQueryRequest(fop), signal))); +} + + +export function useFetchAndForget(lite: Lite | null): T | null | undefined { + return useAPI(undefined, [lite && liteKey(lite)], signal => + lite == null ? Promise.resolve(null) : + Navigator.API.fetchAndForget(lite)); +} + + +export function useFetchAndRemember(lite: Lite | null): T | null | undefined { + + const forceUpdate = useForceUpdate(); + React.useEffect(() => { + if (lite != null && lite.entity != null) + Navigator.API.fetchAndRemember(lite) + .then(() => forceUpdate()) + .done(); + }, [lite && liteKey(lite)]); + + + if (lite == null) + return null; + + if (lite.entity == null) + return undefined; + + return lite.entity; +} + +export function useFetchAll(type: Type): T[] | undefined { + return useAPI(undefined, [], signal => Navigator.API.fetchAll(type)); +} diff --git a/Signum.React/Scripts/Navigator.tsx b/Signum.React/Scripts/Navigator.tsx index 78ec2e260a..f92a1faccc 100644 --- a/Signum.React/Scripts/Navigator.tsx +++ b/Signum.React/Scripts/Navigator.tsx @@ -865,7 +865,7 @@ export class NamedViewSettings { } } -export type ViewModule = { default: React.ComponentClass }*/> }; +export type ViewModule = { default: React.ComponentClass }*/> | React.FunctionComponent }*/> }; export class ViewPromise { promise!: Promise<(ctx: TypeContext) => React.ReactElement>; @@ -901,11 +901,20 @@ export class ViewPromise { applyViewOverrides(typeName: string, viewName?: string): ViewPromise { this.promise = this.promise.then(func => viewDispatcher.getViewOverrides(typeName, viewName).then(vos => { + + if (vos.length == 0) + return func; + return (ctx: TypeContext) => { var result = func(ctx); - var component = result.type as React.ComponentClass<{ ctx: TypeContext }>; - monkeyPatchComponent(component, vos!); - return result; + var component = result.type as React.ComponentClass<{ ctx: TypeContext }> | React.FunctionComponent<{ ctx: TypeContext }>; + if (component.prototype.render) { + monkeyPatchClassComponent(component as React.ComponentClass<{ ctx: TypeContext }>, vos!); + return result; + } else { + var newFunc = surroundFunctionComponent(component as React.FunctionComponent<{ ctx: TypeContext }>, vos) + return React.createElement(newFunc, result.props); + } }; })); @@ -919,7 +928,7 @@ export class ViewPromise { } } -function monkeyPatchComponent(component: React.ComponentClass<{ ctx: TypeContext }>, viewOverrides: ViewOverride[]) { +function monkeyPatchClassComponent(component: React.ComponentClass<{ ctx: TypeContext }>, viewOverrides: ViewOverride[]) { if (!component.prototype.render) throw new Error("render function not defined in " + component); @@ -934,6 +943,9 @@ function monkeyPatchComponent(component: React.Compo const ctx = this.props.ctx; const view = baseRender.call(this); + if (view == null) + return null; + const replacer = new ViewReplacer(view, ctx); viewOverrides.forEach(vo => vo.override(replacer)); @@ -943,6 +955,21 @@ function monkeyPatchComponent(component: React.Compo component.prototype.render.withViewOverrides = true; } +function surroundFunctionComponent(functionComponent: React.FunctionComponent<{ ctx: TypeContext }>, viewOverrides: ViewOverride[]) { + var result = function NewComponent(props: { ctx: TypeContext }) { + var view = functionComponent(props); + if (view == null) + return null; + + const replacer = new ViewReplacer(view, props.ctx); + viewOverrides.forEach(vo => vo.override(replacer)); + return replacer.result; + }; + + Object.defineProperty(result, "name", { value: functionComponent.name + "VO" }); + + return result; +} export function checkFlag(entityWhen: EntityWhen, isSearch: boolean) { return entityWhen == "Always" || diff --git a/Signum.React/Scripts/Services.ts b/Signum.React/Scripts/Services.ts index 76ab0100d9..b0ab37ee57 100644 --- a/Signum.React/Scripts/Services.ts +++ b/Signum.React/Scripts/Services.ts @@ -1,6 +1,5 @@ import { ModelState } from './Signum.Entities' import { GraphExplorer } from './Reflection' -import * as React from 'react' export interface AjaxOptions { url: string; @@ -384,28 +383,3 @@ export class AbortableRequest { }) as Promise; } } - -export function useForceUpdate(): () => void { - return React.useState()[1] as () => void; -} - -export function useAPI(defaultValue: T, key: ReadonlyArray | undefined, makeCall: (signal: AbortSignal) => Promise): T { - - const [data, updateData] = React.useState(defaultValue) - - React.useEffect(() => { - var abortController = new AbortController(); - - updateData(defaultValue); - - makeCall(abortController.signal) - .then(result => updateData(result)) - .done(); - - return () => { - abortController.abort(); - } - }, key); - - return data; -} diff --git a/Signum.React/Signum.React.csproj b/Signum.React/Signum.React.csproj index d2aedde535..f451b91b53 100644 --- a/Signum.React/Signum.React.csproj +++ b/Signum.React/Signum.React.csproj @@ -47,6 +47,7 @@ + diff --git a/SnippetsTS/react.function.snippet b/SnippetsTS/react.function.snippet new file mode 100644 index 0000000000..755901a9b3 --- /dev/null +++ b/SnippetsTS/react.function.snippet @@ -0,0 +1,43 @@ + +
+ React Function Component + Signum Software + reactFunction + Code snippet for adding an React Function Component that can use React Hooks + + Expansion + +
+ + + + + import * as React from 'react'; + + + + + + ComponentName + Component Class Name + MyComponent + + + EntityType + Type of the Entity + Entity + + + + }) { + return ( +
+ $end$ +
+ ); +} + ]]> +
+
+