Skip to content

Commit

Permalink
improve support for Functional Components and React Hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
olmobrutall committed Mar 6, 2019
1 parent 92f8c65 commit ff296a9
Show file tree
Hide file tree
Showing 9 changed files with 326 additions and 45 deletions.
4 changes: 3 additions & 1 deletion Signum.Engine/CodeGeneration/CodeGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
Expand All @@ -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()
{
Expand All @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions Signum.Engine/CodeGeneration/ReactHookConverter.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string> 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<string> SelectFiles(string folder, IEnumerable<string> 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<string> hookImports = new HashSet<string>();


var componentStarts = Regex.Matches(content, @"^(?<export>export )?(?<default>default )?class (?<className>\w+) extends React\.Component<(?<props>.*?)>\s*{\s*\r\n", RegexOptions.Multiline).Cast<Match>();

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<Match>().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<string> hookImports)
{
HashSet<string> hooks = new HashSet<string>();

var matches = Regex.Matches(content, @"^ (?<text>\w.+)\s*\r\n", RegexOptions.Multiline).Cast<Match>().ToList();
var endMatch = new Regex(@"^ };?\s*$", RegexOptions.Multiline).Matches(content).Cast<Match>().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, @"(?<ident> *)(?<name>\w+) *= *\((?<params>.*)\) *=> *{");
if (lambda.Success)
return $"{lambda.Groups["ident"].Value}function {lambda.Groups["name"].Value}({lambda.Groups["params"].Value}) {{\r\n";
}

{
var method = Regex.Match(value, @"(?<ident> *)(?<name>\w+) *\((?<params>.*)\) *{");
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<string> hooks, HashSet<string> 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;
}
}
}
4 changes: 2 additions & 2 deletions Signum.React/Scripts/AsyncImport.tsx
Original file line number Diff line number Diff line change
@@ -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<any>;
default: React.ComponentClass<any> | React.FunctionComponent<any>;
}

interface ImportComponentProps {
Expand Down
12 changes: 1 addition & 11 deletions Signum.React/Scripts/Finder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -979,16 +979,6 @@ export function getQueryDescription(queryName: PseudoType | QueryKey): Promise<Q
});
}

export module Hooks {

export function useQuery(fo: FindOptions): ResultTable | undefined {
return useAPI(undefined, [findOptionsPath(fo)], signal =>
getQueryDescription(fo.queryName)
.then(qd => parseFindOptions(fo, qd))
.then(fop => API.executeQuery(getQueryRequest(fop), signal)));
}
}

export module API {

export function fetchQueryDescription(queryKey: string): Promise<QueryDescription> {
Expand Down
72 changes: 72 additions & 0 deletions Signum.React/Scripts/Hooks.ts
Original file line number Diff line number Diff line change
@@ -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<T>(defaultValue: T, key: ReadonlyArray<any> | undefined, makeCall: (signal: AbortSignal) => Promise<T>): T {

const [data, updateData] = React.useState<T>(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<ResultTable | null>(null) :
Finder.getQueryDescription(fo.queryName)
.then(qd => Finder.parseFindOptions(fo!, qd))
.then(fop => Finder.API.executeQuery(Finder.getQueryRequest(fop), signal)));
}


export function useFetchAndForget<T extends Entity>(lite: Lite<T> | null): T | null | undefined {
return useAPI(undefined, [lite && liteKey(lite)], signal =>
lite == null ? Promise.resolve<T | null>(null) :
Navigator.API.fetchAndForget(lite));
}


export function useFetchAndRemember<T extends Entity>(lite: Lite<T> | 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<T extends Entity>(type: Type<T>): T[] | undefined {
return useAPI(undefined, [], signal => Navigator.API.fetchAll(type));
}
37 changes: 32 additions & 5 deletions Signum.React/Scripts/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ export class NamedViewSettings<T extends ModifiableEntity> {
}
}

export type ViewModule<T extends ModifiableEntity> = { default: React.ComponentClass<any /* { ctx: TypeContext<T> }*/> };
export type ViewModule<T extends ModifiableEntity> = { default: React.ComponentClass<any /* { ctx: TypeContext<T> }*/> | React.FunctionComponent<any /*{ ctx: TypeContext<T> }*/> };

export class ViewPromise<T extends ModifiableEntity> {
promise!: Promise<(ctx: TypeContext<T>) => React.ReactElement<any>>;
Expand Down Expand Up @@ -901,11 +901,20 @@ export class ViewPromise<T extends ModifiableEntity> {
applyViewOverrides(typeName: string, viewName?: string): ViewPromise<T> {
this.promise = this.promise.then(func =>
viewDispatcher.getViewOverrides(typeName, viewName).then(vos => {

if (vos.length == 0)
return func;

return (ctx: TypeContext<T>) => {
var result = func(ctx);
var component = result.type as React.ComponentClass<{ ctx: TypeContext<T> }>;
monkeyPatchComponent<T>(component, vos!);
return result;
var component = result.type as React.ComponentClass<{ ctx: TypeContext<T> }> | React.FunctionComponent<{ ctx: TypeContext<T> }>;
if (component.prototype.render) {
monkeyPatchClassComponent<T>(component as React.ComponentClass<{ ctx: TypeContext<T> }>, vos!);
return result;
} else {
var newFunc = surroundFunctionComponent(component as React.FunctionComponent<{ ctx: TypeContext<T> }>, vos)
return React.createElement(newFunc, result.props);
}
};
}));

Expand All @@ -919,7 +928,7 @@ export class ViewPromise<T extends ModifiableEntity> {
}
}

function monkeyPatchComponent<T extends ModifiableEntity>(component: React.ComponentClass<{ ctx: TypeContext<T> }>, viewOverrides: ViewOverride<T>[]) {
function monkeyPatchClassComponent<T extends ModifiableEntity>(component: React.ComponentClass<{ ctx: TypeContext<T> }>, viewOverrides: ViewOverride<T>[]) {

if (!component.prototype.render)
throw new Error("render function not defined in " + component);
Expand All @@ -934,6 +943,9 @@ function monkeyPatchComponent<T extends ModifiableEntity>(component: React.Compo
const ctx = this.props.ctx;

const view = baseRender.call(this);
if (view == null)
return null;


const replacer = new ViewReplacer<T>(view, ctx);
viewOverrides.forEach(vo => vo.override(replacer));
Expand All @@ -943,6 +955,21 @@ function monkeyPatchComponent<T extends ModifiableEntity>(component: React.Compo
component.prototype.render.withViewOverrides = true;
}

function surroundFunctionComponent<T extends ModifiableEntity>(functionComponent: React.FunctionComponent<{ ctx: TypeContext<T> }>, viewOverrides: ViewOverride<T>[]) {
var result = function NewComponent(props: { ctx: TypeContext<T> }) {
var view = functionComponent(props);
if (view == null)
return null;

const replacer = new ViewReplacer<T>(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" ||
Expand Down
Loading

2 comments on commit ff296a9

@olmobrutall
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Hooks 2

This commit (and a children commits) makes the support of React Functional Components (required to use React Hooks) first class in Signum React.

The improvements are:

  • Add a new ReactHookConverter in CodeGenerator to help convert Class components to Function components. Note: only works with components correctly formatted, and manual fixes are required when the component has state.
  • Transform many components in Signum Extensions to funcitonal components signumsoftware/extensions@4c11546
  • Add react.function.snippet activable with reactFunction.
  • Add GenerateFunctionalComponent (with default to true) in ReactCodeGenerator
  • Implement viewOverrides for Functional Components.
  • Create Hooks.ts with useForceUpdate, useAPI, useQuery, useFetchAndForget, useFetchAndRemember, useFetchAll for now...

With this changes, React Functional Components become the default way of building components for entities, but not the only because they still have one important limitation: You can not take a ref of a functional component to call methods on it.

So slowly, maybe more components will be translated to functional components, but not sure if SearchControl, or components that implemente IRenderButtons or ISimpleFilterBuilder will ever be.

Enjoy!

@MehdyKarimpour
Copy link
Contributor

@MehdyKarimpour MehdyKarimpour commented on ff296a9 Mar 7, 2019 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.