Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ jobs:
otp-version: "28"
gleam-version: "1.14.0"
rebar3-version: "3"
- name: Cache Gleam dependency artifacts
uses: actions/cache@v4
with:
path: |
~/.cache/gleam
weft_lustre_ui/build/packages
key: ${{ runner.os }}-gleam-deps-${{ hashFiles('weft_lustre_ui/gleam.toml', 'weft_lustre_ui/manifest.toml') }}
restore-keys: |
${{ runner.os }}-gleam-deps-
- run: sudo apt-get update && sudo apt-get install -y imagemagick
- id: hex_deps
name: Check Hex dependency semver availability
Expand Down Expand Up @@ -61,7 +70,7 @@ jobs:
bash scripts/ci/run_full_checks_with_local_overrides.sh
working-directory: weft_lustre_ui
- if: steps.hex_deps.outputs.deps_ready == 'true'
run: gleam deps download
run: bash scripts/ci/retry.sh 12 10 gleam deps download
working-directory: weft_lustre_ui
- if: steps.hex_deps.outputs.deps_ready == 'true'
run: |
Expand Down
1 change: 1 addition & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

bash scripts/grep-gates.sh
bash scripts/ci/retry.sh 12 10 gleam deps download
gleam format --check src test
gleam build --target erlang --warnings-as-errors
gleam build --target javascript --warnings-as-errors
Expand Down
38 changes: 38 additions & 0 deletions scripts/ci/retry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash

set -euo pipefail

if [ "$#" -lt 3 ]; then
echo "usage: retry.sh <attempts> <sleep_seconds> <command...>" >&2
exit 2
fi

attempts="$1"
shift
sleep_seconds="$1"
shift

if ! [[ "$attempts" =~ ^[0-9]+$ ]] || [ "$attempts" -lt 1 ]; then
echo "attempts must be a positive integer" >&2
exit 2
fi

if ! [[ "$sleep_seconds" =~ ^[0-9]+$ ]] || [ "$sleep_seconds" -lt 0 ]; then
echo "sleep_seconds must be a non-negative integer" >&2
exit 2
fi

command=("$@")

for attempt in $(seq 1 "$attempts"); do
echo "Attempt ${attempt}/${attempts}: ${command[*]}" >&2
if "${command[@]}"; then
exit 0
fi

if [ "$attempt" -lt "$attempts" ]; then
sleep "$sleep_seconds"
fi
done

exit 1
52 changes: 42 additions & 10 deletions src/weft_lustre_ui/headless/input_otp.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,6 @@ fn normalized_chars(value: String, length: Int) -> List(String) {
}
}

fn first_grapheme_or_empty(value: String) -> String {
case string.to_graphemes(value) {
[head, ..] -> head
[] -> ""
}
}

fn replace_slot(
chars chars: List(String),
index index: Int,
Expand All @@ -138,6 +131,47 @@ fn replace_slot(
|> string.join(with: "")
}

fn replace_slots_from_index(
chars chars: List(String),
index index: Int,
values values: List(String),
) -> String {
chars
|> list.index_map(with: fn(char, i) {
let offset = i - index
case offset < 0 {
True -> char
False -> {
case list.at(values, offset) {
Ok(value) -> value
Error(Nil) -> char
Comment on lines +145 to +147
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does the official Gleam standard library documentation for gleam/listprovide a function namedat in Gleam 1.14.x? If not, what supported pattern should be used to read the nth element of a list?

💡 Result:

No. In the official gleam_stdlib docs for gleam/list (the standard library used with modern Gleam releases, including 1.14.x), there is no list.at function; instead the module provides list.drop and list.first. [1][2]

Supported pattern to read the nth element (0-based):

import gleam/list

pub fn nth(list: List(a), n: Int) -> Result(a, Nil) {
  list
  |> list.drop(up_to: n)
  |> list.first
}
  • drop(up_to: n) removes the first n elements. [2]
  • first returns the first element (or Error(Nil) if empty). [1]

🏁 Script executed:

wc -l src/weft_lustre_ui/headless/input_otp.gleam

Repository: bbopen/weft_lustre_ui

Length of output: 112


🏁 Script executed:

sed -n '140,150p' src/weft_lustre_ui/headless/input_otp.gleam

Repository: bbopen/weft_lustre_ui

Length of output: 310


list.at is not a valid gleam/list API.

Line 145 uses list.at(values, offset), which does not exist in the standard library and causes a compile failure. The OTP paste feature cannot build with this code. Switch to a supported list access pattern.

Suggested fix
-        case list.at(values, offset) {
-          Ok(value) -> value
-          Error(Nil) -> char
-        }
+        case values |> list.drop(up_to: offset) |> list.first {
+          Ok(value) -> value
+          Error(Nil) -> char
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case list.at(values, offset) {
Ok(value) -> value
Error(Nil) -> char
case values |> list.drop(up_to: offset) |> list.first {
Ok(value) -> value
Error(Nil) -> char
}
🧰 Tools
🪛 GitHub Actions: test

[error] 145-145: Gleam compile error: Unknown module value. The expression 'list.at(values, offset)' is invalid because the module 'gleam/list' does not have an 'at' function. Did you mean 'last'?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/weft_lustre_ui/headless/input_otp.gleam` around lines 145 - 147, The code
calls the non-existent list.at(values, offset); replace that with a supported
pattern using list.drop and pattern matching: drop the first offset elements
from values (list.drop(values, offset)) and then case-match on the result ([] ->
char; [value | _] -> value). Update the branch that references list.at to use
list.drop(values, offset) and the pattern match so values, offset and char are
returned correctly when present or fall back to char when empty.

}
}
}
})
|> string.join(with: "")
}

/// Internal helper used by slot-level `on_input` handlers.
///
/// Single-character updates replace only one slot. Multi-character updates
/// (such as paste events) fan out from the current index across subsequent
/// slots.
@internal
pub fn input_otp_apply_slot_input(
chars chars: List(String),
index index: Int,
input input: String,
) -> String {
let next_chars = string.to_graphemes(input)

case next_chars {
[] -> replace_slot(chars, index, "")
[single] -> replace_slot(chars, index, single)
many -> replace_slots_from_index(chars, index, many)
}
}

/// Render an input-otp root.
pub fn input_otp(config config: InputOtpConfig(msg)) -> weft_lustre.Element(msg) {
case config {
Expand All @@ -159,8 +193,7 @@ pub fn input_otp(config config: InputOtpConfig(msg)) -> weft_lustre.Element(msg)
index: index,
value: char,
on_input: fn(input) {
let next = first_grapheme_or_empty(input)
on_change(replace_slot(chars, index, next))
on_change(input_otp_apply_slot_input(chars, index, input))
},
disabled: disabled,
attrs: slot_attrs,
Expand Down Expand Up @@ -228,7 +261,6 @@ pub fn input_otp_slot(
list.flatten([
[weft_lustre.html_attribute(attribute.type_("text"))],
[weft_lustre.html_attribute(attribute.inputmode("numeric"))],
[weft_lustre.html_attribute(attribute.attribute("maxlength", "1"))],
[
weft_lustre.html_attribute(attribute.attribute(
"data-slot",
Expand Down
14 changes: 14 additions & 0 deletions test/input_otp_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ pub fn input_otp_tests() {
string.contains(rendered, "data-index=\"3\"")
|> expect.to_equal(expected: True)
}),
it("fans pasted values across subsequent slots", fn() {
let next =
headless_input_otp.input_otp_apply_slot_input(
chars: ["0", "", "", ""],
index: 1,
input: "789",
)

next
|> expect.to_equal(expected: "0789")
}),
]),
describe("headless rendering", [
it("renders group/slot/separator slot markers", fn() {
Expand All @@ -55,6 +66,9 @@ pub fn input_otp_tests() {
string.contains(rendered, "data-slot=\"input-otp-slot\"")
|> expect.to_equal(expected: True)

string.contains(rendered, "maxlength=\"1\"")
|> expect.to_equal(expected: False)

string.contains(rendered, "data-slot=\"input-otp-separator\"")
|> expect.to_equal(expected: True)
}),
Expand Down
Loading