ruwi 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.cursor/rules/ruby_comments.mdc +29 -0
- data/.github/workflows/playwright.yml +74 -0
- data/.github/workflows/rspec.yml +31 -0
- data/.node-version +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/Makefile +56 -0
- data/README.md +237 -0
- data/Rakefile +4 -0
- data/docs/conditional-rendering.md +119 -0
- data/docs/lifecycle-hooks.md +75 -0
- data/docs/list-rendering.md +51 -0
- data/examples/.gitignore +4 -0
- data/examples/Gemfile +5 -0
- data/examples/Gemfile.lock +39 -0
- data/examples/Makefile +15 -0
- data/examples/npm-packages/runtime/counter/index.html +28 -0
- data/examples/npm-packages/runtime/counter/index.rb +62 -0
- data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
- data/examples/npm-packages/runtime/hello/index.html +28 -0
- data/examples/npm-packages/runtime/hello/index.rb +29 -0
- data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
- data/examples/npm-packages/runtime/input/index.html +28 -0
- data/examples/npm-packages/runtime/input/index.rb +46 -0
- data/examples/npm-packages/runtime/input/index.spec.js +58 -0
- data/examples/npm-packages/runtime/list/index.html +27 -0
- data/examples/npm-packages/runtime/list/index.rb +33 -0
- data/examples/npm-packages/runtime/list/index.spec.js +46 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
- data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
- data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
- data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
- data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
- data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
- data/examples/npm-packages/runtime/search_field/index.html +27 -0
- data/examples/npm-packages/runtime/search_field/index.rb +39 -0
- data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
- data/examples/npm-packages/runtime/todos/index.html +28 -0
- data/examples/npm-packages/runtime/todos/index.rb +239 -0
- data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
- data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
- data/examples/package.json +12 -0
- data/examples/src/counter/index.html +23 -0
- data/examples/src/counter/index.rb +60 -0
- data/examples/src/index.html +21 -0
- data/examples/src/index.rb +26 -0
- data/examples/src/todos/index.html +23 -0
- data/examples/src/todos/index.rb +237 -0
- data/examples/src/todos/todos_repository.rb +23 -0
- data/exe/ruwi +6 -0
- data/lib/ruwi/cli/command/base.rb +192 -0
- data/lib/ruwi/cli/command/dev.rb +207 -0
- data/lib/ruwi/cli/command/pack.rb +36 -0
- data/lib/ruwi/cli/command/rebuild.rb +38 -0
- data/lib/ruwi/cli/command/setup.rb +159 -0
- data/lib/ruwi/cli/command.rb +48 -0
- data/lib/ruwi/runtime/app.rb +53 -0
- data/lib/ruwi/runtime/component.rb +215 -0
- data/lib/ruwi/runtime/dispatcher.rb +46 -0
- data/lib/ruwi/runtime/dom/attributes.rb +105 -0
- data/lib/ruwi/runtime/dom/destroy_dom.rb +63 -0
- data/lib/ruwi/runtime/dom/events.rb +40 -0
- data/lib/ruwi/runtime/dom/mount_dom.rb +108 -0
- data/lib/ruwi/runtime/dom/patch_dom.rb +237 -0
- data/lib/ruwi/runtime/dom/scheduler.rb +51 -0
- data/lib/ruwi/runtime/dom.rb +13 -0
- data/lib/ruwi/runtime/nodes_equal.rb +45 -0
- data/lib/ruwi/runtime/template/build_conditional_group.rb +150 -0
- data/lib/ruwi/runtime/template/build_for_group.rb +125 -0
- data/lib/ruwi/runtime/template/build_vdom.rb +220 -0
- data/lib/ruwi/runtime/template/parser.rb +134 -0
- data/lib/ruwi/runtime/template.rb +11 -0
- data/lib/ruwi/runtime/utils/arrays.rb +185 -0
- data/lib/ruwi/runtime/utils/objects.rb +37 -0
- data/lib/ruwi/runtime/utils/props.rb +25 -0
- data/lib/ruwi/runtime/utils/strings.rb +19 -0
- data/lib/ruwi/runtime/utils.rb +11 -0
- data/lib/ruwi/runtime/vdom.rb +84 -0
- data/lib/ruwi/version.rb +5 -0
- data/lib/ruwi.rb +14 -0
- data/package-lock.json +73 -0
- data/package.json +32 -0
- data/packages/npm-packages/runtime/README.md +5 -0
- data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
- data/packages/npm-packages/runtime/package-lock.json +6668 -0
- data/packages/npm-packages/runtime/package.json +38 -0
- data/packages/npm-packages/runtime/rollup.config.mjs +147 -0
- data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
- data/packages/npm-packages/runtime/src/index.js +37 -0
- data/packages/npm-packages/runtime/src/ruwi +1 -0
- data/packages/npm-packages/runtime/src/ruwi.rb +1 -0
- data/packages/npm-packages/runtime/vitest.config.js +8 -0
- data/playwright.config.js +78 -0
- data/sig/ruwi.rbs +4 -0
- data/spec/ruwi/cli/command/base_spec.rb +503 -0
- data/spec/ruwi/cli/command/dev_spec.rb +442 -0
- data/spec/ruwi/cli/command/pack_spec.rb +131 -0
- data/spec/ruwi/cli/command/rebuild_spec.rb +95 -0
- data/spec/ruwi/cli/command/setup_spec.rb +251 -0
- data/spec/ruwi/cli/command_spec.rb +118 -0
- data/spec/ruwi/runtime/component_spec.rb +416 -0
- data/spec/ruwi/runtime/dom/scheduler_spec.rb +98 -0
- data/spec/ruwi/runtime/nodes_equal_spec.rb +190 -0
- data/spec/ruwi/runtime/template/build_conditional_group_spec.rb +505 -0
- data/spec/ruwi/runtime/template/build_for_group_spec.rb +377 -0
- data/spec/ruwi/runtime/template/build_vdom_spec.rb +573 -0
- data/spec/ruwi/runtime/template/parser_spec.rb +627 -0
- data/spec/ruwi/runtime/utils/arrays_spec.rb +228 -0
- data/spec/ruwi/runtime/utils/objects_spec.rb +127 -0
- data/spec/ruwi/runtime/utils/props_spec.rb +205 -0
- data/spec/ruwi/runtime/utils/strings_spec.rb +107 -0
- data/spec/spec_helper.rb +16 -0
- metadata +229 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "js"
|
|
2
|
+
|
|
3
|
+
random_cocktail = Ruwi.define_component(
|
|
4
|
+
state: ->() {
|
|
5
|
+
{
|
|
6
|
+
is_loading: false,
|
|
7
|
+
cocktail: nil
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
template: ->() {
|
|
12
|
+
is_loading = state[:is_loading] # Used in template
|
|
13
|
+
cocktail = state[:cocktail] # Used in template
|
|
14
|
+
|
|
15
|
+
template = <<~HTML
|
|
16
|
+
<p r-if="{is_loading}">
|
|
17
|
+
Loading...
|
|
18
|
+
</p>
|
|
19
|
+
<button-component r-elsif="{cocktail.nil?}" on="{click_button: ->() { fetch_cocktail }}" label="Get a cocktail"/>
|
|
20
|
+
<template r-else>
|
|
21
|
+
<h1>{cocktail['strDrink']}</h1>
|
|
22
|
+
<p>{cocktail['strInstructions']}</p>
|
|
23
|
+
<img src="{cocktail['strDrinkThumb']}" alt="{cocktail['strDrink']}" style="width: 300px; height: 300px" />
|
|
24
|
+
<button-component on="{click_button: ->() { fetch_cocktail }}" label="Get another cocktail"/>
|
|
25
|
+
</template>
|
|
26
|
+
HTML
|
|
27
|
+
|
|
28
|
+
Ruwi::Template::Parser.parse_and_eval(template, binding)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
methods: {
|
|
32
|
+
fetch_cocktail: ->{
|
|
33
|
+
# Set loading state
|
|
34
|
+
update_state(is_loading: true, cocktail: nil)
|
|
35
|
+
|
|
36
|
+
Fiber.new do
|
|
37
|
+
response = JS.global.fetch("https://www.thecocktaildb.com/api/json/v1/1/random.php").await
|
|
38
|
+
response.call(:json).then(->(data) {
|
|
39
|
+
update_state(is_loading: false, cocktail: data[:drinks][0])
|
|
40
|
+
}).catch(->(error) {
|
|
41
|
+
update_state(is_loading: false, cocktail: nil)
|
|
42
|
+
})
|
|
43
|
+
end.transfer
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
on_mounted: ->() {
|
|
48
|
+
fetch_cocktail
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ButtonComponent = Ruwi.define_component(
|
|
53
|
+
state: ->(props) {
|
|
54
|
+
{ label: props[:label] }
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
template: ->() {
|
|
58
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
59
|
+
<button on="{click: ->() { emit('click_button') }}" style="display: block; margin: 1em auto">
|
|
60
|
+
{state[:label]}
|
|
61
|
+
</button>
|
|
62
|
+
HTML
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Create and mount the app
|
|
67
|
+
app = Ruwi::App.create(random_cocktail)
|
|
68
|
+
app_element = JS.global[:document].getElementById("app")
|
|
69
|
+
app.mount(app_element)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test.describe("Random Cocktail Example", () => {
|
|
4
|
+
test("should display loading state and fetch initial cocktail data", async ({
|
|
5
|
+
page,
|
|
6
|
+
}) => {
|
|
7
|
+
await page.goto("/examples/npm-packages/runtime/random_cocktail/index.html?env=DEV");
|
|
8
|
+
await page.waitForTimeout(1000); // Shorter wait to catch loading state
|
|
9
|
+
|
|
10
|
+
// Check the page title
|
|
11
|
+
await expect(page).toHaveTitle("Random Cocktail");
|
|
12
|
+
|
|
13
|
+
// Should initially show loading state or quickly transition to cocktail data
|
|
14
|
+
// const hasLoadingOrCocktail = await Promise.race([
|
|
15
|
+
// page
|
|
16
|
+
// .getByText("Loading...")
|
|
17
|
+
// .isVisible()
|
|
18
|
+
// .catch(() => false),
|
|
19
|
+
// page
|
|
20
|
+
// .locator("#app h1")
|
|
21
|
+
// .isVisible()
|
|
22
|
+
// .catch(() => false),
|
|
23
|
+
// ]);
|
|
24
|
+
// expect(hasLoadingOrCocktail).toBe(true);
|
|
25
|
+
|
|
26
|
+
// Wait for the cocktail to load (API call)
|
|
27
|
+
await page.waitForTimeout(5000);
|
|
28
|
+
|
|
29
|
+
// After loading, should show cocktail information
|
|
30
|
+
await expect(page.locator("#app h1")).toBeVisible();
|
|
31
|
+
await expect(page.locator("#app p").last()).toBeVisible();
|
|
32
|
+
await expect(page.locator("img")).toBeVisible();
|
|
33
|
+
await expect(page.locator("button")).toHaveText("Get another cocktail");
|
|
34
|
+
|
|
35
|
+
// Verify cocktail data is displayed correctly
|
|
36
|
+
const cocktailName = await page.locator("#app h1").textContent();
|
|
37
|
+
expect(cocktailName).toBeTruthy();
|
|
38
|
+
expect(cocktailName.length).toBeGreaterThan(0);
|
|
39
|
+
|
|
40
|
+
const instructions = await page.locator("#app p").last().textContent();
|
|
41
|
+
expect(instructions).toBeTruthy();
|
|
42
|
+
expect(instructions.length).toBeGreaterThan(0);
|
|
43
|
+
|
|
44
|
+
// Verify image attributes and styling
|
|
45
|
+
const img = page.locator("img");
|
|
46
|
+
await expect(img).toHaveAttribute("alt");
|
|
47
|
+
await expect(img).toHaveAttribute("src");
|
|
48
|
+
await expect(img).toHaveCSS("width", "300px");
|
|
49
|
+
await expect(img).toHaveCSS("height", "300px");
|
|
50
|
+
|
|
51
|
+
const imgSrc = await img.getAttribute("src");
|
|
52
|
+
const imgAlt = await img.getAttribute("alt");
|
|
53
|
+
expect(imgSrc).toBeTruthy();
|
|
54
|
+
expect(imgAlt).toBeTruthy();
|
|
55
|
+
expect(imgSrc).toMatch(/^https?:\/\//); // Should be a valid URL
|
|
56
|
+
|
|
57
|
+
// Verify button styling and behavior
|
|
58
|
+
const button = page.locator("button");
|
|
59
|
+
await expect(button).toBeVisible();
|
|
60
|
+
await expect(button).toBeEnabled();
|
|
61
|
+
await expect(button).toHaveCSS("display", "block");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("should fetch new cocktail when button is clicked", async ({ page }) => {
|
|
65
|
+
await page.goto("/examples/npm-packages/runtime/random_cocktail/index.html?env=DEV");
|
|
66
|
+
await page.waitForTimeout(3000);
|
|
67
|
+
|
|
68
|
+
// Wait for initial cocktail to load
|
|
69
|
+
await page.waitForTimeout(5000);
|
|
70
|
+
|
|
71
|
+
// Get the initial cocktail name
|
|
72
|
+
const initialCocktailName = await page.locator("#app h1").textContent();
|
|
73
|
+
|
|
74
|
+
// Click "Get another cocktail" button
|
|
75
|
+
const button = page.locator("button");
|
|
76
|
+
await button.click();
|
|
77
|
+
|
|
78
|
+
// Should show loading state
|
|
79
|
+
await expect(page.getByText("Loading...")).toBeVisible();
|
|
80
|
+
|
|
81
|
+
// Wait for new cocktail to load
|
|
82
|
+
await page.waitForTimeout(5000);
|
|
83
|
+
|
|
84
|
+
// Should display new cocktail data with correct structure
|
|
85
|
+
await expect(page.locator("#app h1")).toBeVisible();
|
|
86
|
+
await expect(page.locator("#app p").last()).toBeVisible();
|
|
87
|
+
await expect(page.locator("img")).toBeVisible();
|
|
88
|
+
await expect(button).toHaveText("Get another cocktail");
|
|
89
|
+
|
|
90
|
+
// Verify we have valid cocktail data
|
|
91
|
+
const newCocktailName = await page.locator("#app h1").textContent();
|
|
92
|
+
expect(newCocktailName).toBeTruthy();
|
|
93
|
+
expect(newCocktailName.length).toBeGreaterThan(0);
|
|
94
|
+
|
|
95
|
+
// Verify the component maintains proper state
|
|
96
|
+
const hasLoading = (await page.getByText("Loading...").count()) > 0;
|
|
97
|
+
const hasCocktail = (await page.locator("#app h1").count()) > 0;
|
|
98
|
+
const hasButton = (await button.count()) > 0;
|
|
99
|
+
expect(hasLoading || hasCocktail || hasButton).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
|
|
8
|
+
<script>
|
|
9
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
10
|
+
const isDev = urlParams.get("env") === "DEV";
|
|
11
|
+
const script = document.createElement("script");
|
|
12
|
+
script.defer = true;
|
|
13
|
+
script.src = isDev
|
|
14
|
+
? "../../../../packages/npm-packages/runtime/dist/ruwi.js"
|
|
15
|
+
: "https://unpkg.com/ruwi@latest";
|
|
16
|
+
document.head.appendChild(script);
|
|
17
|
+
</script>
|
|
18
|
+
<script defer type="text/ruby" src="index.rb"></script>
|
|
19
|
+
|
|
20
|
+
<title>Search Field</title>
|
|
21
|
+
</head>
|
|
22
|
+
|
|
23
|
+
<body>
|
|
24
|
+
<h1>Search Field</h1>
|
|
25
|
+
<div id="app"></div>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "js"
|
|
2
|
+
|
|
3
|
+
# search-field component definition
|
|
4
|
+
SearchField = Ruwi.define_component(
|
|
5
|
+
template: ->() {
|
|
6
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
7
|
+
<input
|
|
8
|
+
type="text"
|
|
9
|
+
placeholder="Search..."
|
|
10
|
+
value="{props[:value]}"
|
|
11
|
+
on="{ input: ->(event) { emit('search', event[:target][:value].to_s) } }"
|
|
12
|
+
/>
|
|
13
|
+
HTML
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# search-demo component to show the search functionality
|
|
18
|
+
SearchDemo = Ruwi.define_component(
|
|
19
|
+
state: ->() {
|
|
20
|
+
{ search_term: '' }
|
|
21
|
+
},
|
|
22
|
+
template: ->() {
|
|
23
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
24
|
+
<template>
|
|
25
|
+
<h2>Search Demo</h2>
|
|
26
|
+
<search-field
|
|
27
|
+
value="{state[:search_term]}"
|
|
28
|
+
on="{ search: ->(search_term) { update_state({ search_term: search_term }) } }"
|
|
29
|
+
/>
|
|
30
|
+
<p>Current search term: {state[:search_term]}</p>
|
|
31
|
+
</template>
|
|
32
|
+
HTML
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Create and mount the app
|
|
37
|
+
app = Ruwi::App.create(SearchDemo)
|
|
38
|
+
app_element = JS.global[:document].getElementById("app")
|
|
39
|
+
app.mount(app_element)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test.describe("Search Field Example", () => {
|
|
4
|
+
test("should display search demo and handle various input scenarios", async ({
|
|
5
|
+
page,
|
|
6
|
+
}) => {
|
|
7
|
+
await page.goto("/examples/npm-packages/runtime/search_field/index.html?env=DEV");
|
|
8
|
+
await page.waitForTimeout(3000);
|
|
9
|
+
|
|
10
|
+
// Check the page title
|
|
11
|
+
await expect(page).toHaveTitle("Search Field");
|
|
12
|
+
|
|
13
|
+
// Check search demo heading
|
|
14
|
+
await expect(page.locator("h2")).toHaveText("Search Demo");
|
|
15
|
+
|
|
16
|
+
// Check search input field is present
|
|
17
|
+
const searchInput = page.locator('input[type="text"]');
|
|
18
|
+
const searchTermDisplay = page.locator("p");
|
|
19
|
+
await expect(searchInput).toBeVisible();
|
|
20
|
+
await expect(searchInput).toHaveAttribute("placeholder", "Search...");
|
|
21
|
+
|
|
22
|
+
// Check initial search term display
|
|
23
|
+
await expect(searchTermDisplay).toHaveText("Current search term: ");
|
|
24
|
+
|
|
25
|
+
// Test basic search functionality
|
|
26
|
+
await searchInput.fill("test");
|
|
27
|
+
await page.waitForTimeout(100);
|
|
28
|
+
await expect(searchTermDisplay).toHaveText("Current search term: test");
|
|
29
|
+
await expect(searchInput).toHaveValue("test");
|
|
30
|
+
|
|
31
|
+
// Test multiple search term updates
|
|
32
|
+
const searchTerms = ["hello", "world", "ruby", "wasm", ""];
|
|
33
|
+
for (const term of searchTerms) {
|
|
34
|
+
await searchInput.fill(term);
|
|
35
|
+
await page.waitForTimeout(100);
|
|
36
|
+
await expect(searchTermDisplay).toHaveText(
|
|
37
|
+
`Current search term: ${term}`
|
|
38
|
+
);
|
|
39
|
+
await expect(searchInput).toHaveValue(term);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Test special characters in search input
|
|
43
|
+
const specialSearchTerms = [
|
|
44
|
+
"test@example.com",
|
|
45
|
+
"123-456-789",
|
|
46
|
+
"hello world!",
|
|
47
|
+
"search with spaces",
|
|
48
|
+
"special!@#$%^&*()",
|
|
49
|
+
];
|
|
50
|
+
for (const term of specialSearchTerms) {
|
|
51
|
+
await searchInput.fill(term);
|
|
52
|
+
await page.waitForTimeout(100);
|
|
53
|
+
await expect(searchTermDisplay).toHaveText(
|
|
54
|
+
`Current search term: ${term}`
|
|
55
|
+
);
|
|
56
|
+
await expect(searchInput).toHaveValue(term);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
|
|
8
|
+
<script>
|
|
9
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
10
|
+
const isDev = urlParams.get("env") === "DEV";
|
|
11
|
+
const script = document.createElement("script");
|
|
12
|
+
script.defer = true;
|
|
13
|
+
script.src = isDev
|
|
14
|
+
? "../../../../packages/npm-packages/runtime/dist/ruwi.js"
|
|
15
|
+
: "https://unpkg.com/ruwi@latest";
|
|
16
|
+
document.head.appendChild(script);
|
|
17
|
+
</script>
|
|
18
|
+
<script defer type="text/ruby" src="todos_repository.rb"></script>
|
|
19
|
+
<script defer type="text/ruby" src="index.rb"></script>
|
|
20
|
+
|
|
21
|
+
<title>Todos</title>
|
|
22
|
+
</head>
|
|
23
|
+
|
|
24
|
+
<body>
|
|
25
|
+
<h1>Todos</h1>
|
|
26
|
+
<div id="app"></div>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
require "js"
|
|
2
|
+
|
|
3
|
+
# Main App component - coordinates all other components
|
|
4
|
+
AppComponent = Ruwi.define_component(
|
|
5
|
+
# Initialize application state
|
|
6
|
+
state: ->(props) {
|
|
7
|
+
{
|
|
8
|
+
todos: [
|
|
9
|
+
{ id: rand(10000), text: "Walk the dog" },
|
|
10
|
+
{ id: rand(10000), text: "Water the plants" },
|
|
11
|
+
{ id: rand(10000), text: "Sand the chairs" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
on_mounted: -> {
|
|
17
|
+
update_state(todos: TodosRepository.read_todos)
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
# Render the complete application
|
|
21
|
+
template: ->() {
|
|
22
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
23
|
+
<template>
|
|
24
|
+
<h1>My TODOs</h1>
|
|
25
|
+
<CreateTodoComponent
|
|
26
|
+
on="{ add: ->(text) { add_todo(text) } }"
|
|
27
|
+
/>
|
|
28
|
+
<TodoListComponent
|
|
29
|
+
todos="{state[:todos]}"
|
|
30
|
+
on="{
|
|
31
|
+
remove: ->(id) { remove_todo(id) },
|
|
32
|
+
edit: ->(payload) { edit_todo(payload) }
|
|
33
|
+
}"
|
|
34
|
+
/>
|
|
35
|
+
</template>
|
|
36
|
+
HTML
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
# Component methods
|
|
40
|
+
methods: {
|
|
41
|
+
# Add a new TODO to the list
|
|
42
|
+
# @param text [String] The TODO text
|
|
43
|
+
add_todo: ->(text) {
|
|
44
|
+
todo = { id: rand(10000), text: text }
|
|
45
|
+
new_todos = state[:todos] + [todo]
|
|
46
|
+
update_state(todos: new_todos)
|
|
47
|
+
TodosRepository.write_todos(new_todos)
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
# Remove a TODO from the list
|
|
51
|
+
# @param id [Integer] Id of TODO to remove
|
|
52
|
+
remove_todo: ->(id) {
|
|
53
|
+
new_todos = state[:todos].dup
|
|
54
|
+
new_todos.delete_at(new_todos.index { |todo| todo[:id] == id })
|
|
55
|
+
update_state(todos: new_todos)
|
|
56
|
+
TodosRepository.write_todos(new_todos)
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
# Edit an existing TODO
|
|
60
|
+
# @param payload [Hash] Contains edited text and index
|
|
61
|
+
edit_todo: ->(payload) {
|
|
62
|
+
edited = payload[:edited]
|
|
63
|
+
id = payload[:id]
|
|
64
|
+
new_todos = state[:todos].dup
|
|
65
|
+
new_todos[new_todos.index { |todo| todo[:id] == id }] = new_todos[new_todos.index { |todo| todo[:id] == id }].merge(text: edited)
|
|
66
|
+
update_state(todos: new_todos)
|
|
67
|
+
TodosRepository.write_todos(new_todos)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# CreateTodo component - handles new TODO input
|
|
73
|
+
CreateTodoComponent = Ruwi.define_component(
|
|
74
|
+
# Initialize component state
|
|
75
|
+
state: ->(props) {
|
|
76
|
+
{ text: "" }
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
# Render the new TODO input form
|
|
80
|
+
template: ->() {
|
|
81
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
82
|
+
<div>
|
|
83
|
+
<label for="todo-input" type="text">New TODO</label>
|
|
84
|
+
<input
|
|
85
|
+
type="text"
|
|
86
|
+
id="todo-input"
|
|
87
|
+
value="{state[:text]}"
|
|
88
|
+
on="{
|
|
89
|
+
input: ->(e) { update_state(text: e[:target][:value]) },
|
|
90
|
+
keydown: ->(e) {
|
|
91
|
+
if e[:key] == 'Enter' && state[:text].to_s.length >= 3
|
|
92
|
+
add_todo
|
|
93
|
+
end
|
|
94
|
+
}
|
|
95
|
+
}"
|
|
96
|
+
/>
|
|
97
|
+
<button disabled="{state[:text].to_s.length < 3}" on="{ click: ->() { add_todo } }">
|
|
98
|
+
Add
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
HTML
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
# Component methods
|
|
105
|
+
methods: {
|
|
106
|
+
# Add a new TODO and emit event to parent
|
|
107
|
+
add_todo: ->() {
|
|
108
|
+
emit("add", state[:text])
|
|
109
|
+
update_state(text: "")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# TodoList component - manages the list of TODO items
|
|
115
|
+
TodoListComponent = Ruwi.define_component(
|
|
116
|
+
# Render the TODO list
|
|
117
|
+
template: ->() {
|
|
118
|
+
todos = props[:todos]
|
|
119
|
+
|
|
120
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
121
|
+
<ul>
|
|
122
|
+
<TodoItemComponent
|
|
123
|
+
r-for="{todo in todos}"
|
|
124
|
+
key="{todo[:id]}"
|
|
125
|
+
todo="{todo[:text]}"
|
|
126
|
+
id="{todo[:id]}"
|
|
127
|
+
on="{
|
|
128
|
+
remove: ->(id) { emit('remove', id) },
|
|
129
|
+
edit: ->(payload) { emit('edit', payload) }
|
|
130
|
+
}"
|
|
131
|
+
/>
|
|
132
|
+
</ul>
|
|
133
|
+
HTML
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# TodoItem component - handles individual TODO items
|
|
138
|
+
TodoItemComponent = Ruwi.define_component(
|
|
139
|
+
# Initialize component state with editing capabilities
|
|
140
|
+
state: ->(props) {
|
|
141
|
+
{
|
|
142
|
+
original: props[:todo],
|
|
143
|
+
edited: props[:todo],
|
|
144
|
+
is_editing: false
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
# Render TODO item using r-if and r-else for conditional rendering
|
|
149
|
+
template: ->() {
|
|
150
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
151
|
+
<template>
|
|
152
|
+
<TodoItemEditComponent
|
|
153
|
+
r-if="{state[:is_editing]}"
|
|
154
|
+
edited="{state[:edited]}"
|
|
155
|
+
on="{
|
|
156
|
+
input: ->(value) { input_value(value) },
|
|
157
|
+
save: ->() { save_edition },
|
|
158
|
+
cancel: ->() { cancel_edition }
|
|
159
|
+
}"
|
|
160
|
+
/>
|
|
161
|
+
<TodoItemViewComponent
|
|
162
|
+
r-else
|
|
163
|
+
original="{state[:original]}"
|
|
164
|
+
id="{props[:id]}"
|
|
165
|
+
on="{
|
|
166
|
+
editing: ->() { editing },
|
|
167
|
+
remove: ->(id) { emit('remove', id) }
|
|
168
|
+
}"
|
|
169
|
+
/>
|
|
170
|
+
</template>
|
|
171
|
+
HTML
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
# Component methods
|
|
175
|
+
methods: {
|
|
176
|
+
input_value: ->(value) {
|
|
177
|
+
update_state(edited: value)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
editing: ->() {
|
|
181
|
+
update_state(is_editing: true)
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
# Save the edited TODO
|
|
185
|
+
save_edition: ->() {
|
|
186
|
+
update_state(is_editing: false, original: state[:edited])
|
|
187
|
+
emit("edit", { edited: state[:edited], id: props[:id] })
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
# Cancel editing and revert changes
|
|
191
|
+
cancel_edition: ->() {
|
|
192
|
+
update_state(edited: state[:original], is_editing: false)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# TodoItemEdit component - handles TODO editing mode
|
|
198
|
+
TodoItemEditComponent = Ruwi.define_component(
|
|
199
|
+
# Render TODO item in edit mode
|
|
200
|
+
template: ->() {
|
|
201
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
202
|
+
<li>
|
|
203
|
+
<input
|
|
204
|
+
value="{props[:edited]}"
|
|
205
|
+
type="text"
|
|
206
|
+
on="{ input: ->(e) { emit('input', e[:target][:value]) } }"
|
|
207
|
+
/>
|
|
208
|
+
<button on="{ click: ->() { emit('save') } }">
|
|
209
|
+
Save
|
|
210
|
+
</button>
|
|
211
|
+
<button on="{ click: ->() { emit('cancel') } }">
|
|
212
|
+
Cancel
|
|
213
|
+
</button>
|
|
214
|
+
</li>
|
|
215
|
+
HTML
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# TodoItemView component - handles TODO display mode
|
|
220
|
+
TodoItemViewComponent = Ruwi.define_component(
|
|
221
|
+
# Render TODO item in view mode
|
|
222
|
+
template: ->() {
|
|
223
|
+
Ruwi::Template::Parser.parse_and_eval(<<~HTML, binding)
|
|
224
|
+
<li>
|
|
225
|
+
<span on="{ dblclick: ->() { emit('editing') } }">
|
|
226
|
+
{props[:original]}
|
|
227
|
+
</span>
|
|
228
|
+
<button on="{ click: ->() { emit('remove', props[:id]) } }">
|
|
229
|
+
Done
|
|
230
|
+
</button>
|
|
231
|
+
</li>
|
|
232
|
+
HTML
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Create and mount the application
|
|
237
|
+
app = Ruwi::App.create(AppComponent)
|
|
238
|
+
app_element = JS.global[:document].getElementById("app")
|
|
239
|
+
app.mount(app_element)
|