Compare commits
2 Commits
a72f951033
...
2af2ff32c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2af2ff32c1 | |||
| 2ed356fbcd |
3
.gitignore
vendored
3
.gitignore
vendored
@ -12,3 +12,6 @@ apps/docs/.docusaurus/
|
||||
apps/docs/build/
|
||||
.idea
|
||||
.playwright-mcp
|
||||
.opencode
|
||||
dev.db
|
||||
graphify-out/cache/
|
||||
|
||||
139
.husky/post-checkout
Executable file
139
.husky/post-checkout
Executable file
@ -0,0 +1,139 @@
|
||||
#!/bin/sh
|
||||
# graphify-checkout-hook-start
|
||||
# Auto-rebuilds the knowledge graph (code only) when switching branches.
|
||||
# Installed by: graphify hook install
|
||||
|
||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
PREV_HEAD=$1
|
||||
NEW_HEAD=$2
|
||||
BRANCH_SWITCH=$3
|
||||
|
||||
# Only run on branch switches, not file checkouts
|
||||
if [ "$BRANCH_SWITCH" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run if graphify-out/ exists (graph has been built before)
|
||||
if [ ! -d "graphify-out" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip during rebase/merge/cherry-pick
|
||||
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
||||
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
||||
GRAPHIFY_PYTHON=""
|
||||
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
|
||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_PINNED"
|
||||
fi
|
||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
case "$_FROM_FILE" in
|
||||
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
||||
esac
|
||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
case "$GRAPHIFY_BIN" in
|
||||
*.exe) _SHEBANG="" ;;
|
||||
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
|
||||
esac
|
||||
case "$_SHEBANG" in
|
||||
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
||||
# injection if the shebang contains shell metacharacters.
|
||||
case "$GRAPHIFY_PYTHON" in
|
||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
||||
esac
|
||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python"
|
||||
else
|
||||
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
||||
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
|
||||
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
|
||||
_src = '''
|
||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
||||
from pathlib import Path
|
||||
import os, signal, sys
|
||||
try:
|
||||
_apply_resource_limits()
|
||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
||||
signal.alarm(_timeout)
|
||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
||||
# post-checkout: branch switch can touch arbitrary files; full rebuild path
|
||||
# (no changed_paths) is correct here. The flock inside _rebuild_code still
|
||||
# prevents pile-ups when commit + checkout fire back-to-back.
|
||||
_root = Path('.')
|
||||
_saved = Path('graphify-out/.graphify_root')
|
||||
if _saved.exists():
|
||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
||||
if _txt:
|
||||
_root = Path(_txt)
|
||||
_rebuild_code(_root, force=_force)
|
||||
except TimeoutError as exc:
|
||||
print(f'[graphify] {exc}')
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f'[graphify] Rebuild failed: {exc}')
|
||||
sys.exit(1)
|
||||
|
||||
'''
|
||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
||||
except OSError:
|
||||
_out = subprocess.DEVNULL
|
||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
||||
_cmd = [sys.executable, '-c', _src]
|
||||
if os.name == 'nt':
|
||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
||||
except OSError:
|
||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
||||
else:
|
||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
||||
"
|
||||
# graphify-checkout-hook-end
|
||||
150
.husky/post-commit
Executable file
150
.husky/post-commit
Executable file
@ -0,0 +1,150 @@
|
||||
#!/bin/sh
|
||||
# graphify-hook-start
|
||||
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
|
||||
# Installed by: graphify hook install
|
||||
|
||||
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
|
||||
# order is randomized per-process by PYTHONHASHSEED, so community assignments
|
||||
# churn run-to-run. Pinning it makes graphify-out reproducible.
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
|
||||
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
||||
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
|
||||
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
|
||||
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
|
||||
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
|
||||
|
||||
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
|
||||
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
|
||||
if [ -z "$CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
|
||||
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
|
||||
if [ -z "$_NON_GRAPH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
|
||||
# _PINNED was recorded at hook-install time; tried first so the hook works even
|
||||
# when the graphify launcher is not on PATH (common in GUI clients and CI).
|
||||
GRAPHIFY_PYTHON=""
|
||||
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
|
||||
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_PINNED"
|
||||
fi
|
||||
# Second probe: read graphify-out/.graphify_python (written by the skill and
|
||||
# CLI; survives uv-tool reinstalls and is the same source the README documents).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
|
||||
if [ -f "$_GFY_PYTHON_FILE" ]; then
|
||||
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
case "$_FROM_FILE" in
|
||||
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
|
||||
esac
|
||||
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="$_FROM_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
case "$GRAPHIFY_BIN" in
|
||||
*.exe) _SHEBANG="" ;;
|
||||
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
|
||||
esac
|
||||
case "$_SHEBANG" in
|
||||
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
|
||||
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
# Allowlist: only keep characters valid in a filesystem path to prevent
|
||||
# injection if the shebang contains shell metacharacters.
|
||||
case "$GRAPHIFY_PYTHON" in
|
||||
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
|
||||
esac
|
||||
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Last resort: try python3 / python (works for system/venv installs on PATH).
|
||||
if [ -z "$GRAPHIFY_PYTHON" ]; then
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
|
||||
GRAPHIFY_PYTHON="python"
|
||||
else
|
||||
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
export GRAPHIFY_CHANGED="$CHANGED"
|
||||
|
||||
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
|
||||
# can take hours; blocking the post-commit hook stalls the shell. The Python
|
||||
# launcher below detaches the child cross-platform, so it works on Git for
|
||||
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
|
||||
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
|
||||
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
|
||||
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
|
||||
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
|
||||
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
|
||||
_src = '''
|
||||
import os, signal, sys
|
||||
from pathlib import Path
|
||||
|
||||
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
|
||||
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
|
||||
|
||||
if not changed:
|
||||
sys.exit(0)
|
||||
|
||||
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
|
||||
|
||||
try:
|
||||
from graphify.watch import _rebuild_code, _apply_resource_limits
|
||||
_apply_resource_limits()
|
||||
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
|
||||
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
|
||||
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
|
||||
signal.alarm(_timeout)
|
||||
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
|
||||
_root = Path('.')
|
||||
_saved = Path('graphify-out/.graphify_root')
|
||||
if _saved.exists():
|
||||
_txt = _saved.read_text(encoding='utf-8').strip()
|
||||
if _txt:
|
||||
_root = Path(_txt)
|
||||
_rebuild_code(_root, changed_paths=changed, force=_force)
|
||||
except TimeoutError as exc:
|
||||
print(f'[graphify hook] {exc}')
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f'[graphify hook] Rebuild failed: {exc}')
|
||||
sys.exit(1)
|
||||
|
||||
'''
|
||||
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
||||
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
|
||||
except OSError:
|
||||
_out = subprocess.DEVNULL
|
||||
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
|
||||
_cmd = [sys.executable, '-c', _src]
|
||||
if os.name == 'nt':
|
||||
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
try:
|
||||
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
|
||||
except OSError:
|
||||
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
|
||||
else:
|
||||
subprocess.Popen(_cmd, start_new_session=True, **_kw)
|
||||
"
|
||||
# graphify-hook-end
|
||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Memory Maintenance
|
||||
|
||||
## Discovery Model
|
||||
|
||||
- Core principle: progressive discovery through references, building a graph of memories.
|
||||
- Initially, agents are provided with the list of all memories (names only).
|
||||
- Agents should read `mem:core` as the top-level entry point (graph root).
|
||||
This memory should contain references to other memories covering major project domains.
|
||||
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
|
||||
The depth of the graph shall depend on the project complexity.
|
||||
- Use topics/folders to group related memories in order to make the content structure explicit.
|
||||
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
|
||||
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
|
||||
The surrounding text should clearly indicate when to read the memory/which content to expect.
|
||||
The text should provide more precise guidance than the memory name alone,
|
||||
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
|
||||
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
|
||||
|
||||
## Style
|
||||
|
||||
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
|
||||
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
|
||||
Keep guidance durable and generalizable, not task-local.
|
||||
|
||||
## Add/update threshold
|
||||
|
||||
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
|
||||
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
|
||||
|
||||
## Maintenance Actions
|
||||
|
||||
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
|
||||
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "moex-vibe"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
60
AGENTS.md
60
AGENTS.md
@ -41,7 +41,7 @@
|
||||
|
||||
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||
- **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
|
||||
|
||||
---
|
||||
@ -459,3 +459,61 @@ roadmap.md и inbox.md никогда не являются основанием
|
||||
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||||
- CI находится в `.gitea/workflows/ci.yml`.
|
||||
- Pre-commit checks настроены через Husky и lint-staged.
|
||||
|
||||
## code-index-mcp
|
||||
|
||||
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
|
||||
|
||||
**Инструменты:**
|
||||
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
|
||||
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
|
||||
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
|
||||
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
|
||||
- `find_implementations(name_path, relative_path)` — найти реализации символа
|
||||
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
|
||||
|
||||
**Когда использовать:**
|
||||
- Поиск файлов по имени или паттерну (glob)
|
||||
- Быстрый grep по коду с контекстом
|
||||
- Получение только тела функции/класса без всего файла
|
||||
|
||||
---
|
||||
|
||||
## serena
|
||||
|
||||
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
|
||||
|
||||
**Инструменты:**
|
||||
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
|
||||
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
|
||||
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
|
||||
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
|
||||
- `find_declaration(relative_path, regex)` — найти объявление по вызову
|
||||
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
|
||||
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
|
||||
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
|
||||
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
|
||||
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
|
||||
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
|
||||
|
||||
**Когда использовать:**
|
||||
- Найти все использования функции/метода в коде
|
||||
- Получить структуру файла (классы, методы)
|
||||
- Безопасный рефакторинг (переименование, удаление)
|
||||
- Получить LSP-диагностику (ошибки компиляции)
|
||||
- Запомнить что-то между сессиями (memories)
|
||||
|
||||
---
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
@ -174,12 +174,18 @@
|
||||
|
||||
Таблица данных на основе TanStack Table.
|
||||
|
||||
Использует `TanStack Table` как source of truth для модели данных, а MUI только для табличной
|
||||
оболочки. Поддерживает loading state, empty state, density и выравнивание через `columnDef.meta.align`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `columns` | `ColumnDef<T>[]` | — |
|
||||
| `data` | `T[]` | — |
|
||||
| `caption` | `string` | — |
|
||||
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
|
||||
| `loading` | `boolean` | `false` |
|
||||
| `empty` | `ReactNode` | — |
|
||||
| `renderRow` | `(row) => ReactNode` | — |
|
||||
|
||||
### Money
|
||||
|
||||
|
||||
@ -10,6 +10,10 @@
|
||||
|
||||
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
|
||||
|
||||
Для таблиц используйте `DataTable` из дизайн-системы. Он уже встроен в `TanStack Table` и поддерживает
|
||||
выравнивание, loading/empty state и кастомный рендер строк без прямого использования `MUI Table` во
|
||||
frontend-коде.
|
||||
|
||||
## Legacy: CSS custom properties
|
||||
|
||||
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ScreenerTable } from './ScreenerTable'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
|
||||
}))
|
||||
|
||||
const shareResult = {
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
secid: 'SBER',
|
||||
shortName: 'Сбер',
|
||||
type: 'share',
|
||||
price: 289.5,
|
||||
changePercent: 0.87,
|
||||
volume: 1000,
|
||||
capitalization: 625000000,
|
||||
},
|
||||
],
|
||||
} as const
|
||||
|
||||
const bondResult = {
|
||||
...shareResult,
|
||||
items: [
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
price: 98.5,
|
||||
changePercent: -0.5,
|
||||
volume: 500,
|
||||
yieldToMaturity: 8.2,
|
||||
duration: 3.5,
|
||||
couponValue: 36.9,
|
||||
couponPercent: 7.5,
|
||||
},
|
||||
],
|
||||
} as const
|
||||
|
||||
describe('ScreenerTable', () => {
|
||||
it('renders share columns and pagination', () => {
|
||||
const onSort = vi.fn()
|
||||
const onPageChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ScreenerTable
|
||||
result={shareResult as never}
|
||||
sortBy="price"
|
||||
sortOrder="asc"
|
||||
onSort={onSort}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Найдено: 2 бумаг')).toBeInTheDocument()
|
||||
expect(screen.getByText('Капитализация')).toBeInTheDocument()
|
||||
expect(screen.getByText('SBER')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
|
||||
expect(onSort).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSort when a sortable header is clicked', () => {
|
||||
const onSort = vi.fn()
|
||||
|
||||
render(
|
||||
<ScreenerTable
|
||||
result={shareResult as never}
|
||||
sortBy="price"
|
||||
sortOrder="asc"
|
||||
onSort={onSort}
|
||||
onPageChange={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
screen.getByRole('button', { name: /Цена/ }).click()
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('price')
|
||||
})
|
||||
|
||||
it('renders bond columns', () => {
|
||||
render(
|
||||
<ScreenerTable
|
||||
result={bondResult as never}
|
||||
sortBy="price"
|
||||
sortOrder="desc"
|
||||
onSort={() => undefined}
|
||||
onPageChange={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('YTM')).toBeInTheDocument()
|
||||
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,7 @@
|
||||
import { Button, DataTable, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { ScreenerResult } from '@/shared/api'
|
||||
|
||||
interface Props {
|
||||
@ -23,149 +26,173 @@ function formatChange(value: number | null | undefined): { text: string; color:
|
||||
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
|
||||
}
|
||||
|
||||
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
||||
function SortHeader({ field, children }: { field: string; children: string }) {
|
||||
const isActive = sortBy === field
|
||||
return (
|
||||
<th
|
||||
onClick={() => onSort(field)}
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
type ScreenerItem = ScreenerResult['items'][number]
|
||||
|
||||
const columnHelper = createColumnHelper<ScreenerItem>()
|
||||
|
||||
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
||||
const isShare = result.items[0]?.type === 'share'
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('secid', {
|
||||
header: 'Тикер',
|
||||
cell: (info) => {
|
||||
const link = isShare
|
||||
? `/stocks/${info.row.original.secid}`
|
||||
: `/bonds/${info.row.original.secid}`
|
||||
return (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
|
||||
Найдено: {result.total} бумаг
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<SortHeader field="price">Цена</SortHeader>
|
||||
<SortHeader field="changePercent">Изм.</SortHeader>
|
||||
<SortHeader field="volume">Объём</SortHeader>
|
||||
{isShare ? (
|
||||
<SortHeader field="capitalization">Капитализация</SortHeader>
|
||||
) : (
|
||||
<>
|
||||
<SortHeader field="yieldToMaturity">YTM</SortHeader>
|
||||
<SortHeader field="duration">Дюрация</SortHeader>
|
||||
<SortHeader field="couponValue">Купон</SortHeader>
|
||||
<SortHeader field="couponPercent">Куп. %</SortHeader>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((item) => {
|
||||
const change = formatChange(item.changePercent)
|
||||
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`
|
||||
return (
|
||||
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
|
||||
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
{item.secid}
|
||||
{info.getValue()}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
|
||||
{item.shortName}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatNum(item.price)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
|
||||
{change.text}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{item.volume.toLocaleString('ru-RU')}
|
||||
</td>
|
||||
{isShare ? (
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{item.capitalization != null
|
||||
? item.capitalization.toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatNum(item.yieldToMaturity)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatNum(item.couponValue)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatNum(item.couponPercent)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('shortName', { header: 'Название', cell: (info) => info.getValue() }),
|
||||
columnHelper.accessor('price', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('price')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Цена {sortBy === 'price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
}),
|
||||
columnHelper.accessor('changePercent', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('changePercent')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Изм. {sortBy === 'changePercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => {
|
||||
const change = formatChange(info.getValue())
|
||||
return <span style={{ color: change.color }}>{change.text}</span>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('volume', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('volume')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Объём {sortBy === 'volume' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => info.getValue().toLocaleString('ru-RU'),
|
||||
}),
|
||||
...(isShare
|
||||
? [
|
||||
columnHelper.accessor('capitalization', {
|
||||
header: 'Капитализация',
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => {
|
||||
const v = info.getValue()
|
||||
return v != null ? v.toLocaleString('ru-RU') : '—'
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [
|
||||
columnHelper.accessor('yieldToMaturity', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('yieldToMaturity')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
YTM {sortBy === 'yieldToMaturity' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => formatNum(info.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('duration', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('duration')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Дюрация {sortBy === 'duration' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => {
|
||||
const v = info.getValue()
|
||||
return v != null ? `${v.toFixed(2)}г` : '—'
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('couponValue', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('couponValue')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Купон {sortBy === 'couponValue' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => formatNum(info.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('couponPercent', {
|
||||
header: () => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onSort('couponPercent')}
|
||||
sx={{ all: 'unset', cursor: 'pointer' }}
|
||||
>
|
||||
Куп. % {sortBy === 'couponPercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||
</Box>
|
||||
),
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => formatNum(info.getValue()),
|
||||
}),
|
||||
]),
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: result.items,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Text variant="caption" tone="secondary" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
Найдено: {result.total} бумаг
|
||||
</Text>
|
||||
<DataTable table={table} caption="Screener" />
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
|
||||
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
<Button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
background: p === result.page ? 'var(--color-primary)' : 'transparent',
|
||||
color: p === result.page ? '#fff' : 'var(--color-text)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
variant={p === result.page ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import { flexRender, type Table as TanStackTable } from '@tanstack/react-table'
|
||||
|
||||
interface TableProps<TData> {
|
||||
table: TanStackTable<TData>
|
||||
}
|
||||
|
||||
export function Table<TData>({ table }: TableProps<TData>) {
|
||||
return (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={{ cursor: header.column.getCanSort() ? 'pointer' : undefined }}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{{ asc: ' ▲', desc: ' ▼' }[header.column.getIsSorted() as string] ?? null}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { Table } from './Table'
|
||||
@ -1,25 +0,0 @@
|
||||
import { Skeleton } from '@moex-vibe/design-system'
|
||||
|
||||
const tdStyle = {
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
} satisfies React.CSSProperties
|
||||
|
||||
type Column = { width: string }
|
||||
|
||||
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
|
||||
return (
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} style={tdStyle}>
|
||||
<Skeleton height={12} width={col.width} shape="text" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { TableSkeleton } from './TableSkeleton'
|
||||
@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
import { BondPositionTable } from './BondPositionTable'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
|
||||
}))
|
||||
|
||||
const positions: PositionWithPrice[] = [
|
||||
{
|
||||
id: 1,
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
buyPrice: 980,
|
||||
buyDate: null,
|
||||
notes: null,
|
||||
tags: null,
|
||||
currentPrice: 985,
|
||||
totalCost: 4900,
|
||||
currentValue: 4925,
|
||||
weightPercent: 7.5,
|
||||
pnl: 25,
|
||||
pnlPercent: 0.5,
|
||||
dividendIncome: null,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
change: 5,
|
||||
changePercent: 0.5,
|
||||
yieldToMaturity: 8.2,
|
||||
duration: 3.5,
|
||||
couponValue: 36.9,
|
||||
couponPercent: 7.5,
|
||||
nextCouponDate: '2024-07-15',
|
||||
matDate: '2027-05-15',
|
||||
accruedInt: 8.45,
|
||||
bid: 984,
|
||||
offer: 986,
|
||||
couponPeriod: 182,
|
||||
bondType: 'ОФЗ',
|
||||
offerDate: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('BondPositionTable', () => {
|
||||
it('renders section title and bond row', () => {
|
||||
render(
|
||||
<BondPositionTable
|
||||
positions={positions}
|
||||
onUpdatePosition={() => undefined}
|
||||
onDeletePosition={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Облигации')).toBeInTheDocument()
|
||||
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument()
|
||||
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when there are no positions', () => {
|
||||
const { container } = render(
|
||||
<BondPositionTable
|
||||
positions={[]}
|
||||
onUpdatePosition={() => undefined}
|
||||
onDeletePosition={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,6 @@
|
||||
import { DataTable } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
import { BondPositionRow } from './BondPositionRow'
|
||||
|
||||
@ -10,264 +13,58 @@ interface Props {
|
||||
onDeletePosition: (positionId: number) => void
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PositionWithPrice>()
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('secid', { header: 'Тикер' }),
|
||||
columnHelper.accessor('shortName', {
|
||||
header: 'Название',
|
||||
cell: (info) => info.getValue() ?? '—',
|
||||
}),
|
||||
columnHelper.accessor('bondType', { header: 'Тип', cell: (info) => info.getValue() ?? '—' }),
|
||||
columnHelper.accessor('quantity', { header: 'Количество' }),
|
||||
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('bid', { header: 'Бид', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('offer', { header: 'Оффер', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('yieldToMaturity', { header: 'Доходность', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('duration', { header: 'Дюрация', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('couponValue', { header: 'Купон', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('couponPercent', { header: 'Куп. %', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('couponPeriod', { header: 'Период', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('accruedInt', { header: 'НКД', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('nextCouponDate', { header: 'След. купон', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('matDate', { header: 'Погашение', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('offerDate', { header: 'Оферта', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
|
||||
]
|
||||
|
||||
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||
const table = useReactTable({
|
||||
data: positions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
if (positions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Облигации
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тип
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена пок.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Бид
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Оффер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доходность
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Дюрация
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Купон
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Куп. %
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Период
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
НКД
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Затраты
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
P&L
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
P&L %
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
След. купон
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Погашение
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Оферта
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доля
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<Box sx={{ marginTop: 3 }}>
|
||||
<DataTable
|
||||
table={table}
|
||||
caption="Облигации"
|
||||
renderRow={(row) => (
|
||||
<BondPositionRow
|
||||
key={pos.id}
|
||||
position={pos}
|
||||
onUpdate={(data) => onUpdatePosition(pos.id, data)}
|
||||
onDelete={() => onDeletePosition(pos.id)}
|
||||
key={row.id}
|
||||
position={row.original}
|
||||
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
|
||||
onDelete={() => onDeletePosition(row.original.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
type BrokerOperationImpact,
|
||||
getBrokerOperationImpact,
|
||||
@ -10,7 +10,35 @@ import {
|
||||
import { getBrokerInstrumentPath } from '@/entities/broker-position'
|
||||
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
|
||||
import { formatBrokerSignedMoney } from '@/shared/lib/formatters'
|
||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton'
|
||||
|
||||
const TABLE_SKELETON_COLUMNS = [
|
||||
{ width: '35%' },
|
||||
{ width: '30%' },
|
||||
{ width: '40%' },
|
||||
{ width: '25%' },
|
||||
] as const
|
||||
|
||||
const tdSkeletonStyle: CSSProperties = {
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
}
|
||||
|
||||
function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{TABLE_SKELETON_COLUMNS.map((col, j) => (
|
||||
<td key={j} style={tdSkeletonStyle}>
|
||||
<Skeleton height={12} width={col.width} shape="text" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
|
||||
const tableSx = {
|
||||
width: '100%',
|
||||
@ -164,10 +192,7 @@ export function BrokerOperationsTable({
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<TableSkeleton
|
||||
rows={5}
|
||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||
/>
|
||||
<TableSkeleton rows={5} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : operations.length === 0 && !isFetching ? (
|
||||
|
||||
@ -12,4 +12,10 @@ describe('DividendsTable', () => {
|
||||
expect(screen.getByText('2024-07-10')).toBeInTheDocument()
|
||||
expect(screen.getByText('35.00 RUB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when there are no dividends', () => {
|
||||
render(<DividendsTable dividends={[]} />)
|
||||
|
||||
expect(screen.getByText('Нет дивидендов')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,38 +1,47 @@
|
||||
import { DataTable, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { DividendItem } from '@/shared/api'
|
||||
|
||||
interface DividendsTableProps {
|
||||
dividends: DividendItem[]
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<DividendItem>()
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('registryCloseDate', {
|
||||
header: 'Дата закрытия реестра',
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor('value', {
|
||||
header: 'Сумма',
|
||||
meta: { align: 'right' },
|
||||
cell: (info) => `${info.getValue().toFixed(2)} ${info.row.original.currency}`,
|
||||
}),
|
||||
]
|
||||
|
||||
export function DividendsTable({ dividends }: DividendsTableProps) {
|
||||
const table = useReactTable({
|
||||
data: dividends,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #eee' }}>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
|
||||
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dividends.map((d, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
|
||||
<td style={{ textAlign: 'right', padding: 8 }}>
|
||||
{d.value.toFixed(2)} {d.currency}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
caption="Дивиденды"
|
||||
empty={<Text tone="secondary">Нет дивидендов</Text>}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
import { SharePositionTable } from './SharePositionTable'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
|
||||
}))
|
||||
|
||||
const positions: PositionWithPrice[] = [
|
||||
{
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
shortName: 'Сбер',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
buyPrice: 250,
|
||||
buyDate: null,
|
||||
notes: null,
|
||||
tags: null,
|
||||
currentPrice: 260,
|
||||
totalCost: 2500,
|
||||
currentValue: 2600,
|
||||
weightPercent: 12.5,
|
||||
pnl: 100,
|
||||
pnlPercent: 4,
|
||||
dividendIncome: null,
|
||||
totalReturn: null,
|
||||
totalReturnPercent: null,
|
||||
change: 10,
|
||||
changePercent: 4,
|
||||
yieldToMaturity: null,
|
||||
duration: null,
|
||||
couponValue: null,
|
||||
couponPercent: null,
|
||||
nextCouponDate: null,
|
||||
matDate: null,
|
||||
accruedInt: null,
|
||||
bid: null,
|
||||
offer: null,
|
||||
couponPeriod: null,
|
||||
bondType: null,
|
||||
offerDate: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('SharePositionTable', () => {
|
||||
it('renders section title and position row', () => {
|
||||
render(
|
||||
<SharePositionTable
|
||||
positions={positions}
|
||||
onUpdatePosition={() => undefined}
|
||||
onDeletePosition={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Акции')).toBeInTheDocument()
|
||||
expect(screen.getByText('SBER')).toBeInTheDocument()
|
||||
expect(screen.getByText('Сбер')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when there are no positions', () => {
|
||||
const { container } = render(
|
||||
<SharePositionTable
|
||||
positions={[]}
|
||||
onUpdatePosition={() => undefined}
|
||||
onDeletePosition={() => undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,6 @@
|
||||
import { DataTable } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
import { SharePositionRow } from './SharePositionRow'
|
||||
|
||||
@ -10,154 +13,49 @@ interface Props {
|
||||
onDeletePosition: (positionId: number) => void
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PositionWithPrice>()
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('secid', { header: 'Тикер' }),
|
||||
columnHelper.accessor('shortName', {
|
||||
header: 'Название',
|
||||
cell: (info) => info.getValue() ?? '—',
|
||||
}),
|
||||
columnHelper.accessor('quantity', { header: 'Количество' }),
|
||||
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('change', { header: 'Изм.', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('currentValue', { header: 'Стоимость', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
|
||||
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
|
||||
columnHelper.display({ id: 'actions', header: '', meta: { align: 'left' } }),
|
||||
]
|
||||
|
||||
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||
const table = useReactTable({
|
||||
data: positions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
if (positions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Акции
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена пок.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Изм.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Стоимость
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Затраты
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
P&L
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
P&L %
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доля
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<Box sx={{ marginTop: 2 }}>
|
||||
<DataTable
|
||||
table={table}
|
||||
caption="Акции"
|
||||
renderRow={(row) => (
|
||||
<SharePositionRow
|
||||
key={pos.id}
|
||||
position={pos}
|
||||
onUpdate={(data) => onUpdatePosition(pos.id, data)}
|
||||
onDelete={() => onDeletePosition(pos.id)}
|
||||
key={row.id}
|
||||
position={row.original}
|
||||
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
|
||||
onDelete={() => onDeletePosition(row.original.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,46 +10,47 @@
|
||||
|
||||
## Task 1: Уточнить API `DataTable`
|
||||
|
||||
- [ ] Проверить текущее API `DataTable`
|
||||
- [ ] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
|
||||
- [ ] Подготовить изменения API только под подтверждённые кейсы
|
||||
- [ ] Убедиться, что `TanStack Table` остаётся source of truth
|
||||
- [x] Проверить текущее API `DataTable`
|
||||
- [x] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
|
||||
- [x] Подготовить изменения API только под подтверждённые кейсы
|
||||
- [x] Убедиться, что `TanStack Table` остаётся source of truth
|
||||
|
||||
## Task 2: Мигрировать `DividendsTable`
|
||||
|
||||
- [ ] Перевести HTML table на `DataTable`
|
||||
- [ ] Сохранить текущие колонки и форматирование
|
||||
- [ ] Проверить empty/loading presentation
|
||||
- [x] Перевести HTML table на `DataTable`
|
||||
- [x] Сохранить текущие колонки и форматирование
|
||||
- [x] Проверить empty/loading presentation
|
||||
|
||||
## Task 3: Мигрировать `ScreenerTable`
|
||||
|
||||
- [ ] Перевести табличную оболочку на `DataTable`
|
||||
- [ ] Сохранить сортировку и пагинацию
|
||||
- [ ] Сохранить row actions и кастомные ячейки
|
||||
- [x] Перевести табличную оболочку на `DataTable`
|
||||
- [x] Сохранить сортировку и пагинацию
|
||||
- [x] Сохранить row actions и кастомные ячейки
|
||||
|
||||
## Task 4: Мигрировать `SharePositionTable`
|
||||
|
||||
- [ ] Перевести таблицу на `DataTable`
|
||||
- [ ] Сохранить доменные row/cell renderers в приложении
|
||||
- [ ] Сохранить update/delete сценарии
|
||||
- [x] Перевести таблицу на `DataTable`
|
||||
- [x] Сохранить доменные row/cell renderers в приложении
|
||||
- [x] Сохранить update/delete сценарии
|
||||
|
||||
## Task 5: Мигрировать `BondPositionTable`
|
||||
|
||||
- [ ] Перевести таблицу на `DataTable`
|
||||
- [ ] Сохранить bond-specific rendering и действия
|
||||
- [ ] Не менять предметную финансовую логику
|
||||
- [x] Перевести таблицу на `DataTable`
|
||||
- [x] Сохранить bond-specific rendering и действия
|
||||
- [x] Не менять предметную финансовую логику
|
||||
|
||||
## Task 6: Очистка legacy и документация
|
||||
|
||||
- [ ] Проверить использование `shared/ui/Table`
|
||||
- [ ] Проверить использование `TableSkeleton`
|
||||
- [ ] Обновить docs по `DataTable` и `TanStack Table`
|
||||
- [ ] Удалить legacy helper'ы, если они больше не нужны
|
||||
- [x] Проверить использование `shared/ui/Table` — 0 потребителей, удалён
|
||||
- [x] Проверить использование `TableSkeleton` — 1 потребитель (BrokerOperationsTable, out of scope)
|
||||
- [x] Обновить docs по `DataTable` и `TanStack Table`
|
||||
- [x] Удалить `shared/ui/Table` (без потребителей)
|
||||
- [x] `TableSkeleton` заинлайнен в BrokerOperationsTable, оригинал удалён
|
||||
|
||||
## Task 7: Верификация
|
||||
|
||||
- [ ] Запустить frontend tests
|
||||
- [ ] Запустить frontend lint
|
||||
- [ ] Запустить design-system lint/tests при необходимости
|
||||
- [ ] Запустить frontend и design-system build
|
||||
- [ ] Проверить отсутствие новых запрещённых MUI table imports
|
||||
- [x] Запустить frontend tests — 124 passed
|
||||
- [x] Запустить frontend lint — 1 pre-existing error (react-hooks/exhaustive-deps not found)
|
||||
- [x] Запустить design-system lint/tests — 160 tests passed, 7 pre-existing lint errors
|
||||
- [x] Запустить frontend и design-system build — оба проходят
|
||||
- [x] Проверить отсутствие новых запрещённых MUI table imports — 0 прямых импортов Table/TableRow/TableCell
|
||||
|
||||
2370
graphify-out/.graphify_analysis.json
Normal file
2370
graphify-out/.graphify_analysis.json
Normal file
File diff suppressed because it is too large
Load Diff
286
graphify-out/.graphify_labels.json
Normal file
286
graphify-out/.graphify_labels.json
Normal file
@ -0,0 +1,286 @@
|
||||
{
|
||||
"0": "Community 0",
|
||||
"1": "Community 1",
|
||||
"2": "Community 2",
|
||||
"3": "Community 3",
|
||||
"4": "Community 4",
|
||||
"5": "Community 5",
|
||||
"6": "Community 6",
|
||||
"7": "Community 7",
|
||||
"8": "Community 8",
|
||||
"9": "Community 9",
|
||||
"10": "Community 10",
|
||||
"11": "Community 11",
|
||||
"12": "Community 12",
|
||||
"13": "Community 13",
|
||||
"14": "Community 14",
|
||||
"15": "Community 15",
|
||||
"16": "Community 16",
|
||||
"17": "Community 17",
|
||||
"18": "Community 18",
|
||||
"19": "Community 19",
|
||||
"20": "Community 20",
|
||||
"21": "Community 21",
|
||||
"22": "Community 22",
|
||||
"23": "Community 23",
|
||||
"24": "Community 24",
|
||||
"25": "Community 25",
|
||||
"26": "Community 26",
|
||||
"27": "Community 27",
|
||||
"28": "Community 28",
|
||||
"29": "Community 29",
|
||||
"30": "Community 30",
|
||||
"31": "Community 31",
|
||||
"32": "Community 32",
|
||||
"33": "Community 33",
|
||||
"34": "Community 34",
|
||||
"35": "Community 35",
|
||||
"36": "Community 36",
|
||||
"37": "Community 37",
|
||||
"38": "Community 38",
|
||||
"39": "Community 39",
|
||||
"40": "Community 40",
|
||||
"41": "Community 41",
|
||||
"42": "Community 42",
|
||||
"43": "Community 43",
|
||||
"44": "Community 44",
|
||||
"45": "Community 45",
|
||||
"46": "Community 46",
|
||||
"47": "Community 47",
|
||||
"48": "Community 48",
|
||||
"49": "Community 49",
|
||||
"50": "Community 50",
|
||||
"51": "Community 51",
|
||||
"52": "Community 52",
|
||||
"53": "Community 53",
|
||||
"54": "Community 54",
|
||||
"55": "Community 55",
|
||||
"56": "Community 56",
|
||||
"57": "Community 57",
|
||||
"58": "Community 58",
|
||||
"59": "Community 59",
|
||||
"60": "Community 60",
|
||||
"61": "Community 61",
|
||||
"62": "Community 62",
|
||||
"63": "Community 63",
|
||||
"64": "Community 64",
|
||||
"65": "Community 65",
|
||||
"66": "Community 66",
|
||||
"67": "Community 67",
|
||||
"68": "Community 68",
|
||||
"69": "Community 69",
|
||||
"70": "Community 70",
|
||||
"71": "Community 71",
|
||||
"72": "Community 72",
|
||||
"73": "Community 73",
|
||||
"74": "Community 74",
|
||||
"75": "Community 75",
|
||||
"76": "Community 76",
|
||||
"77": "Community 77",
|
||||
"78": "Community 78",
|
||||
"79": "Community 79",
|
||||
"80": "Community 80",
|
||||
"81": "Community 81",
|
||||
"82": "Community 82",
|
||||
"83": "Community 83",
|
||||
"84": "Community 84",
|
||||
"85": "Community 85",
|
||||
"86": "Community 86",
|
||||
"87": "Community 87",
|
||||
"88": "Community 88",
|
||||
"89": "Community 89",
|
||||
"90": "Community 90",
|
||||
"91": "Community 91",
|
||||
"92": "Community 92",
|
||||
"93": "Community 93",
|
||||
"94": "Community 94",
|
||||
"95": "Community 95",
|
||||
"96": "Community 96",
|
||||
"97": "Community 97",
|
||||
"98": "Community 98",
|
||||
"99": "Community 99",
|
||||
"100": "Community 100",
|
||||
"101": "Community 101",
|
||||
"102": "Community 102",
|
||||
"103": "Community 103",
|
||||
"104": "Community 104",
|
||||
"105": "Community 105",
|
||||
"106": "Community 106",
|
||||
"107": "Community 107",
|
||||
"108": "Community 108",
|
||||
"109": "Community 109",
|
||||
"110": "Community 110",
|
||||
"111": "Community 111",
|
||||
"112": "Community 112",
|
||||
"113": "Community 113",
|
||||
"114": "Community 114",
|
||||
"115": "Community 115",
|
||||
"116": "Community 116",
|
||||
"117": "Community 117",
|
||||
"118": "Community 118",
|
||||
"119": "Community 119",
|
||||
"120": "Community 120",
|
||||
"121": "Community 121",
|
||||
"122": "Community 122",
|
||||
"123": "Community 123",
|
||||
"124": "Community 124",
|
||||
"125": "Community 125",
|
||||
"126": "Community 126",
|
||||
"127": "Community 127",
|
||||
"128": "Community 128",
|
||||
"129": "Community 129",
|
||||
"130": "Community 130",
|
||||
"131": "Community 131",
|
||||
"132": "Community 132",
|
||||
"133": "Community 133",
|
||||
"134": "Community 134",
|
||||
"135": "Community 135",
|
||||
"136": "Community 136",
|
||||
"137": "Community 137",
|
||||
"138": "Community 138",
|
||||
"139": "Community 139",
|
||||
"140": "Community 140",
|
||||
"141": "Community 141",
|
||||
"142": "Community 142",
|
||||
"143": "Community 143",
|
||||
"144": "Community 144",
|
||||
"145": "Community 145",
|
||||
"146": "Community 146",
|
||||
"147": "Community 147",
|
||||
"148": "Community 148",
|
||||
"149": "Community 149",
|
||||
"150": "Community 150",
|
||||
"151": "Community 151",
|
||||
"152": "Community 152",
|
||||
"153": "Community 153",
|
||||
"154": "Community 154",
|
||||
"155": "Community 155",
|
||||
"156": "Community 156",
|
||||
"157": "Community 157",
|
||||
"158": "Community 158",
|
||||
"159": "Community 159",
|
||||
"160": "Community 160",
|
||||
"161": "Community 161",
|
||||
"162": "Community 162",
|
||||
"163": "Community 163",
|
||||
"164": "Community 164",
|
||||
"165": "Community 165",
|
||||
"166": "Community 166",
|
||||
"167": "Community 167",
|
||||
"168": "Community 168",
|
||||
"169": "Community 169",
|
||||
"170": "Community 170",
|
||||
"171": "Community 171",
|
||||
"172": "Community 172",
|
||||
"173": "Community 173",
|
||||
"174": "Community 174",
|
||||
"175": "Community 175",
|
||||
"176": "Community 176",
|
||||
"177": "Community 177",
|
||||
"178": "Community 178",
|
||||
"179": "Community 179",
|
||||
"180": "Community 180",
|
||||
"181": "Community 181",
|
||||
"182": "Community 182",
|
||||
"183": "Community 183",
|
||||
"184": "Community 184",
|
||||
"185": "Community 185",
|
||||
"186": "Community 186",
|
||||
"187": "Community 187",
|
||||
"188": "Community 188",
|
||||
"189": "Community 189",
|
||||
"190": "Community 190",
|
||||
"191": "Community 191",
|
||||
"192": "Community 192",
|
||||
"193": "Community 193",
|
||||
"194": "Community 194",
|
||||
"195": "Community 195",
|
||||
"196": "Community 196",
|
||||
"197": "Community 197",
|
||||
"198": "Community 198",
|
||||
"199": "Community 199",
|
||||
"200": "Community 200",
|
||||
"201": "Community 201",
|
||||
"202": "Community 202",
|
||||
"203": "Community 203",
|
||||
"204": "Community 204",
|
||||
"205": "Community 205",
|
||||
"206": "Community 206",
|
||||
"207": "Community 207",
|
||||
"208": "Community 208",
|
||||
"209": "Community 209",
|
||||
"210": "Community 210",
|
||||
"211": "Community 211",
|
||||
"212": "Community 212",
|
||||
"213": "Community 213",
|
||||
"214": "Community 214",
|
||||
"215": "Community 215",
|
||||
"216": "Community 216",
|
||||
"217": "Community 217",
|
||||
"218": "Community 218",
|
||||
"219": "Community 219",
|
||||
"220": "Community 220",
|
||||
"221": "Community 221",
|
||||
"222": "Community 222",
|
||||
"223": "Community 223",
|
||||
"224": "Community 224",
|
||||
"225": "Community 225",
|
||||
"226": "Community 226",
|
||||
"227": "Community 227",
|
||||
"228": "Community 228",
|
||||
"229": "Community 229",
|
||||
"230": "Community 230",
|
||||
"231": "Community 231",
|
||||
"232": "Community 232",
|
||||
"233": "Community 233",
|
||||
"234": "Community 234",
|
||||
"235": "Community 235",
|
||||
"236": "Community 236",
|
||||
"237": "Community 237",
|
||||
"238": "Community 238",
|
||||
"239": "Community 239",
|
||||
"240": "Community 240",
|
||||
"241": "Community 241",
|
||||
"242": "Community 242",
|
||||
"243": "Community 243",
|
||||
"244": "Community 244",
|
||||
"245": "Community 245",
|
||||
"246": "Community 246",
|
||||
"247": "Community 247",
|
||||
"248": "Community 248",
|
||||
"249": "Community 249",
|
||||
"250": "Community 250",
|
||||
"251": "Community 251",
|
||||
"252": "Community 252",
|
||||
"253": "Community 253",
|
||||
"254": "Community 254",
|
||||
"255": "Community 255",
|
||||
"256": "Community 256",
|
||||
"257": "Community 257",
|
||||
"258": "Community 258",
|
||||
"259": "Community 259",
|
||||
"260": "Community 260",
|
||||
"261": "Community 261",
|
||||
"262": "Community 262",
|
||||
"263": "Community 263",
|
||||
"264": "Community 264",
|
||||
"265": "Community 265",
|
||||
"266": "Community 266",
|
||||
"267": "Community 267",
|
||||
"268": "Community 268",
|
||||
"269": "Community 269",
|
||||
"270": "Community 270",
|
||||
"271": "Community 271",
|
||||
"272": "Community 272",
|
||||
"273": "Community 273",
|
||||
"274": "Community 274",
|
||||
"275": "Community 275",
|
||||
"276": "Community 276",
|
||||
"277": "Community 277",
|
||||
"278": "Community 278",
|
||||
"279": "Community 279",
|
||||
"280": "Community 280",
|
||||
"281": "Community 281",
|
||||
"282": "Community 282",
|
||||
"283": "Community 283"
|
||||
}
|
||||
1
graphify-out/.graphify_root
Normal file
1
graphify-out/.graphify_root
Normal file
@ -0,0 +1 @@
|
||||
.
|
||||
1381
graphify-out/GRAPH_REPORT.md
Normal file
1381
graphify-out/GRAPH_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
307
graphify-out/graph.html
Normal file
307
graphify-out/graph.html
Normal file
File diff suppressed because one or more lines are too long
100147
graphify-out/graph.json
Normal file
100147
graphify-out/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
3437
graphify-out/manifest.json
Normal file
3437
graphify-out/manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TableCell, TableRow } from '@mui/material';
|
||||
import { DataTable } from './DataTable';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
|
||||
@ -65,6 +66,50 @@ function EmptyTable() {
|
||||
);
|
||||
}
|
||||
|
||||
function AlignedTable() {
|
||||
const alignedColumns = [
|
||||
columnHelper.accessor('name', { header: 'Name' }),
|
||||
columnHelper.accessor('price', {
|
||||
header: 'Price',
|
||||
meta: { align: 'right' } as any,
|
||||
}),
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: alignedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Aligned" />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomRowTable() {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable
|
||||
table={table}
|
||||
caption="Custom"
|
||||
renderRow={(row) => (
|
||||
<TableRow key={row.id} data-testid={`custom-row-${row.id}`}>
|
||||
<TableCell colSpan={2}>{row.original.name}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
/>
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DataTable', () => {
|
||||
it('renders caption', () => {
|
||||
render(<TestTable caption="Test Caption" />);
|
||||
@ -94,4 +139,25 @@ describe('DataTable', () => {
|
||||
render(<EmptyTable />);
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading rows instead of data when loading', () => {
|
||||
render(<TestTableWithProps caption="Stocks" loading />);
|
||||
|
||||
expect(screen.queryByText('AAPL')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('row')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('applies column alignment from meta', () => {
|
||||
render(<AlignedTable />);
|
||||
|
||||
expect(screen.getByText('Price').closest('th')).toHaveClass('MuiTableCell-alignRight');
|
||||
});
|
||||
|
||||
it('renders custom rows when provided', () => {
|
||||
render(<CustomRowTable />);
|
||||
|
||||
expect(screen.getByTestId('custom-row-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-row-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,19 +6,30 @@ import {
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Skeleton,
|
||||
type TableProps as MuiTableProps,
|
||||
} from '@mui/material';
|
||||
import type { Table as TanStackTable } from '@tanstack/react-table';
|
||||
import type { Column, Row, RowData, Table as TanStackTable } from '@tanstack/react-table';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
|
||||
export type Density = 'balanced' | 'compact';
|
||||
|
||||
type CellAlign = 'left' | 'center' | 'right';
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
align?: CellAlign;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
table: TanStackTable<T>;
|
||||
density?: Density;
|
||||
loading?: boolean;
|
||||
empty?: ReactNode;
|
||||
caption: string;
|
||||
renderRow?: (row: Row<T>) => ReactNode;
|
||||
}
|
||||
|
||||
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
|
||||
@ -32,6 +43,7 @@ export function DataTable<T>({
|
||||
loading,
|
||||
empty,
|
||||
caption,
|
||||
renderRow,
|
||||
}: DataTableProps<T>) {
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
@ -39,6 +51,14 @@ export function DataTable<T>({
|
||||
return <>{empty}</>;
|
||||
}
|
||||
|
||||
const isLoading = Boolean(loading);
|
||||
const loadingRows = Array.from({ length: 5 });
|
||||
const leafColumns = table.getVisibleLeafColumns();
|
||||
|
||||
function getAlign(column: Column<T, unknown>) {
|
||||
return column.columnDef.meta?.align;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size={DENSITY_PADDING[density]}>
|
||||
@ -47,7 +67,11 @@ export function DataTable<T>({
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} sortDirection={header.column.getIsSorted() || false}>
|
||||
<TableCell
|
||||
key={header.id}
|
||||
align={header.column.columnDef.meta?.align ?? 'left'}
|
||||
sortDirection={header.column.getIsSorted() || false}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
@ -57,10 +81,22 @@ export function DataTable<T>({
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
{isLoading
|
||||
? loadingRows.map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{leafColumns.map((column) => (
|
||||
<TableCell key={column.id} align={getAlign(column) ?? 'left'}>
|
||||
<Skeleton height={12} width="80%" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: renderRow
|
||||
? rows.map((row) => renderRow(row))
|
||||
: rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell key={cell.id} align={cell.column.columnDef.meta?.align ?? 'left'}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user