Skip to content

Commit

Permalink
basic working example
Browse files Browse the repository at this point in the history
  • Loading branch information
JensRavens committed Nov 23, 2024
1 parent 55458a8 commit 2682e6f
Show file tree
Hide file tree
Showing 31 changed files with 579 additions and 174 deletions.
2 changes: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

class ApplicationController < ActionController::Base
include Shimmer::Localizable
include Shimmer::RemoteNavigation
include Reaction::Controller
include Pundit::Authorization
end
24 changes: 0 additions & 24 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,6 @@

require "benchmark"

class TSXHandler
class << self
def render(path, assigns)
id = Pathname.new(path).relative_path_from(Rails.root.join("app", "views")).to_s
schema = PropSchema.new(path.sub(".tsx", ".props.rb"))
schema_file_path = path.sub(".tsx", ".schema.ts")
File.write(schema_file_path, schema.to_typescript)
props = schema.serialize(assigns).deep_transform_keys { _1.to_s.camelize(:lower) }
response = nil
time = Benchmark.realtime do
response = HTTParty.post("http://localhost:4000/render", body: {path:, props:, id:}.to_json)
end
puts "took #{(time * 1000).round(2)}ms to render"
response.body
end
end

def call(template, source)
"TSXHandler.render('#{template.identifier}', assigns)"
end
end

ActionView::Template.register_template_handler(:tsx, TSXHandler.new)

class PagesController < ApplicationController
before_action :authenticate_user!

Expand Down
15 changes: 3 additions & 12 deletions app/javascript/application.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import '@hotwired/turbo-rails';
import { start } from '@nerdgeschoss/shimmer';
import { Application } from '@hotwired/stimulus';
import { registerControllers } from 'stimulus-vite-helpers';
import 'chartkick/chart.js';
import { Reaction } from './sprinkles/reaction';

const application = Application.start();
const controllers = import.meta.glob('./controllers/**/*_controller.{ts,tsx}', {
eager: true,
});
registerControllers(application, controllers);

start({ application });
const reaction = new Reaction();
reaction.start();
32 changes: 32 additions & 0 deletions app/javascript/components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import logo from '../../../frontend/images/logo.svg';

export function Sidebar(): JSX.Element {
return (
<nav className="sidebar">
<a className="sidebar__header" href="/">
<img className="sidebar__logo" src={logo} alt="logo" />
</a>
<div className="sidebar__links">
<a className="sidebar__link" href="/sprints">
Sprint
</a>
<a className="sidebar__link" href="/leaves">
Leave
</a>
<a className="sidebar__link" href="/payslips">
Payslip
</a>
<a className="sidebar__link" href="/users">
User
</a>
</div>
<div className="sidebar__footer">
<a className="sidebar__avatar" href="/user">
<div className="sidebar__avatar-name">User</div>
<img src="" alt="avatar" />
</a>
</div>
</nav>
);
}
24 changes: 24 additions & 0 deletions app/javascript/sprinkles/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Meta } from './meta';
import { MetaCache } from './meta_cache';

export class History {
cache = new MetaCache();

constructor(private onChange: (meta: Meta) => void) {
window.addEventListener('popstate', this.restore.bind(this));
}

async navigate(url: string): Promise<void> {
const result = await this.cache.fetch(url);
window.history.pushState({}, '', url);
this.onChange(result.meta);
if (result.fresh) return;
this.onChange(await this.cache.refresh(url));
}

async restore(): Promise<void> {
const url = window.location.pathname + window.location.search;
const result = await this.cache.fetch(url);
this.onChange(result.meta);
}
}
9 changes: 9 additions & 0 deletions app/javascript/sprinkles/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class Meta {
component: string;
props: any;

constructor(data: any) {
this.component = data.component;
this.props = data.props;
}
}
37 changes: 37 additions & 0 deletions app/javascript/sprinkles/meta_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Meta } from './meta';

interface CacheResult {
meta: Meta;
fresh: boolean;
}

export class MetaCache {
private cache = new Map<string, Meta>();

async fetch(url: string): Promise<CacheResult> {
console.log('fetch', url, this.cache.has(url));
if (this.cache.has(url)) {
return { meta: this.cache.get(url)!, fresh: false };
}
return { meta: await this.refresh(url), fresh: true };
}

async refresh(url: string): Promise<Meta> {
console.log('refreshing', url);
const response = await fetch(url, {
headers: { accept: 'application/json' },
});
const body = await response.json();
const meta = new Meta(body);
this.cache.set(url, meta);
return body;
}

write(url: string, data: Meta): void {
this.cache.set(url, data);
}

clear(): void {
this.cache.clear();
}
}
48 changes: 48 additions & 0 deletions app/javascript/sprinkles/reaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { History } from './history';
import { Meta } from './meta';

const imports = import.meta.glob('../../views/**/*.tsx', {});

export class Reaction {
private root!: ReturnType<typeof createRoot>;
history = new History((meta) => this.renderPage(meta));

start(): void {
document.addEventListener('DOMContentLoaded', () => {
this.loadPage();
});
document.addEventListener('click', (event) => {
let target = event.target as HTMLAnchorElement;
while (target && target.tagName !== 'A') {
if (target === document.body) return;
target = target.parentElement as HTMLAnchorElement;
}
if (!target) return;
event.preventDefault();
this.history.navigate(target.getAttribute('href')!);
});
}

private async loadPage(): Promise<void> {
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Root element not found');
const metaJson = (
document.querySelector('meta[name="reaction-data"]') as HTMLMetaElement
).content;
const data = metaJson ? JSON.parse(metaJson) : {};
const meta = new Meta(data);
this.history.cache.write(window.location.pathname, meta);
this.root = createRoot(rootElement);
this.renderPage(meta);
}

private async renderPage(meta: Meta): Promise<void> {
const importPath = '../../views/' + meta.component + '.tsx';
const implementation = imports[importPath];
if (!implementation) return;
const App = ((await implementation()) as any).default;
this.root.render(React.createElement(App, { data: meta.props }));
}
}
67 changes: 0 additions & 67 deletions app/models/prop_field.rb

This file was deleted.

4 changes: 1 addition & 3 deletions app/views/layouts/application.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,4 @@ doctype html
html lang=I18n.locale
= render "components/head"
body class=Rails.env
= render "components/flash"
= render "components/sidebar" if current_user
main.content = yield
= yield
File renamed without changes.
11 changes: 4 additions & 7 deletions app/views/pages/home.props.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
field :current_user, null: false do
field :id, String, null: false
field :first_name, String, null: false
end
field :sprint do
field :id, String, null: false
field :title, String, null: false
field :current_user, global: :current_user do
field :id
field :email
field :first_name
end
10 changes: 0 additions & 10 deletions app/views/pages/home.schema.ts

This file was deleted.

22 changes: 14 additions & 8 deletions app/views/pages/home.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import React, { useState } from 'react';
import { Props } from './home.schema.js';
import { PageProps } from '../../../data.d';
import { Sidebar } from '../../javascript/components/sidebar/sidebar';

export default function Home({ currentUser }: Props): JSX.Element {
export default function Home({
data: { currentUser },
}: PageProps<'pages/home'>): JSX.Element {
const [counter, setCounter] = useState(0);
return (
<div id="something">
<h1>Home</h1>
<p>Welcome to the home page, {currentUser.firstName}</p>
<p>Counter: {counter}</p>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
<>
<Sidebar />
<div className="content">
<h1>Home</h1>
<p>Welcome to the home page, {currentUser.firstName}</p>
<p>Counter: {counter}</p>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
}
7 changes: 0 additions & 7 deletions app/views/users/index.html.slim

This file was deleted.

12 changes: 12 additions & 0 deletions app/views/users/index.props.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
field :filter, value: -> { @filter }
field :users, array: true, value: -> { @users } do
field :id
field :avatar_url, value: -> { avatar_image(size: 80) }
field :full_name
field :nick_name, null: true
field :remaining_holidays, Integer
field :current_salary, null: true do
field :brut, Float
field :valid_from, Date
end
end
Loading

0 comments on commit 2682e6f

Please sign in to comment.