Reorganize RSpade directory structure for clarity
Improve Jqhtml_Integration.js documentation with hydration system explanation Add jqhtml-laravel integration packages for traditional Laravel projects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2186,7 +2186,7 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
|
||||
// Transform the file (will use cache if available)
|
||||
try {
|
||||
$transformed_code = \App\RSpade\Core\JavaScript\Js_Transformer::transform($file);
|
||||
$transformed_code = \App\RSpade\Core\JsParsers\Js_Transformer::transform($file);
|
||||
|
||||
// Write transformed code to a temp file
|
||||
$temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '.js');
|
||||
|
||||
@@ -26,8 +26,8 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
'app/RSpade/Core/Data',
|
||||
'app/RSpade/Core/Database',
|
||||
'app/RSpade/Core/SPA',
|
||||
'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*)
|
||||
'app/RSpade/Lib',
|
||||
'app/RSpade/Components',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
29
app/RSpade/Core/Debug/JS_Tree_Debug_Component.jqhtml
Executable file
29
app/RSpade/Core/Debug/JS_Tree_Debug_Component.jqhtml
Executable file
@@ -0,0 +1,29 @@
|
||||
<%--
|
||||
JS_Tree_Debug_Component
|
||||
|
||||
A universal "var_dump" style component for debugging JavaScript values.
|
||||
Renders any JavaScript value as an expandable/collapsible tree, similar to browser DevTools.
|
||||
Useful for debugging, displaying error metadata, and inspecting ORM model instances.
|
||||
|
||||
$data - The JavaScript value to display. Pass directly (unquoted) for objects/arrays:
|
||||
$data=this.data.myObject (correct - passes object reference)
|
||||
$data="<%= this.data.myObject %>" (wrong - stringifies the object)
|
||||
$expand_depth - How many levels deep to expand by default (default: 1)
|
||||
$root_label - Optional label for the root element
|
||||
$show_class_names - If true, display class names for object instances in a small
|
||||
bordered badge (default: false). Shown after { when expanded,
|
||||
after } when collapsed. Only for named class instances, not
|
||||
generic Object or Array.
|
||||
--%>
|
||||
<Define:JS_Tree_Debug_Component tag="div" class="js-tree-debug">
|
||||
<% if (JS_Tree_Debug_Node.get_type(this.args.data) !== 'object' && JS_Tree_Debug_Node.get_type(this.args.data) !== 'array') { %>
|
||||
<span class="js-tree-debug-value js-tree-debug-<%= JS_Tree_Debug_Node.get_type(this.args.data) %>"><%= JS_Tree_Debug_Node.format_value(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
|
||||
<% } else { %>
|
||||
<JS_Tree_Debug_Node
|
||||
$data=this.args.data
|
||||
$expand_depth=(this.args.expand_depth ?? 1)
|
||||
$label=(this.args.root_label || null)
|
||||
$show_class_names=(this.args.show_class_names ?? false)
|
||||
/>
|
||||
<% } %>
|
||||
</Define:JS_Tree_Debug_Component>
|
||||
3
app/RSpade/Core/Debug/JS_Tree_Debug_Component.js
Executable file
3
app/RSpade/Core/Debug/JS_Tree_Debug_Component.js
Executable file
@@ -0,0 +1,3 @@
|
||||
class JS_Tree_Debug_Component extends Component {
|
||||
// No special logic needed at root level - just passes data to JS_Tree_Debug_Node
|
||||
}
|
||||
68
app/RSpade/Core/Debug/JS_Tree_Debug_Node.jqhtml
Executable file
68
app/RSpade/Core/Debug/JS_Tree_Debug_Node.jqhtml
Executable file
@@ -0,0 +1,68 @@
|
||||
<%--
|
||||
JS_Tree_Debug_Node (Internal component for JS_Tree_Debug_Component)
|
||||
|
||||
Renders a single expandable node in the debug tree.
|
||||
Not intended for direct use - use JS_Tree_Debug_Component instead.
|
||||
|
||||
$data - The object or array to render
|
||||
$expand_depth - How many levels deep to expand
|
||||
$label - Optional key/index label for this node
|
||||
$show_class_names - If true, display class names for named object instances
|
||||
--%>
|
||||
<Define:JS_Tree_Debug_Node tag="div" class="js-tree-debug-node">
|
||||
<%
|
||||
const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null;
|
||||
const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data);
|
||||
%>
|
||||
<span class="js-tree-debug-toggle<%= this.is_expanded ? '' : ' js-tree-debug-collapsed' %>" $sid="toggle" @click=this.toggle>
|
||||
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
|
||||
</span>
|
||||
<% if (this.args.label !== null && this.args.label !== undefined) { %>
|
||||
<span class="js-tree-debug-key"><%= this.args.label %></span><span class="js-tree-debug-colon">: </span>
|
||||
<% } %>
|
||||
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? '[' : '{' %></span>
|
||||
<% if (class_name) { %>
|
||||
<span class="js-tree-debug-class-badge"><span class="js-tree-debug-class-name"><%= class_name %></span><% if (this.args.data && this.args.data.id !== undefined) { %><span class="js-tree-debug-class-paren">(</span><span class="js-tree-debug-class-id"><%= this.args.data.id %></span><span class="js-tree-debug-class-paren">)</span><% } %></span>
|
||||
<% } %>
|
||||
<span class="js-tree-debug-preview-collapsed" $sid="preview_collapsed" style="<%= this.is_expanded ? 'display:none' : '' %>"><%= JS_Tree_Debug_Node.get_preview(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
|
||||
|
||||
<div class="js-tree-debug-children" $sid="children" style="<%= this.is_expanded ? '' : 'display:none' %>">
|
||||
<%-- Regular data entries --%>
|
||||
<% for (const [key, value] of JS_Tree_Debug_Node.get_entries(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data))) {
|
||||
const val_type = JS_Tree_Debug_Node.get_type(value);
|
||||
const is_expandable = val_type === 'object' || val_type === 'array';
|
||||
%>
|
||||
<% if (is_expandable) { %>
|
||||
<JS_Tree_Debug_Node
|
||||
$data=value
|
||||
$expand_depth=((this.args.expand_depth ?? 1) - 1)
|
||||
$label=key
|
||||
$show_class_names=(this.args.show_class_names ?? false)
|
||||
/>
|
||||
<% } else { %>
|
||||
<div class="js-tree-debug-leaf">
|
||||
<span class="js-tree-debug-key"><%= key %></span><span class="js-tree-debug-colon">: </span>
|
||||
<span class="js-tree-debug-value js-tree-debug-<%= val_type %>"><%= JS_Tree_Debug_Node.format_value(value, val_type) %></span>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<%-- Relationship nodes (lazy-loaded) --%>
|
||||
<% for (const rel_name of relationships) { %>
|
||||
<div class="js-tree-debug-node js-tree-debug-relationship" $sid="rel_<%= rel_name %>">
|
||||
<%
|
||||
this.handler_toggle_rel = () => this.toggle_relationship(rel_name);
|
||||
%>
|
||||
<span class="js-tree-debug-toggle js-tree-debug-collapsed" @click=this.handler_toggle_rel>
|
||||
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
|
||||
</span>
|
||||
<span class="js-tree-debug-key js-tree-debug-rel-key"><%= rel_name %>()</span><span class="js-tree-debug-colon">: </span>
|
||||
<span class="js-tree-debug-preview-collapsed">...</span>
|
||||
<div class="js-tree-debug-rel-children js-tree-debug-children" style="display:none">
|
||||
<div class="js-tree-debug-leaf js-tree-debug-pending">(click to load)</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? ']' : '}' %></span>
|
||||
</Define:JS_Tree_Debug_Node>
|
||||
257
app/RSpade/Core/Debug/JS_Tree_Debug_Node.js
Executable file
257
app/RSpade/Core/Debug/JS_Tree_Debug_Node.js
Executable file
@@ -0,0 +1,257 @@
|
||||
class JS_Tree_Debug_Node extends Component {
|
||||
|
||||
on_create() {
|
||||
this.is_expanded = (this.args.expand_depth ?? 1) > 0;
|
||||
// Track relationship loading states: { rel_name: 'pending'|'loading'|'loaded'|'error' }
|
||||
this.state.rel_states = {};
|
||||
// Store loaded relationship data: { rel_name: data }
|
||||
this.state.rel_data = {};
|
||||
// Store error messages: { rel_name: error_message }
|
||||
this.state.rel_errors = {};
|
||||
}
|
||||
|
||||
on_ready() {
|
||||
// Relationships are never auto-loaded - they only load when explicitly expanded
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.is_expanded = !this.is_expanded;
|
||||
this.$sid('children').toggle(this.is_expanded);
|
||||
this.$sid('toggle').toggleClass('js-tree-debug-collapsed', !this.is_expanded);
|
||||
this.$sid('preview_collapsed').toggle(!this.is_expanded);
|
||||
// Note: Relationships are NOT auto-loaded here - they have their own toggle handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a relationship node and load its data if not already loaded
|
||||
*/
|
||||
toggle_relationship(rel_name) {
|
||||
const $container = this.$sid('rel_' + rel_name);
|
||||
const $toggle = $container.find('.js-tree-debug-toggle').first();
|
||||
const $children = $container.find('.js-tree-debug-rel-children').first();
|
||||
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
|
||||
|
||||
const is_expanded = !$toggle.hasClass('js-tree-debug-collapsed');
|
||||
|
||||
if (is_expanded) {
|
||||
// Collapse
|
||||
$toggle.addClass('js-tree-debug-collapsed');
|
||||
$children.hide();
|
||||
$preview.show();
|
||||
} else {
|
||||
// Expand
|
||||
$toggle.removeClass('js-tree-debug-collapsed');
|
||||
$children.show();
|
||||
$preview.hide();
|
||||
|
||||
// Load if not already loaded
|
||||
if (!this.state.rel_states[rel_name] || this.state.rel_states[rel_name] === 'pending') {
|
||||
this._load_relationship(rel_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a relationship and update the UI
|
||||
*/
|
||||
async _load_relationship(rel_name) {
|
||||
// Validate the relationship function exists
|
||||
const obj = this.args.data;
|
||||
if (!obj || typeof obj[rel_name] !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.rel_states[rel_name] = 'loading';
|
||||
this._update_relationship_ui(rel_name);
|
||||
|
||||
try {
|
||||
const result = await obj[rel_name]();
|
||||
this.state.rel_states[rel_name] = 'loaded';
|
||||
this.state.rel_data[rel_name] = result;
|
||||
this._update_relationship_ui(rel_name);
|
||||
} catch (e) {
|
||||
this.state.rel_states[rel_name] = 'error';
|
||||
this.state.rel_errors[rel_name] = e.message || 'Error loading relationship';
|
||||
this._update_relationship_ui(rel_name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the UI for a relationship after loading
|
||||
*/
|
||||
_update_relationship_ui(rel_name) {
|
||||
const $container = this.$sid('rel_' + rel_name);
|
||||
const $children = $container.find('.js-tree-debug-rel-children').first();
|
||||
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
|
||||
|
||||
const state = this.state.rel_states[rel_name];
|
||||
const data = this.state.rel_data[rel_name];
|
||||
const error = this.state.rel_errors[rel_name];
|
||||
|
||||
// Update preview text
|
||||
if (state === 'loading') {
|
||||
$preview.html('<span class="js-tree-debug-loading">loading...</span>');
|
||||
} else if (state === 'error') {
|
||||
$preview.html('<span class="js-tree-debug-error">error</span>');
|
||||
} else if (state === 'loaded') {
|
||||
const type = JS_Tree_Debug_Node.get_type(data);
|
||||
$preview.text(JS_Tree_Debug_Node.get_preview(data, type) || JS_Tree_Debug_Node.format_value(data, type));
|
||||
}
|
||||
|
||||
// Update children content
|
||||
$children.empty();
|
||||
|
||||
if (state === 'loading') {
|
||||
$children.html('<div class="js-tree-debug-leaf js-tree-debug-loading">Loading...</div>');
|
||||
} else if (state === 'error') {
|
||||
$children.html('<div class="js-tree-debug-leaf js-tree-debug-error">"' + this._escape_html(error) + '"</div>');
|
||||
} else if (state === 'loaded') {
|
||||
this._render_relationship_result($children, data, rel_name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the result of a relationship fetch into the container
|
||||
* Renders entries directly (not wrapped in another node) so user doesn't have to double-expand
|
||||
*/
|
||||
_render_relationship_result($container, data, rel_name) {
|
||||
const type = JS_Tree_Debug_Node.get_type(data);
|
||||
|
||||
// Handle null/undefined
|
||||
if (type === 'null' || type === 'undefined') {
|
||||
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(no data)</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty array
|
||||
if (type === 'array' && data.length === 0) {
|
||||
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty array)</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty object
|
||||
if (type === 'object' && Object.keys(data).length === 0) {
|
||||
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty object)</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle primitive values (shouldn't happen but be safe)
|
||||
if (type !== 'array' && type !== 'object') {
|
||||
$container.html('<div class="js-tree-debug-leaf"><span class="js-tree-debug-value js-tree-debug-' + type + '">' +
|
||||
this._escape_html(JS_Tree_Debug_Node.format_value(data, type)) + '</span></div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Render entries directly into container (no wrapper node)
|
||||
const entries = JS_Tree_Debug_Node.get_entries(data, type);
|
||||
const expand_depth = Math.max(0, (this.args.expand_depth ?? 1) - 1);
|
||||
const show_class_names = this.args.show_class_names ?? false;
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const val_type = JS_Tree_Debug_Node.get_type(value);
|
||||
const is_expandable = val_type === 'object' || val_type === 'array';
|
||||
|
||||
if (is_expandable) {
|
||||
// Create a node for expandable values
|
||||
const $node = $('<div>');
|
||||
$container.append($node);
|
||||
$node.component('JS_Tree_Debug_Node', {
|
||||
data: value,
|
||||
expand_depth: expand_depth,
|
||||
label: key,
|
||||
show_class_names: show_class_names
|
||||
});
|
||||
} else {
|
||||
// Render leaf value directly
|
||||
$container.append(
|
||||
'<div class="js-tree-debug-leaf">' +
|
||||
'<span class="js-tree-debug-key">' + this._escape_html(String(key)) + '</span>' +
|
||||
'<span class="js-tree-debug-colon">: </span>' +
|
||||
'<span class="js-tree-debug-value js-tree-debug-' + val_type + '">' +
|
||||
this._escape_html(JS_Tree_Debug_Node.format_value(value, val_type)) +
|
||||
'</span></div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_escape_html(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Static helpers for template use
|
||||
static get_type(val) {
|
||||
if (val === null) return 'null';
|
||||
if (val === undefined) return 'undefined';
|
||||
if (is_array(val)) return 'array';
|
||||
return typeof val;
|
||||
}
|
||||
|
||||
static get_preview(val, type) {
|
||||
if (type === 'array') return 'Array(' + val.length + ')';
|
||||
if (type === 'object') {
|
||||
const keys = Object.keys(val);
|
||||
if (keys.length === 0) return '{}';
|
||||
if (keys.length <= 3) return '{' + keys.join(', ') + '}';
|
||||
return '{' + keys.slice(0, 3).join(', ') + ', ...}';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static get_entries(data, type) {
|
||||
if (type === 'array') return data.map((v, i) => [i, v]);
|
||||
return Object.entries(data || {}).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
static format_value(val, type) {
|
||||
if (type === 'string') return '"' + val + '"';
|
||||
if (type === 'null') return 'null';
|
||||
if (type === 'undefined') return 'undefined';
|
||||
return str(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name of an object if it's a named class instance (not generic Object/Array)
|
||||
* @param {*} val - Value to check
|
||||
* @returns {string|null} - Class name or null if generic/primitive
|
||||
*/
|
||||
static get_class_name(val) {
|
||||
if (val === null || val === undefined) return null;
|
||||
if (Array.isArray(val)) return null;
|
||||
if (typeof val !== 'object') return null;
|
||||
const name = val.constructor?.name;
|
||||
if (!name || name === 'Object') return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fetchable relationships for an object
|
||||
* Returns array of relationship names if object's class has get_relationships()
|
||||
* @param {*} obj - Object to check
|
||||
* @returns {string[]} - Array of relationship names, or empty array
|
||||
*/
|
||||
static get_object_relationships(obj) {
|
||||
try {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
if (Array.isArray(obj)) return [];
|
||||
|
||||
// Check if constructor has get_relationships static method
|
||||
const ctor = obj.constructor;
|
||||
if (!ctor || typeof ctor.get_relationships !== 'function') return [];
|
||||
|
||||
// Get relationships and validate it returns an array
|
||||
const relationships = ctor.get_relationships();
|
||||
if (!Array.isArray(relationships)) return [];
|
||||
|
||||
// Filter to only relationships that are actually functions on the object
|
||||
return relationships.filter(name => {
|
||||
return typeof name === 'string' && typeof obj[name] === 'function';
|
||||
});
|
||||
} catch (e) {
|
||||
// Any error, just return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/RSpade/Core/Debug/js_tree_debug_component.scss
Executable file
150
app/RSpade/Core/Debug/js_tree_debug_component.scss
Executable file
@@ -0,0 +1,150 @@
|
||||
.js-tree-debug {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
height: 250px;
|
||||
overflow: auto;
|
||||
resize: both;
|
||||
padding: 20px;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
|
||||
.js-tree-debug-node {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.js-tree-debug-children {
|
||||
margin-left: 20px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.js-tree-debug-leaf {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.js-tree-debug-toggle {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.js-tree-debug-arrow {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
transition: transform 0.15s ease; // @SCSS-ANIM-01-EXCEPTION
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:not(.js-tree-debug-collapsed) .js-tree-debug-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&:hover .js-tree-debug-arrow {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.js-tree-debug-key {
|
||||
color: #881391;
|
||||
}
|
||||
|
||||
.js-tree-debug-colon {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.js-tree-debug-preview {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.js-tree-debug-preview-collapsed {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.js-tree-debug-bracket-close {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// Value type colors
|
||||
.js-tree-debug-string {
|
||||
color: #c41a16;
|
||||
}
|
||||
|
||||
.js-tree-debug-number {
|
||||
color: #1c00cf;
|
||||
}
|
||||
|
||||
.js-tree-debug-boolean {
|
||||
color: #0d22aa;
|
||||
}
|
||||
|
||||
.js-tree-debug-null,
|
||||
.js-tree-debug-undefined {
|
||||
color: #808080;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.js-tree-debug-value {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.js-tree-debug-class-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
margin-left: 4px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: #f5f5f5;
|
||||
vertical-align: middle;
|
||||
line-height: 1.4;
|
||||
|
||||
.js-tree-debug-class-name {
|
||||
color: #881391; // Same as keys
|
||||
}
|
||||
|
||||
.js-tree-debug-class-paren {
|
||||
color: #666; // Same as colons/symbols
|
||||
}
|
||||
|
||||
.js-tree-debug-class-id {
|
||||
color: #1c00cf; // Same as numbers
|
||||
}
|
||||
}
|
||||
|
||||
// Relationship nodes (lazy-loaded)
|
||||
.js-tree-debug-relationship {
|
||||
.js-tree-debug-rel-key {
|
||||
color: #0066cc;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
.js-tree-debug-loading {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Error state
|
||||
.js-tree-debug-error {
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
// Empty/no data state
|
||||
.js-tree-debug-empty {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Pending (not yet loaded)
|
||||
.js-tree-debug-pending {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
470
app/RSpade/Core/Js/Form_Utils.js
Executable file
470
app/RSpade/Core/Js/Form_Utils.js
Executable file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Form utilities for validation and error handling
|
||||
*/
|
||||
class Form_Utils {
|
||||
/**
|
||||
* Framework initialization hook to register jQuery plugin
|
||||
* Creates $.fn.ajax_submit() for form elements
|
||||
* @private
|
||||
*/
|
||||
static _on_framework_core_define(params = {}) {
|
||||
$.fn.ajax_submit = function(options = {}) {
|
||||
const $element = $(this);
|
||||
|
||||
if (!$element.is('form')) {
|
||||
throw new Error('ajax_submit() can only be called on form elements');
|
||||
}
|
||||
|
||||
const url = $element.attr('action');
|
||||
if (!url) {
|
||||
throw new Error('Form must have an action attribute');
|
||||
}
|
||||
|
||||
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
|
||||
|
||||
return Form_Utils.ajax_submit($element, controller, action, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows form validation errors
|
||||
*
|
||||
* REQUIRED HTML STRUCTURE:
|
||||
* For inline field errors to display properly, form fields must follow this structure:
|
||||
*
|
||||
* <div class="form-group">
|
||||
* <label class="form-label" for="field-name">Field Label</label>
|
||||
* <input class="form-control" id="field-name" name="field-name" type="text">
|
||||
* </div>
|
||||
*
|
||||
* Key requirements:
|
||||
* - Wrap each field in a container with class "form-group" (or "form-check" / "input-group")
|
||||
* - Input must have a "name" attribute matching the error key
|
||||
* - Use "form-control" class on inputs for Bootstrap 5 styling
|
||||
*
|
||||
* Accepts three formats:
|
||||
* - String: Single error shown as alert
|
||||
* - Array of strings: Multiple errors shown as bulleted alert
|
||||
* - Object: Field names mapped to errors, shown inline (unmatched shown as alert)
|
||||
*
|
||||
* @param {string} parent_selector - jQuery selector for parent element
|
||||
* @param {string|Object|Array} errors - Error messages to display
|
||||
* @returns {Promise} Promise that resolves when all animations complete
|
||||
*/
|
||||
static apply_form_errors(parent_selector, errors) {
|
||||
console.error(errors);
|
||||
|
||||
const $parent = $(parent_selector);
|
||||
|
||||
// Reset the form errors before applying new ones
|
||||
Form_Utils.reset_form_errors(parent_selector);
|
||||
|
||||
// Normalize input to standard format
|
||||
const normalized = Form_Utils._normalize_errors(errors);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let animations = [];
|
||||
|
||||
if (normalized.type === 'string') {
|
||||
// Single error message
|
||||
animations = Form_Utils._apply_general_errors($parent, normalized.data);
|
||||
} else if (normalized.type === 'array') {
|
||||
// Array of error messages
|
||||
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
|
||||
animations = Form_Utils._apply_general_errors($parent, deduplicated);
|
||||
} else if (normalized.type === 'fields') {
|
||||
// Field-specific errors
|
||||
const result = Form_Utils._apply_field_errors($parent, normalized.data);
|
||||
animations = result.animations;
|
||||
|
||||
// Count matched fields
|
||||
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
|
||||
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
|
||||
const unmatched_count = Object.keys(unmatched_deduplicated).length;
|
||||
|
||||
// Show summary alert if there are any field errors (matched or unmatched)
|
||||
if (matched_count > 0 || unmatched_count > 0) {
|
||||
// Build summary message
|
||||
let summary_msg = '';
|
||||
if (matched_count > 0) {
|
||||
summary_msg = matched_count === 1
|
||||
? 'Please correct the error highlighted below.'
|
||||
: 'Please correct the errors highlighted below.';
|
||||
}
|
||||
|
||||
// If there are unmatched errors, add them as a bulleted list
|
||||
if (unmatched_count > 0) {
|
||||
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
|
||||
animations.push(...summary_animations);
|
||||
} else {
|
||||
// Just the summary message, no unmatched errors
|
||||
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
|
||||
animations.push(...summary_animations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the promise once all animations are complete
|
||||
Promise.all(animations).then(() => {
|
||||
// Scroll to error container if it exists
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
if ($error_container.length > 0) {
|
||||
const container_top = $error_container.offset().top;
|
||||
|
||||
// Calculate fixed header offset
|
||||
const fixed_header_height = Form_Utils._get_fixed_header_height();
|
||||
|
||||
// Scroll to position error container 20px below any fixed headers
|
||||
const target_scroll = container_top - fixed_header_height - 20;
|
||||
$('html, body').animate({
|
||||
scrollTop: target_scroll
|
||||
}, 500);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears form validation errors and resets all form values to defaults
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
*/
|
||||
static reset(form_selector) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
$form.trigger('reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes form data into key-value object
|
||||
* Returns all input elements with name attributes as object properties
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
* @returns {Object} Form data as key-value pairs
|
||||
*/
|
||||
static serialize(form_selector) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
const data = {};
|
||||
|
||||
$form.serializeArray().forEach((item) => {
|
||||
data[item.name] = item.value;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits form to RSX controller action via AJAX
|
||||
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
|
||||
* @param {string} controller - Controller class name (e.g., 'User_Controller')
|
||||
* @param {string} action - Action method name (e.g., 'save_profile')
|
||||
* @param {Object} options - Optional configuration {on_success: fn, on_error: fn}
|
||||
* @returns {Promise} Promise that resolves with response data
|
||||
*/
|
||||
static async ajax_submit(form_selector, controller, action, options = {}) {
|
||||
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
|
||||
const form_data = Form_Utils.serialize($form);
|
||||
|
||||
Form_Utils.reset_form_errors(form_selector);
|
||||
|
||||
try {
|
||||
const response = await Ajax.call(controller, action, form_data);
|
||||
|
||||
if (options.on_success) {
|
||||
options.on_success(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.type === 'form_error' && error.details) {
|
||||
await Form_Utils.apply_form_errors(form_selector, error.details);
|
||||
} else {
|
||||
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
|
||||
}
|
||||
|
||||
if (options.on_error) {
|
||||
options.on_error(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes form validation errors
|
||||
* @param {string} parent_selector - jQuery selector for parent element
|
||||
*/
|
||||
static reset_form_errors(parent_selector) {
|
||||
const $parent = $(parent_selector);
|
||||
|
||||
// Remove flash messages
|
||||
$('.flash-messages').remove();
|
||||
|
||||
// Remove alert-danger messages
|
||||
$parent.find('.alert-danger').remove();
|
||||
|
||||
// Remove validation error classes and text from form elements
|
||||
$parent.find('.is-invalid').removeClass('is-invalid');
|
||||
$parent.find('.invalid-feedback').remove();
|
||||
}
|
||||
|
||||
// ------------------------
|
||||
|
||||
/**
|
||||
* Normalizes error input into standard formats
|
||||
* @param {string|Object|Array} errors - Raw error input
|
||||
* @returns {Object} Normalized errors as {type: 'string'|'array'|'fields', data: ...}
|
||||
* @private
|
||||
*/
|
||||
static _normalize_errors(errors) {
|
||||
// Handle null/undefined
|
||||
if (!errors) {
|
||||
return { type: 'string', data: 'An error has occurred' };
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof errors === 'string') {
|
||||
return { type: 'string', data: errors };
|
||||
}
|
||||
|
||||
// Handle array
|
||||
if (Array.isArray(errors)) {
|
||||
// Array of strings - general errors
|
||||
if (errors.every((e) => typeof e === 'string')) {
|
||||
return { type: 'array', data: errors };
|
||||
}
|
||||
// Array with object as first element - extract it
|
||||
if (errors.length > 0 && typeof errors[0] === 'object') {
|
||||
return Form_Utils._normalize_errors(errors[0]);
|
||||
}
|
||||
// Empty or mixed array
|
||||
return { type: 'array', data: [] };
|
||||
}
|
||||
|
||||
// Handle object - check for Laravel response wrapper
|
||||
if (typeof errors === 'object') {
|
||||
// Unwrap {errors: {...}} or {error: {...}}
|
||||
const unwrapped = errors.errors || errors.error;
|
||||
if (unwrapped) {
|
||||
return Form_Utils._normalize_errors(unwrapped);
|
||||
}
|
||||
|
||||
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
|
||||
const normalized = {};
|
||||
for (const field in errors) {
|
||||
if (errors.hasOwnProperty(field)) {
|
||||
const value = errors[field];
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
normalized[field] = value[0];
|
||||
} else if (typeof value === 'string') {
|
||||
normalized[field] = value;
|
||||
} else {
|
||||
normalized[field] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'fields', data: normalized };
|
||||
}
|
||||
|
||||
// Final catch-all*
|
||||
return { type: 'string', data: String(errors) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate error messages from array or object values
|
||||
* @param {Array|Object} errors - Errors to deduplicate
|
||||
* @returns {Array|Object} Deduplicated errors
|
||||
* @private
|
||||
*/
|
||||
static _deduplicate_errors(errors) {
|
||||
if (Array.isArray(errors)) {
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
if (typeof errors === 'object') {
|
||||
const seen = new Set();
|
||||
const result = {};
|
||||
for (const key in errors) {
|
||||
const value = errors[key];
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies field-specific validation errors to form inputs
|
||||
* @param {jQuery} $parent - Parent element containing form
|
||||
* @param {Object} field_errors - Object mapping field names to error messages
|
||||
* @returns {Object} Object containing {animations: Array, unmatched: Object}
|
||||
* @private
|
||||
*/
|
||||
static _apply_field_errors($parent, field_errors) {
|
||||
const animations = [];
|
||||
const unmatched = {};
|
||||
|
||||
for (const field_name in field_errors) {
|
||||
const error_message = field_errors[field_name];
|
||||
const $input = $parent.find(`[name="${field_name}"]`);
|
||||
|
||||
if (!$input.length) {
|
||||
unmatched[field_name] = error_message;
|
||||
continue;
|
||||
}
|
||||
|
||||
const $error = $('<div class="invalid-feedback"></div>').html(error_message);
|
||||
const $target = $input.closest('.form-group, .form-check, .input-group');
|
||||
|
||||
if (!$target.length) {
|
||||
unmatched[field_name] = error_message;
|
||||
continue;
|
||||
}
|
||||
|
||||
$input.addClass('is-invalid');
|
||||
$error.appendTo($target);
|
||||
animations.push($error.hide().fadeIn(300).promise());
|
||||
}
|
||||
|
||||
return { animations, unmatched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies combined error message with summary and unmatched field errors
|
||||
* @param {jQuery} $parent - Parent element containing form
|
||||
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
|
||||
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
|
||||
* @returns {Array} Array of animation promises
|
||||
* @private
|
||||
*/
|
||||
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
|
||||
const animations = [];
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
// Create alert with summary message and bulleted list of unmatched errors
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
|
||||
|
||||
// Add summary message if provided
|
||||
if (summary_msg) {
|
||||
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
|
||||
}
|
||||
|
||||
// Add unmatched errors as bulleted list
|
||||
if (Object.keys(unmatched_errors).length > 0) {
|
||||
const $list = $('<ul class="mb-0"></ul>');
|
||||
for (const field_name in unmatched_errors) {
|
||||
const error_msg = unmatched_errors[field_name];
|
||||
$('<li></li>').html(error_msg).appendTo($list);
|
||||
}
|
||||
$list.appendTo($alert);
|
||||
}
|
||||
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies general error messages as alert box
|
||||
* @param {jQuery} $parent - Parent element to prepend alert to
|
||||
* @param {string|Array} messages - Error message(s) to display
|
||||
* @returns {Array} Array of animation promises
|
||||
* @private
|
||||
*/
|
||||
static _apply_general_errors($parent, messages) {
|
||||
const animations = [];
|
||||
|
||||
// Look for a specific error container div (e.g., in Rsx_Form component)
|
||||
const $error_container = $parent.find('[data-id="error_container"]').first();
|
||||
const $target = $error_container.length > 0 ? $error_container : $parent;
|
||||
|
||||
if (typeof messages === 'string') {
|
||||
// Single error - simple alert without list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (Array.isArray(messages) && messages.length > 0) {
|
||||
// Multiple errors - bulleted list
|
||||
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
|
||||
const $list = $alert.find('ul');
|
||||
|
||||
messages.forEach((msg) => {
|
||||
const text = (msg + '').trim() || 'An error has occurred';
|
||||
$('<li></li>').html(text).appendTo($list);
|
||||
});
|
||||
|
||||
if ($error_container.length > 0) {
|
||||
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
|
||||
} else {
|
||||
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
|
||||
}
|
||||
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
|
||||
// Object of unmatched field errors - convert to array
|
||||
const error_list = Object.values(messages)
|
||||
.map((v) => String(v).trim())
|
||||
.filter((v) => v);
|
||||
if (error_list.length > 0) {
|
||||
return Form_Utils._apply_general_errors($parent, error_list);
|
||||
}
|
||||
}
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total height of fixed/sticky headers at the top of the page
|
||||
* @returns {number} Total height in pixels of fixed top elements
|
||||
* @private
|
||||
*/
|
||||
static _get_fixed_header_height() {
|
||||
let total_height = 0;
|
||||
|
||||
// Find all fixed or sticky positioned elements
|
||||
$('*').each(function() {
|
||||
const $el = $(this);
|
||||
const position = $el.css('position');
|
||||
|
||||
// Only check fixed or sticky elements
|
||||
if (position !== 'fixed' && position !== 'sticky') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element is positioned at or near the top
|
||||
const top = parseInt($el.css('top')) || 0;
|
||||
if (top > 50) {
|
||||
return; // Not a top header
|
||||
}
|
||||
|
||||
// Check if element is visible
|
||||
if (!$el.is(':visible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if element spans significant width (likely a header/navbar)
|
||||
const width = $el.outerWidth();
|
||||
const viewport_width = $(window).width();
|
||||
if (width < viewport_width * 0.5) {
|
||||
return; // Too narrow to be a header
|
||||
}
|
||||
|
||||
// Add this element's height
|
||||
total_height += $el.outerHeight();
|
||||
});
|
||||
|
||||
return total_height;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\JavaScript;
|
||||
namespace App\RSpade\Core\JsParsers;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\JavaScript;
|
||||
namespace App\RSpade\Core\JsParsers;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use App\RSpade\Core\JavaScript\Js_Exception;
|
||||
use App\RSpade\Core\JsParsers\Js_Exception;
|
||||
|
||||
class Js_Parser
|
||||
{
|
||||
@@ -22,7 +22,7 @@ class Js_Parser
|
||||
/**
|
||||
* Node.js RPC server script path
|
||||
*/
|
||||
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-parser-server.js';
|
||||
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-parser-server.js';
|
||||
|
||||
/**
|
||||
* Unix socket path for RPC server
|
||||
@@ -10,7 +10,7 @@
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\JavaScript;
|
||||
namespace App\RSpade\Core\JsParsers;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -22,12 +22,12 @@ class Js_Transformer
|
||||
/**
|
||||
* Node.js transformer script path (RPC server)
|
||||
*/
|
||||
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-transformer-server.js';
|
||||
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-transformer-server.js';
|
||||
|
||||
/**
|
||||
* Transformer script path for availability checking (RPC server used for actual transformations)
|
||||
*/
|
||||
protected const TRANSFORMER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-transformer.js';
|
||||
protected const TRANSFORMER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-transformer.js';
|
||||
|
||||
/**
|
||||
* Cache directory for transformed JavaScript files
|
||||
@@ -99,7 +99,7 @@ When `start_rpc_server()` is called:
|
||||
```php
|
||||
$process = new Process([
|
||||
'node',
|
||||
base_path('app/RSpade/Core/JavaScript/resource/js-parser-server.js'),
|
||||
base_path('app/RSpade/Core/JsParsers/resource/js-parser-server.js'),
|
||||
'--socket=' . $socket_path
|
||||
]);
|
||||
$process->start();
|
||||
@@ -348,11 +348,11 @@ When implementing RPC server for another operation:
|
||||
|
||||
Reference these when implementing pattern elsewhere:
|
||||
|
||||
- **PHP Server Management:** `/system/app/RSpade/Core/JavaScript/Js_Parser.php`
|
||||
- **PHP Server Management:** `/system/app/RSpade/Core/JsParsers/Js_Parser.php`
|
||||
- Methods: `start_rpc_server()`, `stop_rpc_server()`, `ping_rpc_server()`
|
||||
- Lines: 528-680
|
||||
|
||||
- **Node.js RPC Server:** `/system/app/RSpade/Core/JavaScript/resource/js-parser-server.js`
|
||||
- **Node.js RPC Server:** `/system/app/RSpade/Core/JsParsers/resource/js-parser-server.js`
|
||||
- Full example server with line-delimited JSON handling
|
||||
- Socket cleanup, signal handlers, graceful shutdown
|
||||
|
||||
@@ -398,7 +398,7 @@ echo '{"id":1,"method":"ping"}' | nc -U storage/rsx-tmp/js-parser-server.sock
|
||||
### Manual Server Test
|
||||
```bash
|
||||
# Start server manually
|
||||
node system/app/RSpade/Core/JavaScript/resource/js-parser-server.js \
|
||||
node system/app/RSpade/Core/JsParsers/resource/js-parser-server.js \
|
||||
--socket=/tmp/test.sock
|
||||
|
||||
# In another terminal:
|
||||
@@ -51,7 +51,7 @@ The build process in Manifest.php follows these phases:
|
||||
|
||||
### Phase 1: File Discovery (__discover_files)
|
||||
- Scans directories from `config('rsx.manifest.scan_directories')`
|
||||
- Default: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
|
||||
- Default: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Core/Manifest/Modules']`
|
||||
- Excludes: vendor/, node_modules/, .git/, storage/, public/
|
||||
- Returns array of file paths with basic stats (mtime, size)
|
||||
|
||||
@@ -213,7 +213,7 @@ This ensures **fail-fast behavior** - no silent failures.
|
||||
|
||||
### Built-in Modules
|
||||
|
||||
Located in `/app/RSpade/Modules/`:
|
||||
Located in `/app/RSpade/Core/Manifest/Modules/`:
|
||||
|
||||
- `Php_ManifestModule` - PHP reflection and attribute extraction
|
||||
- `Blade_ManifestModule` - Blade directive parsing
|
||||
@@ -310,7 +310,7 @@ When testing manifest functionality:
|
||||
- Cache file: `storage/rsx-build/manifest_data.php`
|
||||
- JS stubs: `storage/rsx-build/js-stubs/`
|
||||
- Model stubs: `storage/rsx-build/js-model-stubs/`
|
||||
- Default scan dirs: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
|
||||
- Default scan dirs: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Core/Manifest/Modules']`
|
||||
|
||||
## Direct Data Access
|
||||
|
||||
|
||||
@@ -2494,7 +2494,7 @@ class Manifest
|
||||
|
||||
case 'js':
|
||||
console_debug('BUILD', "Parsing JS file: {$file_path}");
|
||||
$js_metadata = \App\RSpade\Core\JavaScript\Js_Parser::extract_metadata($absolute_path);
|
||||
$js_metadata = \App\RSpade\Core\JsParsers\Js_Parser::extract_metadata($absolute_path);
|
||||
$data = array_merge($data, $js_metadata);
|
||||
break;
|
||||
|
||||
|
||||
170
app/RSpade/Core/Manifest/Modules/Model_ManifestSupport.php
Executable file
170
app/RSpade/Core/Manifest/Modules/Model_ManifestSupport.php
Executable file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Manifest\Modules;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use ReflectionClass;
|
||||
use App\RSpade\Core\Cache\RsxCache;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for extracting database metadata for Rsx_Model_Abstract classes
|
||||
* This runs after the primary manifest is built to add model metadata
|
||||
*/
|
||||
class Model_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Process the manifest and add model database metadata
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize models key if it doesn't exist
|
||||
if (!isset($manifest_data['data']['models'])) {
|
||||
$manifest_data['data']['models'] = [];
|
||||
}
|
||||
|
||||
// All PHP files should already be loaded in Phase 3 of manifest processing
|
||||
// Get all classes extending Rsx_Model_Abstract
|
||||
$model_entries = Manifest::php_get_extending('Rsx_Model_Abstract');
|
||||
|
||||
foreach ($model_entries as $model_entry) {
|
||||
if (!isset($model_entry['fqcn'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fqcn = $model_entry['fqcn'];
|
||||
$class_name = $model_entry['class'] ?? '';
|
||||
|
||||
$cachekey = 'Model_ManifestSupport_' . $model_entry['file'] . '__' . $model_entry['hash'];
|
||||
$cache = RsxCache::get_persistent($cachekey);
|
||||
|
||||
if (!empty($cache)) {
|
||||
$manifest_data['data']['models'][$class_name] = $cache;
|
||||
}
|
||||
|
||||
include_once($model_entry['file']);
|
||||
|
||||
// Check if class is abstract
|
||||
if (\App\RSpade\Core\Manifest\Manifest::php_is_abstract($fqcn)) {
|
||||
// Skip abstract classes
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instantiate the model to get table name
|
||||
$model_instance = new $fqcn();
|
||||
$table_name = $model_instance->getTable();
|
||||
|
||||
// Check if table exists
|
||||
if (!Schema::hasTable($table_name)) {
|
||||
// This is acceptable, maybe migrations didnt run or migrations are broken. Just skip adding
|
||||
// the database metadata to the manifest.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get column information
|
||||
$columns = [];
|
||||
|
||||
// Use SHOW COLUMNS to get column information
|
||||
$column_results = DB::select("SHOW COLUMNS FROM `{$table_name}`");
|
||||
|
||||
foreach ($column_results as $column) {
|
||||
$columns[$column->Field] = [
|
||||
'type' => static::__parse_column_type($column->Type),
|
||||
'nullable' => ($column->Null === 'YES'),
|
||||
'key' => $column->Key,
|
||||
'default' => $column->Default,
|
||||
'extra' => $column->Extra,
|
||||
];
|
||||
}
|
||||
|
||||
// Look up the file's metadata to get public_static_methods extracted during reflection
|
||||
$file_path = $model_entry['file'] ?? '';
|
||||
$public_static_methods = [];
|
||||
|
||||
// Find the public_static_methods data from the files array
|
||||
if ($file_path && isset($manifest_data['data']['files'][$file_path])) {
|
||||
$file_metadata = $manifest_data['data']['files'][$file_path];
|
||||
$public_static_methods = $file_metadata['public_static_methods'] ?? [];
|
||||
}
|
||||
|
||||
// Store model metadata with database info AND preserved public_static_methods
|
||||
$full_data = [
|
||||
'fqcn' => $fqcn,
|
||||
'file' => $file_path,
|
||||
'table' => $table_name,
|
||||
'columns' => $columns,
|
||||
'public_static_methods' => $public_static_methods, // Include the public static methods
|
||||
'class' => $class_name, // Ajax_Endpoint_Controller expects this
|
||||
];
|
||||
|
||||
$manifest_data['data']['models'][$class_name] = $full_data;
|
||||
|
||||
RsxCache::set_persistent($cachekey, $full_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MySQL column type to a simpler format
|
||||
*
|
||||
* CRITICAL: TINYINT(1) is treated as boolean (common MySQL boolean convention)
|
||||
* All other TINYINT sizes are treated as integers
|
||||
*
|
||||
* @param string $type MySQL column type (e.g., "varchar(255)", "tinyint(1)")
|
||||
* @return string Simplified type
|
||||
*/
|
||||
protected static function __parse_column_type(string $type): string
|
||||
{
|
||||
// Special case: tinyint(1) is boolean
|
||||
if (preg_match('/^tinyint\(1\)/i', $type)) {
|
||||
return 'boolean';
|
||||
}
|
||||
|
||||
// Extract base type without parameters
|
||||
if (preg_match('/^([a-z]+)/', $type, $matches)) {
|
||||
$base_type = $matches[1];
|
||||
|
||||
// Map MySQL types to simplified types
|
||||
$type_map = [
|
||||
'int' => 'integer',
|
||||
'bigint' => 'integer',
|
||||
'tinyint' => 'integer', // tinyint(1) handled above, others are integers
|
||||
'smallint' => 'integer',
|
||||
'mediumint' => 'integer',
|
||||
'varchar' => 'string',
|
||||
'char' => 'string',
|
||||
'text' => 'text',
|
||||
'mediumtext' => 'text',
|
||||
'longtext' => 'text',
|
||||
'datetime' => 'datetime',
|
||||
'timestamp' => 'datetime',
|
||||
'date' => 'date',
|
||||
'time' => 'time',
|
||||
'decimal' => 'decimal',
|
||||
'float' => 'float',
|
||||
'double' => 'double',
|
||||
'boolean' => 'boolean',
|
||||
'json' => 'json',
|
||||
'enum' => 'enum',
|
||||
];
|
||||
|
||||
return $type_map[$base_type] ?? $base_type;
|
||||
}
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Model Database Metadata';
|
||||
}
|
||||
}
|
||||
133
app/RSpade/Core/Task/Cleanup_Service.php
Executable file
133
app/RSpade/Core/Task/Cleanup_Service.php
Executable file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Task;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use App\RSpade\Core\Service\Rsx_Service_Abstract;
|
||||
use App\RSpade\Core\Task\Task_Instance;
|
||||
use App\RSpade\Core\Task\Task_Status;
|
||||
|
||||
/**
|
||||
* Cleanup Service
|
||||
*
|
||||
* System maintenance tasks that run on a schedule.
|
||||
* Handles cleanup of old data and temporary files.
|
||||
*/
|
||||
class Cleanup_Service extends Rsx_Service_Abstract
|
||||
{
|
||||
/**
|
||||
* Clean up old completed/failed tasks
|
||||
*
|
||||
* Deletes task records older than the retention period (default: 30 days)
|
||||
* Keeps pending/running tasks regardless of age.
|
||||
*
|
||||
* Runs daily at 3 AM
|
||||
*/
|
||||
#[Task_Attribute('Clean up old completed and failed task records')]
|
||||
#[Schedule('0 3 * * *')]
|
||||
public static function cleanup_old_tasks(Task_Instance $task, array $params = []): array
|
||||
{
|
||||
$retention_days = config('rsx.tasks.task_retention_days', 30);
|
||||
$cutoff_date = now()->subDays($retention_days);
|
||||
|
||||
$task->info("Cleaning up tasks older than {$retention_days} days (before {$cutoff_date})");
|
||||
|
||||
// Delete old completed tasks
|
||||
$deleted_completed = DB::table('_tasks')
|
||||
->where('status', Task_Status::COMPLETED)
|
||||
->where('completed_at', '<', $cutoff_date)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$deleted_completed} completed task(s)");
|
||||
|
||||
// Delete old failed tasks
|
||||
$deleted_failed = DB::table('_tasks')
|
||||
->where('status', Task_Status::FAILED)
|
||||
->where('completed_at', '<', $cutoff_date)
|
||||
->delete();
|
||||
|
||||
$task->info("Deleted {$deleted_failed} failed task(s)");
|
||||
|
||||
$total_deleted = $deleted_completed + $deleted_failed;
|
||||
|
||||
return [
|
||||
'deleted_completed' => $deleted_completed,
|
||||
'deleted_failed' => $deleted_failed,
|
||||
'total_deleted' => $total_deleted,
|
||||
'retention_days' => $retention_days,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned task temporary directories
|
||||
*
|
||||
* Removes temp directories for tasks that no longer exist or are completed.
|
||||
* Checks the temp_expires_at timestamp for cleanup eligibility.
|
||||
*
|
||||
* Runs every hour
|
||||
*/
|
||||
#[Task_Attribute('Clean up orphaned task temporary directories')]
|
||||
#[Schedule('0 * * * *')]
|
||||
public static function cleanup_temp_directories(Task_Instance $task, array $params = []): array
|
||||
{
|
||||
$base_temp_dir = storage_path('rsx-tmp/tasks');
|
||||
|
||||
if (!is_dir($base_temp_dir)) {
|
||||
$task->info("Temp directory does not exist: {$base_temp_dir}");
|
||||
return ['directories_removed' => 0];
|
||||
}
|
||||
|
||||
$task->info("Scanning temp directory: {$base_temp_dir}");
|
||||
|
||||
$directories_removed = 0;
|
||||
$directories = File::directories($base_temp_dir);
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
$dir_name = basename($dir);
|
||||
|
||||
// Check if it's an immediate task temp dir (these should be cleaned up by the task itself)
|
||||
if (str_starts_with($dir_name, 'immediate_')) {
|
||||
// Check if directory is old (more than 1 hour)
|
||||
$dir_time = filemtime($dir);
|
||||
if (time() - $dir_time > 3600) {
|
||||
$task->info("Removing old immediate temp directory: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract task ID from directory name (format: task_123)
|
||||
if (preg_match('/^task_(\d+)$/', $dir_name, $matches)) {
|
||||
$task_id = (int) $matches[1];
|
||||
|
||||
// Check if task still exists
|
||||
$task_row = DB::table('_tasks')->where('id', $task_id)->first();
|
||||
|
||||
if (!$task_row) {
|
||||
// Task doesn't exist, remove directory
|
||||
$task->info("Removing orphaned temp directory for deleted task: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if task is completed/failed
|
||||
if (in_array($task_row->status, [Task_Status::COMPLETED, Task_Status::FAILED])) {
|
||||
$task->info("Removing temp directory for completed/failed task: {$dir_name}");
|
||||
File::deleteDirectory($dir);
|
||||
$directories_removed++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$task->info("Removed {$directories_removed} temp director(ies)");
|
||||
|
||||
return [
|
||||
'directories_removed' => $directories_removed,
|
||||
'total_directories_scanned' => count($directories),
|
||||
];
|
||||
}
|
||||
}
|
||||
260
app/RSpade/Core/Testing/Rsx_Test_Abstract.php
Executable file
260
app/RSpade/Core/Testing/Rsx_Test_Abstract.php
Executable file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Testing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Rsx_Test_Abstract - Base class for RSX framework tests
|
||||
*
|
||||
* Provides simple test framework in the RSX spirit - no complex dependencies,
|
||||
* just straightforward testing with clear pass/fail results
|
||||
*/
|
||||
abstract class Rsx_Test_Abstract
|
||||
{
|
||||
/**
|
||||
* Test results storage
|
||||
*/
|
||||
protected static $results = [];
|
||||
protected static $current_test = null;
|
||||
|
||||
/**
|
||||
* Initialize the test class
|
||||
* Called before running tests
|
||||
*/
|
||||
public static function setup()
|
||||
{
|
||||
// Override in child classes for test setup
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up after tests
|
||||
* Called after all tests complete
|
||||
*/
|
||||
public static function teardown()
|
||||
{
|
||||
// Override in child classes for cleanup
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all test methods in this class
|
||||
*
|
||||
* @return array Test results
|
||||
*/
|
||||
public static function run()
|
||||
{
|
||||
static::$results = [];
|
||||
|
||||
// Call setup
|
||||
static::setup();
|
||||
|
||||
// Get all methods starting with 'test_'
|
||||
$reflection = new \ReflectionClass(static::class);
|
||||
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
|
||||
|
||||
foreach ($methods as $method) {
|
||||
if (strpos($method->getName(), 'test_') === 0) {
|
||||
static::$current_test = $method->getName();
|
||||
|
||||
try {
|
||||
// Run the test
|
||||
$method->invoke(null);
|
||||
|
||||
// If we got here without an exception, test passed
|
||||
if (!isset(static::$results[static::$current_test])) {
|
||||
static::$results[static::$current_test] = [
|
||||
'status' => 'passed',
|
||||
'message' => 'Test completed successfully'
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
static::$results[static::$current_test] = [
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call teardown
|
||||
static::teardown();
|
||||
|
||||
return static::$results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a condition is true
|
||||
*
|
||||
* @param bool $condition
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_true($condition, $message = 'Assertion failed')
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a condition is false
|
||||
*
|
||||
* @param bool $condition
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_false($condition, $message = 'Assertion failed')
|
||||
{
|
||||
static::__assert_true(!$condition, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that two values are equal
|
||||
*
|
||||
* @param mixed $expected
|
||||
* @param mixed $actual
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_equals($expected, $actual, $message = null)
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
$message = $message ?: "Expected '" . var_export($expected, true) .
|
||||
"' but got '" . var_export($actual, true) . "'";
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that two values are not equal
|
||||
*
|
||||
* @param mixed $expected
|
||||
* @param mixed $actual
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_not_equals($expected, $actual, $message = null)
|
||||
{
|
||||
if ($expected === $actual) {
|
||||
$message = $message ?: "Values should not be equal: '" . var_export($expected, true) . "'";
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a string contains a substring
|
||||
*
|
||||
* @param string $needle
|
||||
* @param string $haystack
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_contains($needle, $haystack, $message = null)
|
||||
{
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
$message = $message ?: "String does not contain '{$needle}'";
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that an array has a key
|
||||
*
|
||||
* @param string $key
|
||||
* @param array $array
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_array_has_key($key, $array, $message = null)
|
||||
{
|
||||
if (!array_key_exists($key, $array)) {
|
||||
$message = $message ?: "Array does not have key '{$key}'";
|
||||
throw new \Exception($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a value is null
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_null($value, $message = 'Value should be null')
|
||||
{
|
||||
static::__assert_true($value === null, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a value is not null
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __assert_not_null($value, $message = 'Value should not be null')
|
||||
{
|
||||
static::__assert_true($value !== null, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a test HTTP request
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $method
|
||||
* @param array $data
|
||||
* @return \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected static function __request($url, $method = 'GET', $data = [])
|
||||
{
|
||||
$full_url = 'http://localhost' . $url;
|
||||
|
||||
switch (strtoupper($method)) {
|
||||
case 'POST':
|
||||
return Http::post($full_url, $data);
|
||||
case 'GET':
|
||||
return Http::get($full_url, $data);
|
||||
default:
|
||||
throw new \Exception("Unsupported HTTP method: {$method}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current test as passed with optional message
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __pass($message = 'Test passed')
|
||||
{
|
||||
static::$results[static::$current_test] = [
|
||||
'status' => 'passed',
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current test as failed
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
protected static function __fail($message = 'Test failed')
|
||||
{
|
||||
throw new \Exception($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip current test
|
||||
*
|
||||
* @param string $reason
|
||||
*/
|
||||
protected static function __skip($reason = 'Test skipped')
|
||||
{
|
||||
static::$results[static::$current_test] = [
|
||||
'status' => 'skipped',
|
||||
'message' => $reason
|
||||
];
|
||||
|
||||
// Throw special exception to stop test execution but not mark as failed
|
||||
throw new \Exception('__SKIP__:' . $reason);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user