@wallarm-org/claude-design 0.0.0-stage → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -2
- package/bin/claude-design.js +347 -0
- package/dist/SETUP.md +130 -0
- package/dist/UPDATE.md +84 -0
- package/dist/assets/Icons/activity.svg +1 -0
- package/dist/assets/Icons/alt.svg +1 -0
- package/dist/assets/Icons/android-neutral.svg +1 -0
- package/dist/assets/Icons/android.svg +1 -0
- package/dist/assets/Icons/api.svg +1 -0
- package/dist/assets/Icons/apple-music-neutral.svg +1 -0
- package/dist/assets/Icons/apple-music.svg +1 -0
- package/dist/assets/Icons/apple-neutral.svg +1 -0
- package/dist/assets/Icons/apple-podcasts-neutral.svg +1 -0
- package/dist/assets/Icons/apple-podcasts.svg +1 -0
- package/dist/assets/Icons/apple.svg +1 -0
- package/dist/assets/Icons/archive.svg +1 -0
- package/dist/assets/Icons/arrow-big-up-dash.svg +1 -0
- package/dist/assets/Icons/arrow-big-up.svg +1 -0
- package/dist/assets/Icons/arrow-down-left.svg +1 -0
- package/dist/assets/Icons/arrow-down-right.svg +1 -0
- package/dist/assets/Icons/arrow-down.svg +1 -0
- package/dist/assets/Icons/arrow-left.svg +1 -0
- package/dist/assets/Icons/arrow-right-alt.svg +1 -0
- package/dist/assets/Icons/arrow-right.svg +1 -0
- package/dist/assets/Icons/arrow-up-down.svg +1 -0
- package/dist/assets/Icons/arrow-up-left.svg +1 -0
- package/dist/assets/Icons/arrow-up-right.svg +1 -0
- package/dist/assets/Icons/arrow-up.svg +1 -0
- package/dist/assets/Icons/artstation-neutral.svg +1 -0
- package/dist/assets/Icons/artstation.svg +1 -0
- package/dist/assets/Icons/asterisk.svg +1 -0
- package/dist/assets/Icons/audio-waveform.svg +1 -0
- package/dist/assets/Icons/aws-neutral.svg +1 -0
- package/dist/assets/Icons/aws.svg +1 -0
- package/dist/assets/Icons/badge-check.svg +1 -0
- package/dist/assets/Icons/baidu-neutral.svg +1 -0
- package/dist/assets/Icons/baidu.svg +1 -0
- package/dist/assets/Icons/bandage.svg +1 -0
- package/dist/assets/Icons/behance-neutral.svg +1 -0
- package/dist/assets/Icons/behance.svg +1 -0
- package/dist/assets/Icons/bell-cog.svg +1 -0
- package/dist/assets/Icons/bell.svg +1 -0
- package/dist/assets/Icons/binary.svg +1 -0
- package/dist/assets/Icons/book-open.svg +1 -0
- package/dist/assets/Icons/book.svg +1 -0
- package/dist/assets/Icons/boosty-neutral.svg +1 -0
- package/dist/assets/Icons/boosty.svg +1 -0
- package/dist/assets/Icons/bot.svg +1 -0
- package/dist/assets/Icons/brain.svg +1 -0
- package/dist/assets/Icons/braze-neutral.svg +1 -0
- package/dist/assets/Icons/braze.svg +1 -0
- package/dist/assets/Icons/brevo-neutral.svg +1 -0
- package/dist/assets/Icons/brevo.svg +1 -0
- package/dist/assets/Icons/bug-off.svg +1 -0
- package/dist/assets/Icons/bug-play.svg +1 -0
- package/dist/assets/Icons/bug.svg +1 -0
- package/dist/assets/Icons/building.svg +1 -0
- package/dist/assets/Icons/building2.svg +1 -0
- package/dist/assets/Icons/calendar-check.svg +1 -0
- package/dist/assets/Icons/calendar-check2.svg +1 -0
- package/dist/assets/Icons/calendar-clock.svg +1 -0
- package/dist/assets/Icons/calendar-cog.svg +1 -0
- package/dist/assets/Icons/calendar-days.svg +1 -0
- package/dist/assets/Icons/calendar-fold.svg +1 -0
- package/dist/assets/Icons/calendar-heart.svg +1 -0
- package/dist/assets/Icons/calendar-minus.svg +1 -0
- package/dist/assets/Icons/calendar-minus2.svg +1 -0
- package/dist/assets/Icons/calendar-off.svg +1 -0
- package/dist/assets/Icons/calendar-plus.svg +1 -0
- package/dist/assets/Icons/calendar-plus2.svg +1 -0
- package/dist/assets/Icons/calendar-range.svg +1 -0
- package/dist/assets/Icons/calendar-search.svg +1 -0
- package/dist/assets/Icons/calendar-x.svg +1 -0
- package/dist/assets/Icons/calendar-x2.svg +1 -0
- package/dist/assets/Icons/calendar.svg +1 -0
- package/dist/assets/Icons/chart-area.svg +1 -0
- package/dist/assets/Icons/chart-bar.svg +1 -0
- package/dist/assets/Icons/chart-column.svg +1 -0
- package/dist/assets/Icons/chart-no-axes-column.svg +1 -0
- package/dist/assets/Icons/check-check.svg +1 -0
- package/dist/assets/Icons/check.svg +1 -0
- package/dist/assets/Icons/checkbox-indeterminate.svg +1 -0
- package/dist/assets/Icons/chevron-down.svg +1 -0
- package/dist/assets/Icons/chevron-first.svg +1 -0
- package/dist/assets/Icons/chevron-last.svg +1 -0
- package/dist/assets/Icons/chevron-left.svg +1 -0
- package/dist/assets/Icons/chevron-right.svg +1 -0
- package/dist/assets/Icons/chevron-up-down.svg +1 -0
- package/dist/assets/Icons/chevron-up.svg +1 -0
- package/dist/assets/Icons/chevrons-down-up.svg +1 -0
- package/dist/assets/Icons/chevrons-down.svg +1 -0
- package/dist/assets/Icons/chevrons-left-right.svg +1 -0
- package/dist/assets/Icons/chevrons-left.svg +1 -0
- package/dist/assets/Icons/chevrons-right-left.svg +1 -0
- package/dist/assets/Icons/chevrons-right.svg +1 -0
- package/dist/assets/Icons/chevrons-up-down.svg +1 -0
- package/dist/assets/Icons/chevrons-up.svg +1 -0
- package/dist/assets/Icons/circle-arrow-down.svg +1 -0
- package/dist/assets/Icons/circle-arrow-left.svg +1 -0
- package/dist/assets/Icons/circle-arrow-out-down-left.svg +1 -0
- package/dist/assets/Icons/circle-arrow-out-down-right.svg +1 -0
- package/dist/assets/Icons/circle-arrow-out-up-left.svg +1 -0
- package/dist/assets/Icons/circle-arrow-out-up-right.svg +1 -0
- package/dist/assets/Icons/circle-arrow-right.svg +1 -0
- package/dist/assets/Icons/circle-arrow-up.svg +1 -0
- package/dist/assets/Icons/circle-check-big.svg +1 -0
- package/dist/assets/Icons/circle-check.svg +1 -0
- package/dist/assets/Icons/circle-chevron-down.svg +1 -0
- package/dist/assets/Icons/circle-chevron-left.svg +1 -0
- package/dist/assets/Icons/circle-chevron-right.svg +1 -0
- package/dist/assets/Icons/circle-chevron-up.svg +1 -0
- package/dist/assets/Icons/circle-dashed.svg +1 -0
- package/dist/assets/Icons/circle-dot-dashed.svg +1 -0
- package/dist/assets/Icons/circle-ellipsis.svg +1 -0
- package/dist/assets/Icons/circle-gauge.svg +1 -0
- package/dist/assets/Icons/circle-help.svg +1 -0
- package/dist/assets/Icons/circle-minus.svg +1 -0
- package/dist/assets/Icons/circle-plus.svg +1 -0
- package/dist/assets/Icons/circle-slash.svg +1 -0
- package/dist/assets/Icons/circle-slash2.svg +1 -0
- package/dist/assets/Icons/circle-stop.svg +1 -0
- package/dist/assets/Icons/circle.svg +1 -0
- package/dist/assets/Icons/clipboard-check.svg +1 -0
- package/dist/assets/Icons/clipboard-x.svg +1 -0
- package/dist/assets/Icons/clipboard.svg +1 -0
- package/dist/assets/Icons/clock-fading.svg +1 -0
- package/dist/assets/Icons/clock.svg +1 -0
- package/dist/assets/Icons/cloud-off.svg +1 -0
- package/dist/assets/Icons/cloud-sync.svg +1 -0
- package/dist/assets/Icons/cloud.svg +1 -0
- package/dist/assets/Icons/code.svg +1 -0
- package/dist/assets/Icons/collapse.svg +1 -0
- package/dist/assets/Icons/columns3-cog.svg +1 -0
- package/dist/assets/Icons/command.svg +1 -0
- package/dist/assets/Icons/compass.svg +1 -0
- package/dist/assets/Icons/copy-slash.svg +1 -0
- package/dist/assets/Icons/copy.svg +1 -0
- package/dist/assets/Icons/corner-down-left.svg +1 -0
- package/dist/assets/Icons/corner-down-right.svg +1 -0
- package/dist/assets/Icons/corner-left-down.svg +1 -0
- package/dist/assets/Icons/corner-left-up.svg +1 -0
- package/dist/assets/Icons/corner-right-down.svg +1 -0
- package/dist/assets/Icons/corner-right-up.svg +1 -0
- package/dist/assets/Icons/corner-up-left.svg +1 -0
- package/dist/assets/Icons/corner-up-right.svg +1 -0
- package/dist/assets/Icons/cpu.svg +1 -0
- package/dist/assets/Icons/credit-card.svg +1 -0
- package/dist/assets/Icons/crosshair.svg +1 -0
- package/dist/assets/Icons/ctrl.svg +1 -0
- package/dist/assets/Icons/database.svg +1 -0
- package/dist/assets/Icons/devianart-neutral.svg +1 -0
- package/dist/assets/Icons/devianart.svg +1 -0
- package/dist/assets/Icons/discord-neutral.svg +1 -0
- package/dist/assets/Icons/discord.svg +1 -0
- package/dist/assets/Icons/dollar-sign.svg +1 -0
- package/dist/assets/Icons/dot.svg +1 -0
- package/dist/assets/Icons/download.svg +1 -0
- package/dist/assets/Icons/dprofile-neutral.svg +1 -0
- package/dist/assets/Icons/dprofile.svg +1 -0
- package/dist/assets/Icons/dribbble-neutral.svg +1 -0
- package/dist/assets/Icons/dribbble.svg +1 -0
- package/dist/assets/Icons/droplet.svg +1 -0
- package/dist/assets/Icons/duplicate.svg +1 -0
- package/dist/assets/Icons/dzen-neutral.svg +1 -0
- package/dist/assets/Icons/dzen.svg +1 -0
- package/dist/assets/Icons/earth-lock.svg +1 -0
- package/dist/assets/Icons/earth.svg +1 -0
- package/dist/assets/Icons/ellipsis.svg +1 -0
- package/dist/assets/Icons/expand.svg +1 -0
- package/dist/assets/Icons/external-link.svg +1 -0
- package/dist/assets/Icons/eye-off.svg +1 -0
- package/dist/assets/Icons/eye.svg +1 -0
- package/dist/assets/Icons/facebook-neutral.svg +1 -0
- package/dist/assets/Icons/facebook.svg +1 -0
- package/dist/assets/Icons/fast-forward.svg +1 -0
- package/dist/assets/Icons/figma-neutral.svg +1 -0
- package/dist/assets/Icons/figma.svg +1 -0
- package/dist/assets/Icons/file-down.svg +1 -0
- package/dist/assets/Icons/file-plus.svg +1 -0
- package/dist/assets/Icons/file-text.svg +1 -0
- package/dist/assets/Icons/file.svg +1 -0
- package/dist/assets/Icons/film.svg +1 -0
- package/dist/assets/Icons/filter-x.svg +1 -0
- package/dist/assets/Icons/filter.svg +1 -0
- package/dist/assets/Icons/fingerprint-pattern.svg +1 -0
- package/dist/assets/Icons/flag.svg +1 -0
- package/dist/assets/Icons/flask-conical.svg +1 -0
- package/dist/assets/Icons/folder.svg +1 -0
- package/dist/assets/Icons/funnel-plus.svg +1 -0
- package/dist/assets/Icons/ghost.svg +1 -0
- package/dist/assets/Icons/git-branch.svg +1 -0
- package/dist/assets/Icons/git-commit-horizontal.svg +1 -0
- package/dist/assets/Icons/git-commit-vertical.svg +1 -0
- package/dist/assets/Icons/git-compare-arrows-alt.svg +1 -0
- package/dist/assets/Icons/git-compare-arrows.svg +1 -0
- package/dist/assets/Icons/git-compare.svg +1 -0
- package/dist/assets/Icons/git-fork.svg +1 -0
- package/dist/assets/Icons/git-graph.svg +1 -0
- package/dist/assets/Icons/git-merge.svg +1 -0
- package/dist/assets/Icons/git-pull-request-arrow.svg +1 -0
- package/dist/assets/Icons/git-pull-request-closed.svg +1 -0
- package/dist/assets/Icons/git-pull-request-create-arrow.svg +1 -0
- package/dist/assets/Icons/git-pull-request-create.svg +1 -0
- package/dist/assets/Icons/git-pull-request-draft.svg +1 -0
- package/dist/assets/Icons/git-pull-request.svg +1 -0
- package/dist/assets/Icons/github-neutral.svg +1 -0
- package/dist/assets/Icons/github.svg +1 -0
- package/dist/assets/Icons/globe-lock.svg +1 -0
- package/dist/assets/Icons/globe.svg +1 -0
- package/dist/assets/Icons/gmail-neutral.svg +1 -0
- package/dist/assets/Icons/gmail.svg +1 -0
- package/dist/assets/Icons/google-meet-neutral.svg +1 -0
- package/dist/assets/Icons/google-meet.svg +1 -0
- package/dist/assets/Icons/google-neutral.svg +1 -0
- package/dist/assets/Icons/google-play-neutral.svg +1 -0
- package/dist/assets/Icons/google-play.svg +1 -0
- package/dist/assets/Icons/google-podcast-neutral.svg +1 -0
- package/dist/assets/Icons/google-podcast.svg +1 -0
- package/dist/assets/Icons/google.svg +1 -0
- package/dist/assets/Icons/grip-horizontal.svg +1 -0
- package/dist/assets/Icons/grip-vertical.svg +1 -0
- package/dist/assets/Icons/hammer.svg +1 -0
- package/dist/assets/Icons/heart-pulse.svg +1 -0
- package/dist/assets/Icons/history.svg +1 -0
- package/dist/assets/Icons/home.svg +1 -0
- package/dist/assets/Icons/imo-neutral.svg +1 -0
- package/dist/assets/Icons/imo.svg +1 -0
- package/dist/assets/Icons/infinity.svg +1 -0
- package/dist/assets/Icons/info.svg +1 -0
- package/dist/assets/Icons/infobip-neutral.svg +1 -0
- package/dist/assets/Icons/infobip.svg +1 -0
- package/dist/assets/Icons/instagram-neutral.svg +1 -0
- package/dist/assets/Icons/instagram.svg +1 -0
- package/dist/assets/Icons/key-round.svg +1 -0
- package/dist/assets/Icons/key-square.svg +1 -0
- package/dist/assets/Icons/keyboard.svg +1 -0
- package/dist/assets/Icons/kickstarter-neutral.svg +1 -0
- package/dist/assets/Icons/kickstarter.svg +1 -0
- package/dist/assets/Icons/laptop.svg +1 -0
- package/dist/assets/Icons/layers.svg +1 -0
- package/dist/assets/Icons/layers2.svg +1 -0
- package/dist/assets/Icons/layers3.svg +1 -0
- package/dist/assets/Icons/layout-dashboard.svg +1 -0
- package/dist/assets/Icons/layout-grid.svg +1 -0
- package/dist/assets/Icons/layout-list.svg +1 -0
- package/dist/assets/Icons/layout-panel-left.svg +1 -0
- package/dist/assets/Icons/layout-panel-top.svg +1 -0
- package/dist/assets/Icons/layout-template.svg +1 -0
- package/dist/assets/Icons/library-big.svg +1 -0
- package/dist/assets/Icons/life-buoy.svg +1 -0
- package/dist/assets/Icons/lightbulb-off.svg +1 -0
- package/dist/assets/Icons/lightbulb.svg +1 -0
- package/dist/assets/Icons/line-neutral.svg +1 -0
- package/dist/assets/Icons/line.svg +1 -0
- package/dist/assets/Icons/link.svg +1 -0
- package/dist/assets/Icons/link2-off.svg +1 -0
- package/dist/assets/Icons/link2.svg +1 -0
- package/dist/assets/Icons/linkedin-neutral.svg +1 -0
- package/dist/assets/Icons/linkedin.svg +1 -0
- package/dist/assets/Icons/list-checks.svg +1 -0
- package/dist/assets/Icons/list-collapse.svg +1 -0
- package/dist/assets/Icons/list-filter.svg +1 -0
- package/dist/assets/Icons/list-minus.svg +1 -0
- package/dist/assets/Icons/list-ordered.svg +1 -0
- package/dist/assets/Icons/list-plus.svg +1 -0
- package/dist/assets/Icons/list-restart.svg +1 -0
- package/dist/assets/Icons/list-start.svg +1 -0
- package/dist/assets/Icons/list-todo.svg +1 -0
- package/dist/assets/Icons/list-tree.svg +1 -0
- package/dist/assets/Icons/list-video.svg +1 -0
- package/dist/assets/Icons/list-x.svg +1 -0
- package/dist/assets/Icons/list.svg +1 -0
- package/dist/assets/Icons/loader-circle.svg +1 -0
- package/dist/assets/Icons/loader.svg +1 -0
- package/dist/assets/Icons/lock-open.svg +1 -0
- package/dist/assets/Icons/lock.svg +1 -0
- package/dist/assets/Icons/log-in.svg +1 -0
- package/dist/assets/Icons/log-out.svg +1 -0
- package/dist/assets/Icons/mail-check.svg +1 -0
- package/dist/assets/Icons/mail-minus.svg +1 -0
- package/dist/assets/Icons/mail-open.svg +1 -0
- package/dist/assets/Icons/mail-plus.svg +1 -0
- package/dist/assets/Icons/mail-question.svg +1 -0
- package/dist/assets/Icons/mail-search.svg +1 -0
- package/dist/assets/Icons/mail-warning.svg +1 -0
- package/dist/assets/Icons/mail-x.svg +1 -0
- package/dist/assets/Icons/mail.svg +1 -0
- package/dist/assets/Icons/mailbox.svg +1 -0
- package/dist/assets/Icons/mailersend-neutral.svg +1 -0
- package/dist/assets/Icons/mailersend.svg +1 -0
- package/dist/assets/Icons/mailgun-neutral.svg +1 -0
- package/dist/assets/Icons/mailgun.svg +1 -0
- package/dist/assets/Icons/mailjet-neutral.svg +1 -0
- package/dist/assets/Icons/mailjet.svg +1 -0
- package/dist/assets/Icons/mails.svg +1 -0
- package/dist/assets/Icons/mailtrap-neutral.svg +1 -0
- package/dist/assets/Icons/mailtrap.svg +1 -0
- package/dist/assets/Icons/mandrill-neutral.svg +1 -0
- package/dist/assets/Icons/mandrill.svg +1 -0
- package/dist/assets/Icons/map-pin.svg +1 -0
- package/dist/assets/Icons/maqsam-neutral.svg +1 -0
- package/dist/assets/Icons/maqsam.svg +1 -0
- package/dist/assets/Icons/mattermost-neutral.svg +1 -0
- package/dist/assets/Icons/mattermost.svg +1 -0
- package/dist/assets/Icons/maximize.svg +1 -0
- package/dist/assets/Icons/maximize2.svg +1 -0
- package/dist/assets/Icons/medium-neutral.svg +1 -0
- package/dist/assets/Icons/medium.svg +1 -0
- package/dist/assets/Icons/megaphone-off.svg +1 -0
- package/dist/assets/Icons/megaphone.svg +1 -0
- package/dist/assets/Icons/menu.svg +1 -0
- package/dist/assets/Icons/merge.svg +1 -0
- package/dist/assets/Icons/message-square-code.svg +1 -0
- package/dist/assets/Icons/message-square-dashed.svg +1 -0
- package/dist/assets/Icons/message-square-diff.svg +1 -0
- package/dist/assets/Icons/message-square-dot.svg +1 -0
- package/dist/assets/Icons/message-square-heart.svg +1 -0
- package/dist/assets/Icons/message-square-off.svg +1 -0
- package/dist/assets/Icons/message-square-plus.svg +1 -0
- package/dist/assets/Icons/message-square-quote.svg +1 -0
- package/dist/assets/Icons/message-square-reply.svg +1 -0
- package/dist/assets/Icons/message-square-share.svg +1 -0
- package/dist/assets/Icons/message-square-text.svg +1 -0
- package/dist/assets/Icons/message-square-warning.svg +1 -0
- package/dist/assets/Icons/message-square-x.svg +1 -0
- package/dist/assets/Icons/message-square.svg +1 -0
- package/dist/assets/Icons/messages-square.svg +1 -0
- package/dist/assets/Icons/messenger-neutral.svg +1 -0
- package/dist/assets/Icons/messenger.svg +1 -0
- package/dist/assets/Icons/microsoft-neutral.svg +1 -0
- package/dist/assets/Icons/microsoft-teams-neutral.svg +1 -0
- package/dist/assets/Icons/microsoft-teams.svg +1 -0
- package/dist/assets/Icons/microsoft.svg +1 -0
- package/dist/assets/Icons/minimize.svg +1 -0
- package/dist/assets/Icons/minimize2.svg +1 -0
- package/dist/assets/Icons/minus.svg +1 -0
- package/dist/assets/Icons/moon.svg +1 -0
- package/dist/assets/Icons/mouse-pointer-ban.svg +1 -0
- package/dist/assets/Icons/mouse-pointer-click.svg +1 -0
- package/dist/assets/Icons/mouse-pointer.svg +1 -0
- package/dist/assets/Icons/mouse-pointer2.svg +1 -0
- package/dist/assets/Icons/mouse.svg +1 -0
- package/dist/assets/Icons/move-diagonal.svg +1 -0
- package/dist/assets/Icons/move-diagonal2.svg +1 -0
- package/dist/assets/Icons/move-down-left.svg +1 -0
- package/dist/assets/Icons/move-down-right.svg +1 -0
- package/dist/assets/Icons/move-down.svg +1 -0
- package/dist/assets/Icons/move-horizontal.svg +1 -0
- package/dist/assets/Icons/move-left.svg +1 -0
- package/dist/assets/Icons/move-right.svg +1 -0
- package/dist/assets/Icons/move-up-left.svg +1 -0
- package/dist/assets/Icons/move-up-right.svg +1 -0
- package/dist/assets/Icons/move-up.svg +1 -0
- package/dist/assets/Icons/move-vertical.svg +1 -0
- package/dist/assets/Icons/move3-d.svg +1 -0
- package/dist/assets/Icons/netcore-neutral.svg +1 -0
- package/dist/assets/Icons/netcore.svg +1 -0
- package/dist/assets/Icons/notebook-pen.svg +1 -0
- package/dist/assets/Icons/notebook-tabs.svg +1 -0
- package/dist/assets/Icons/notebook-text.svg +1 -0
- package/dist/assets/Icons/notebook.svg +1 -0
- package/dist/assets/Icons/notepad-text-dashed.svg +1 -0
- package/dist/assets/Icons/notepad-text.svg +1 -0
- package/dist/assets/Icons/notion-neutral.svg +1 -0
- package/dist/assets/Icons/notion.svg +1 -0
- package/dist/assets/Icons/octagon-alert.svg +1 -0
- package/dist/assets/Icons/octagon-pause.svg +1 -0
- package/dist/assets/Icons/octagon-x.svg +1 -0
- package/dist/assets/Icons/octagon.svg +1 -0
- package/dist/assets/Icons/ok-neutral.svg +1 -0
- package/dist/assets/Icons/ok-only-sign-neutral.svg +1 -0
- package/dist/assets/Icons/ok-only-sign.svg +1 -0
- package/dist/assets/Icons/ok.svg +1 -0
- package/dist/assets/Icons/onlyfans-neutral.svg +1 -0
- package/dist/assets/Icons/onlyfans.svg +1 -0
- package/dist/assets/Icons/option.svg +1 -0
- package/dist/assets/Icons/outlook365-neutral.svg +1 -0
- package/dist/assets/Icons/outlook365.svg +1 -0
- package/dist/assets/Icons/paintbrush.svg +1 -0
- package/dist/assets/Icons/panel-bottom-open.svg +1 -0
- package/dist/assets/Icons/panel-bottom.svg +1 -0
- package/dist/assets/Icons/panel-left-close.svg +1 -0
- package/dist/assets/Icons/panel-left-dashed.svg +1 -0
- package/dist/assets/Icons/panel-left-open.svg +1 -0
- package/dist/assets/Icons/panel-left.svg +1 -0
- package/dist/assets/Icons/panel-right-animated.svg +1 -0
- package/dist/assets/Icons/panel-right-close.svg +1 -0
- package/dist/assets/Icons/panel-right-dashed.svg +1 -0
- package/dist/assets/Icons/panel-right-open.svg +1 -0
- package/dist/assets/Icons/panel-right.svg +1 -0
- package/dist/assets/Icons/panel-top-bottom-dashed.svg +1 -0
- package/dist/assets/Icons/panel-top-close.svg +1 -0
- package/dist/assets/Icons/panel-top-open.svg +1 -0
- package/dist/assets/Icons/panel-top.svg +1 -0
- package/dist/assets/Icons/panels-left-bottom.svg +1 -0
- package/dist/assets/Icons/panels-right-bottom.svg +1 -0
- package/dist/assets/Icons/panels-top-left.svg +1 -0
- package/dist/assets/Icons/paperclip.svg +1 -0
- package/dist/assets/Icons/patreon-neutral.svg +1 -0
- package/dist/assets/Icons/patreon.svg +1 -0
- package/dist/assets/Icons/paypal-neutral.svg +1 -0
- package/dist/assets/Icons/paypal.svg +1 -0
- package/dist/assets/Icons/pen-line.svg +1 -0
- package/dist/assets/Icons/pen-off.svg +1 -0
- package/dist/assets/Icons/pen-tool.svg +1 -0
- package/dist/assets/Icons/pen.svg +1 -0
- package/dist/assets/Icons/pencil-line.svg +1 -0
- package/dist/assets/Icons/pencil-off.svg +1 -0
- package/dist/assets/Icons/pencil-ruler.svg +1 -0
- package/dist/assets/Icons/pencil.svg +1 -0
- package/dist/assets/Icons/pentagon.svg +1 -0
- package/dist/assets/Icons/percentage.svg +1 -0
- package/dist/assets/Icons/phone.svg +1 -0
- package/dist/assets/Icons/pin-off.svg +1 -0
- package/dist/assets/Icons/pin.svg +1 -0
- package/dist/assets/Icons/pinterest-neutral.svg +1 -0
- package/dist/assets/Icons/pinterest.svg +1 -0
- package/dist/assets/Icons/play.svg +1 -0
- package/dist/assets/Icons/plug.svg +1 -0
- package/dist/assets/Icons/plunk-neutral.svg +1 -0
- package/dist/assets/Icons/plunk.svg +1 -0
- package/dist/assets/Icons/plus.svg +1 -0
- package/dist/assets/Icons/postmark-neutral.svg +1 -0
- package/dist/assets/Icons/postmark.svg +1 -0
- package/dist/assets/Icons/product-hunt-neutral.svg +1 -0
- package/dist/assets/Icons/product-hunt.svg +1 -0
- package/dist/assets/Icons/quora-neutral.svg +1 -0
- package/dist/assets/Icons/quora.svg +1 -0
- package/dist/assets/Icons/quote.svg +1 -0
- package/dist/assets/Icons/radar.svg +1 -0
- package/dist/assets/Icons/reddit-neutral.svg +1 -0
- package/dist/assets/Icons/reddit.svg +1 -0
- package/dist/assets/Icons/redo-dot.svg +1 -0
- package/dist/assets/Icons/redo.svg +1 -0
- package/dist/assets/Icons/redo2.svg +1 -0
- package/dist/assets/Icons/refresh-ccw.svg +1 -0
- package/dist/assets/Icons/refresh-cw-off.svg +1 -0
- package/dist/assets/Icons/resend-neutral.svg +1 -0
- package/dist/assets/Icons/resend.svg +1 -0
- package/dist/assets/Icons/rss.svg +1 -0
- package/dist/assets/Icons/rules.svg +1 -0
- package/dist/assets/Icons/scan-line.svg +1 -0
- package/dist/assets/Icons/scroll-text.svg +1 -0
- package/dist/assets/Icons/search-hint.svg +1 -0
- package/dist/assets/Icons/search-x.svg +1 -0
- package/dist/assets/Icons/search.svg +1 -0
- package/dist/assets/Icons/sendgrid-neutral.svg +1 -0
- package/dist/assets/Icons/sendgrid.svg +1 -0
- package/dist/assets/Icons/server.svg +1 -0
- package/dist/assets/Icons/ses-neutral.svg +1 -0
- package/dist/assets/Icons/ses.svg +1 -0
- package/dist/assets/Icons/settings.svg +1 -0
- package/dist/assets/Icons/settings2.svg +1 -0
- package/dist/assets/Icons/shapes.svg +1 -0
- package/dist/assets/Icons/share.svg +1 -0
- package/dist/assets/Icons/sheet.svg +1 -0
- package/dist/assets/Icons/shield-alert.svg +1 -0
- package/dist/assets/Icons/shield-ban.svg +1 -0
- package/dist/assets/Icons/shield-check.svg +1 -0
- package/dist/assets/Icons/shield-ellipsis.svg +1 -0
- package/dist/assets/Icons/shield-half.svg +1 -0
- package/dist/assets/Icons/shield-minus.svg +1 -0
- package/dist/assets/Icons/shield-off.svg +1 -0
- package/dist/assets/Icons/shield-plus.svg +1 -0
- package/dist/assets/Icons/shield-question.svg +1 -0
- package/dist/assets/Icons/shield-user.svg +1 -0
- package/dist/assets/Icons/shield-x.svg +1 -0
- package/dist/assets/Icons/shield.svg +1 -0
- package/dist/assets/Icons/shift.svg +1 -0
- package/dist/assets/Icons/signal-neutral.svg +1 -0
- package/dist/assets/Icons/signal.svg +1 -0
- package/dist/assets/Icons/signpost.svg +1 -0
- package/dist/assets/Icons/sina-weibo-neutral.svg +1 -0
- package/dist/assets/Icons/sina-weibo.svg +1 -0
- package/dist/assets/Icons/skull.svg +1 -0
- package/dist/assets/Icons/skype-neutral.svg +1 -0
- package/dist/assets/Icons/skype.svg +1 -0
- package/dist/assets/Icons/slack-neutral.svg +1 -0
- package/dist/assets/Icons/slack.svg +1 -0
- package/dist/assets/Icons/sliders-horizontal.svg +1 -0
- package/dist/assets/Icons/sliders-vertical.svg +1 -0
- package/dist/assets/Icons/smtp-neutral.svg +1 -0
- package/dist/assets/Icons/smtp.svg +1 -0
- package/dist/assets/Icons/snapchat-neutral.svg +1 -0
- package/dist/assets/Icons/snapchat.svg +1 -0
- package/dist/assets/Icons/soundcloud-neutral.svg +1 -0
- package/dist/assets/Icons/soundcloud.svg +1 -0
- package/dist/assets/Icons/space.svg +1 -0
- package/dist/assets/Icons/sparkpost-neutral.svg +1 -0
- package/dist/assets/Icons/sparkpost.svg +1 -0
- package/dist/assets/Icons/speed.svg +1 -0
- package/dist/assets/Icons/split.svg +1 -0
- package/dist/assets/Icons/spotify-neutral.svg +1 -0
- package/dist/assets/Icons/spotify.svg +1 -0
- package/dist/assets/Icons/square-arrow-out-up-right.svg +1 -0
- package/dist/assets/Icons/square-minus.svg +1 -0
- package/dist/assets/Icons/square-pen.svg +1 -0
- package/dist/assets/Icons/square-plus.svg +1 -0
- package/dist/assets/Icons/square-user-round.svg +1 -0
- package/dist/assets/Icons/stack-overflow-neutral.svg +1 -0
- package/dist/assets/Icons/stack-overflow.svg +1 -0
- package/dist/assets/Icons/star.svg +1 -0
- package/dist/assets/Icons/sun.svg +1 -0
- package/dist/assets/Icons/syringe.svg +1 -0
- package/dist/assets/Icons/tag.svg +1 -0
- package/dist/assets/Icons/target.svg +1 -0
- package/dist/assets/Icons/teams-neutral.svg +1 -0
- package/dist/assets/Icons/teams.svg +1 -0
- package/dist/assets/Icons/telegram-neutral.svg +1 -0
- package/dist/assets/Icons/telegram-only-sign-neutral.svg +1 -0
- package/dist/assets/Icons/telegram-only-sign.svg +1 -0
- package/dist/assets/Icons/telegram.svg +1 -0
- package/dist/assets/Icons/terminal.svg +1 -0
- package/dist/assets/Icons/test-policies.svg +1 -0
- package/dist/assets/Icons/threads-neutral.svg +1 -0
- package/dist/assets/Icons/threads.svg +1 -0
- package/dist/assets/Icons/thumbs-down.svg +1 -0
- package/dist/assets/Icons/thumbs-up.svg +1 -0
- package/dist/assets/Icons/tiktok-neutral.svg +1 -0
- package/dist/assets/Icons/tiktok.svg +1 -0
- package/dist/assets/Icons/tinder-neutral.svg +1 -0
- package/dist/assets/Icons/tinder.svg +1 -0
- package/dist/assets/Icons/trash.svg +1 -0
- package/dist/assets/Icons/trash2.svg +1 -0
- package/dist/assets/Icons/triangle-alert.svg +1 -0
- package/dist/assets/Icons/tumblr-neutral.svg +1 -0
- package/dist/assets/Icons/tumblr.svg +1 -0
- package/dist/assets/Icons/twitch-neutral.svg +1 -0
- package/dist/assets/Icons/twitch.svg +1 -0
- package/dist/assets/Icons/undo-dot.svg +1 -0
- package/dist/assets/Icons/undo.svg +1 -0
- package/dist/assets/Icons/undo2.svg +1 -0
- package/dist/assets/Icons/unplug.svg +1 -0
- package/dist/assets/Icons/user-check.svg +1 -0
- package/dist/assets/Icons/user-minus.svg +1 -0
- package/dist/assets/Icons/user-plus.svg +1 -0
- package/dist/assets/Icons/user-round-x.svg +1 -0
- package/dist/assets/Icons/user-round.svg +1 -0
- package/dist/assets/Icons/user.svg +1 -0
- package/dist/assets/Icons/username.svg +1 -0
- package/dist/assets/Icons/users-round.svg +1 -0
- package/dist/assets/Icons/users.svg +1 -0
- package/dist/assets/Icons/viber-neutral.svg +1 -0
- package/dist/assets/Icons/viber.svg +1 -0
- package/dist/assets/Icons/video.svg +1 -0
- package/dist/assets/Icons/vimeo-neutral.svg +1 -0
- package/dist/assets/Icons/vimeo.svg +1 -0
- package/dist/assets/Icons/vk-music-neutral.svg +1 -0
- package/dist/assets/Icons/vk-music.svg +1 -0
- package/dist/assets/Icons/vk-neutral.svg +1 -0
- package/dist/assets/Icons/vk-only-sign-neutral.svg +1 -0
- package/dist/assets/Icons/vk-only-sign.svg +1 -0
- package/dist/assets/Icons/vk.svg +1 -0
- package/dist/assets/Icons/webhook-neutral.svg +1 -0
- package/dist/assets/Icons/webhook.svg +1 -0
- package/dist/assets/Icons/wechat-neutral.svg +1 -0
- package/dist/assets/Icons/wechat.svg +1 -0
- package/dist/assets/Icons/whatsapp-badge-neutral.svg +1 -0
- package/dist/assets/Icons/whatsapp-badge.svg +1 -0
- package/dist/assets/Icons/whatsapp-neutral.svg +1 -0
- package/dist/assets/Icons/whatsapp.svg +1 -0
- package/dist/assets/Icons/workflow.svg +1 -0
- package/dist/assets/Icons/wrap-text.svg +1 -0
- package/dist/assets/Icons/wrench.svg +1 -0
- package/dist/assets/Icons/x-ex-twitter-neutral.svg +1 -0
- package/dist/assets/Icons/x-ex-twitter.svg +1 -0
- package/dist/assets/Icons/x.svg +1 -0
- package/dist/assets/Icons/xing-neutral.svg +1 -0
- package/dist/assets/Icons/xing.svg +1 -0
- package/dist/assets/Icons/yandex-music-neutral.svg +1 -0
- package/dist/assets/Icons/yandex-music.svg +1 -0
- package/dist/assets/Icons/yelp-neutral.svg +1 -0
- package/dist/assets/Icons/yelp.svg +1 -0
- package/dist/assets/Icons/youtube-music-neutral.svg +1 -0
- package/dist/assets/Icons/youtube-music.svg +1 -0
- package/dist/assets/Icons/youtube-neutral.svg +1 -0
- package/dist/assets/Icons/youtube-shorts-neutral.svg +1 -0
- package/dist/assets/Icons/youtube-shorts.svg +1 -0
- package/dist/assets/Icons/youtube.svg +1 -0
- package/dist/assets/Icons/zap.svg +1 -0
- package/dist/assets/Icons/zoom-in.svg +1 -0
- package/dist/assets/Icons/zoom-neutral.svg +1 -0
- package/dist/assets/Icons/zoom-out.svg +1 -0
- package/dist/assets/Icons/zoom.svg +1 -0
- package/dist/assets/Icons/zulip-neutral.svg +1 -0
- package/dist/assets/Icons/zulip.svg +1 -0
- package/dist/assets/Logos/wallarm.svg +1 -0
- package/dist/assets.json +4666 -0
- package/dist/project/README.md +265 -0
- package/dist/project/components/Accordion/README.md +354 -0
- package/dist/project/components/Accordion/preview.html +2 -0
- package/dist/project/components/Accordion/stories.js +12 -0
- package/dist/project/components/AgentAvatar/README.md +127 -0
- package/dist/project/components/AgentAvatar/preview.html +2 -0
- package/dist/project/components/AgentAvatar/stories.js +12 -0
- package/dist/project/components/AgentAvatar/stories.md +1805 -0
- package/dist/project/components/AgentGlyph/README.md +1182 -0
- package/dist/project/components/AgentGlyph/preview.html +2 -0
- package/dist/project/components/AgentGlyph/stories.js +12 -0
- package/dist/project/components/AgentLookBuilder/README.md +269 -0
- package/dist/project/components/AgentLookBuilder/preview.html +2 -0
- package/dist/project/components/AgentLookBuilder/stories.js +12 -0
- package/dist/project/components/Alert/README.md +492 -0
- package/dist/project/components/Alert/preview.html +2 -0
- package/dist/project/components/Alert/stories.js +12 -0
- package/dist/project/components/AnimatedBackground/README.md +124 -0
- package/dist/project/components/AnimatedBackground/preview.html +2 -0
- package/dist/project/components/AnimatedBackground/stories.js +12 -0
- package/dist/project/components/AppShell/README.md +75 -0
- package/dist/project/components/AppShell/preview.html +2 -0
- package/dist/project/components/AppShell/stories.js +28 -0
- package/dist/project/components/AppShell/stories.md +2878 -0
- package/dist/project/components/ApprovalCard/README.md +353 -0
- package/dist/project/components/ApprovalCard/preview.html +2 -0
- package/dist/project/components/ApprovalCard/stories.js +12 -0
- package/dist/project/components/AreaChart/README.md +673 -0
- package/dist/project/components/AreaChart/preview.html +2 -0
- package/dist/project/components/AreaChart/stories.js +12 -0
- package/dist/project/components/AssistantMessage/README.md +724 -0
- package/dist/project/components/AssistantMessage/preview.html +2 -0
- package/dist/project/components/AssistantMessage/stories.js +55 -0
- package/dist/project/components/Attribute/README.md +1174 -0
- package/dist/project/components/Attribute/preview.html +2 -0
- package/dist/project/components/Attribute/stories.js +12 -0
- package/dist/project/components/Avatar/README.md +416 -0
- package/dist/project/components/Avatar/preview.html +2 -0
- package/dist/project/components/Avatar/stories.js +12 -0
- package/dist/project/components/Badge/README.md +853 -0
- package/dist/project/components/Badge/preview.html +2 -0
- package/dist/project/components/Badge/stories.js +12 -0
- package/dist/project/components/Banner/README.md +341 -0
- package/dist/project/components/Banner/preview.html +2 -0
- package/dist/project/components/Banner/stories.js +12 -0
- package/dist/project/components/BarList/README.md +757 -0
- package/dist/project/components/BarList/preview.html +2 -0
- package/dist/project/components/BarList/stories.js +12 -0
- package/dist/project/components/Breadcrumbs/README.md +274 -0
- package/dist/project/components/Breadcrumbs/preview.html +2 -0
- package/dist/project/components/Breadcrumbs/stories.js +12 -0
- package/dist/project/components/BulkBar/README.md +19 -0
- package/dist/project/components/Button/README.md +467 -0
- package/dist/project/components/Button/preview.html +2 -0
- package/dist/project/components/Button/stories.js +12 -0
- package/dist/project/components/Calendar/README.md +654 -0
- package/dist/project/components/Calendar/preview.html +2 -0
- package/dist/project/components/Calendar/stories.js +12 -0
- package/dist/project/components/Card/README.md +367 -0
- package/dist/project/components/Card/preview.html +2 -0
- package/dist/project/components/Card/stories.js +12 -0
- package/dist/project/components/ChainOfThought/README.md +412 -0
- package/dist/project/components/ChainOfThought/preview.html +2 -0
- package/dist/project/components/ChainOfThought/stories.js +12 -0
- package/dist/project/components/Chart/README.md +224 -0
- package/dist/project/components/Chart/preview.html +2 -0
- package/dist/project/components/Chart/stories.js +12 -0
- package/dist/project/components/Chat/README.md +1244 -0
- package/dist/project/components/Chat/preview.html +2 -0
- package/dist/project/components/Chat/stories.js +12 -0
- package/dist/project/components/ChatInput/README.md +378 -0
- package/dist/project/components/ChatInput/preview.html +2 -0
- package/dist/project/components/ChatInput/stories.js +12 -0
- package/dist/project/components/ChatPermissions/README.md +218 -0
- package/dist/project/components/ChatPermissions/preview.html +2 -0
- package/dist/project/components/ChatPermissions/stories.js +12 -0
- package/dist/project/components/Checkbox/README.md +367 -0
- package/dist/project/components/Checkbox/preview.html +2 -0
- package/dist/project/components/Checkbox/stories.js +12 -0
- package/dist/project/components/Code/README.md +243 -0
- package/dist/project/components/Code/preview.html +2 -0
- package/dist/project/components/Code/stories.js +12 -0
- package/dist/project/components/CodeEditor/README.md +280 -0
- package/dist/project/components/CodeEditor/preview.html +2 -0
- package/dist/project/components/CodeEditor/stories.js +162 -0
- package/dist/project/components/CodeEditor/stories.md +1533 -0
- package/dist/project/components/CodeSnippet/README.md +1352 -0
- package/dist/project/components/CodeSnippet/preview.html +2 -0
- package/dist/project/components/CodeSnippet/stories.js +156 -0
- package/dist/project/components/ColumnChart/README.md +521 -0
- package/dist/project/components/ColumnChart/preview.html +2 -0
- package/dist/project/components/ColumnChart/stories.js +12 -0
- package/dist/project/components/Conversation/README.md +314 -0
- package/dist/project/components/Conversation/preview.html +2 -0
- package/dist/project/components/Conversation/stories.js +12 -0
- package/dist/project/components/Copyable/README.md +406 -0
- package/dist/project/components/Copyable/preview.html +2 -0
- package/dist/project/components/Copyable/stories.js +12 -0
- package/dist/project/components/Country/README.md +226 -0
- package/dist/project/components/Country/preview.html +2 -0
- package/dist/project/components/Country/stories.js +12 -0
- package/dist/project/components/Cover/preview.html +43 -0
- package/dist/project/components/DateInput/README.md +458 -0
- package/dist/project/components/DateInput/preview.html +2 -0
- package/dist/project/components/DateInput/stories.js +12 -0
- package/dist/project/components/DateRangeInput/README.md +412 -0
- package/dist/project/components/DateRangeInput/preview.html +2 -0
- package/dist/project/components/DateRangeInput/stories.js +12 -0
- package/dist/project/components/Dialog/README.md +1140 -0
- package/dist/project/components/Dialog/preview.html +2 -0
- package/dist/project/components/Dialog/stories.js +12 -0
- package/dist/project/components/Drawer/README.md +1163 -0
- package/dist/project/components/Drawer/preview.html +2 -0
- package/dist/project/components/Drawer/stories.js +12 -0
- package/dist/project/components/DropdownMenu/README.md +678 -0
- package/dist/project/components/DropdownMenu/preview.html +2 -0
- package/dist/project/components/DropdownMenu/stories.js +12 -0
- package/dist/project/components/EmptyState/README.md +276 -0
- package/dist/project/components/EmptyState/preview.html +2 -0
- package/dist/project/components/EmptyState/stories.js +12 -0
- package/dist/project/components/FeedbackPulse/README.md +125 -0
- package/dist/project/components/FeedbackPulse/preview.html +2 -0
- package/dist/project/components/FeedbackPulse/stories.js +12 -0
- package/dist/project/components/Field/README.md +676 -0
- package/dist/project/components/Field/preview.html +2 -0
- package/dist/project/components/Field/stories.js +12 -0
- package/dist/project/components/FileUpload/README.md +544 -0
- package/dist/project/components/FileUpload/preview.html +2 -0
- package/dist/project/components/FileUpload/stories.js +12 -0
- package/dist/project/components/FilterCascade/README.md +348 -0
- package/dist/project/components/FilterCascade/preview.html +2 -0
- package/dist/project/components/FilterCascade/stories.js +12 -0
- package/dist/project/components/FilterDropdown/README.md +542 -0
- package/dist/project/components/FilterDropdown/preview.html +2 -0
- package/dist/project/components/FilterDropdown/stories.js +12 -0
- package/dist/project/components/FilterInput/README.md +416 -0
- package/dist/project/components/FilterInput/preview.html +2 -0
- package/dist/project/components/FilterInput/stories.js +12 -0
- package/dist/project/components/FilterInput/stories.md +6682 -0
- package/dist/project/components/Flex/README.md +225 -0
- package/dist/project/components/Flex/preview.html +2 -0
- package/dist/project/components/Flex/stories.js +12 -0
- package/dist/project/components/FormatDateTime/README.md +315 -0
- package/dist/project/components/FormatDateTime/preview.html +2 -0
- package/dist/project/components/FormatDateTime/stories.js +12 -0
- package/dist/project/components/FormatNumber/README.md +317 -0
- package/dist/project/components/FormatNumber/preview.html +2 -0
- package/dist/project/components/FormatNumber/stories.js +12 -0
- package/dist/project/components/Heading/README.md +240 -0
- package/dist/project/components/Heading/preview.html +2 -0
- package/dist/project/components/Heading/stories.js +12 -0
- package/dist/project/components/HorizontalBarStack/README.md +281 -0
- package/dist/project/components/HorizontalBarStack/preview.html +2 -0
- package/dist/project/components/HorizontalBarStack/stories.js +12 -0
- package/dist/project/components/HttpMethod/README.md +137 -0
- package/dist/project/components/HttpMethod/preview.html +2 -0
- package/dist/project/components/HttpMethod/stories.js +12 -0
- package/dist/project/components/ImageCropper/README.md +332 -0
- package/dist/project/components/ImageCropper/preview.html +2 -0
- package/dist/project/components/ImageCropper/stories.js +33 -0
- package/dist/project/components/Indicator/README.md +110 -0
- package/dist/project/components/Indicator/preview.html +2 -0
- package/dist/project/components/Indicator/stories.js +12 -0
- package/dist/project/components/InlineEdit/README.md +977 -0
- package/dist/project/components/InlineEdit/preview.html +2 -0
- package/dist/project/components/InlineEdit/stories.js +12 -0
- package/dist/project/components/Input/README.md +187 -0
- package/dist/project/components/Input/preview.html +2 -0
- package/dist/project/components/Input/stories.js +12 -0
- package/dist/project/components/InputGroup/README.md +423 -0
- package/dist/project/components/InputGroup/preview.html +2 -0
- package/dist/project/components/InputGroup/stories.js +12 -0
- package/dist/project/components/InputOTP/README.md +201 -0
- package/dist/project/components/InputOTP/preview.html +2 -0
- package/dist/project/components/InputOTP/stories.js +12 -0
- package/dist/project/components/Ip/README.md +226 -0
- package/dist/project/components/Ip/preview.html +2 -0
- package/dist/project/components/Ip/stories.js +12 -0
- package/dist/project/components/Kbd/README.md +126 -0
- package/dist/project/components/Kbd/preview.html +2 -0
- package/dist/project/components/Kbd/stories.js +12 -0
- package/dist/project/components/LineChart/README.md +1479 -0
- package/dist/project/components/LineChart/preview.html +2 -0
- package/dist/project/components/LineChart/stories.js +12 -0
- package/dist/project/components/Link/README.md +160 -0
- package/dist/project/components/Link/preview.html +2 -0
- package/dist/project/components/Link/stories.js +12 -0
- package/dist/project/components/List/README.md +181 -0
- package/dist/project/components/List/preview.html +2 -0
- package/dist/project/components/List/stories.js +12 -0
- package/dist/project/components/Loader/README.md +145 -0
- package/dist/project/components/Loader/preview.html +2 -0
- package/dist/project/components/Loader/stories.js +12 -0
- package/dist/project/components/Logo/README.md +183 -0
- package/dist/project/components/Logo/preview.html +2 -0
- package/dist/project/components/Logo/stories.js +12 -0
- package/dist/project/components/MessageActions/README.md +255 -0
- package/dist/project/components/MessageActions/preview.html +2 -0
- package/dist/project/components/MessageActions/stories.js +12 -0
- package/dist/project/components/Metric/README.md +324 -0
- package/dist/project/components/Metric/preview.html +2 -0
- package/dist/project/components/Metric/stories.js +12 -0
- package/dist/project/components/NavRail/README.md +25 -0
- package/dist/project/components/NotificationCenter/README.md +91 -0
- package/dist/project/components/NotificationCenter/preview.html +2 -0
- package/dist/project/components/NotificationCenter/stories.js +12 -0
- package/dist/project/components/NotificationCenter/stories.md +2170 -0
- package/dist/project/components/NumberInput/README.md +177 -0
- package/dist/project/components/NumberInput/preview.html +2 -0
- package/dist/project/components/NumberInput/stories.js +12 -0
- package/dist/project/components/NumericBadge/README.md +252 -0
- package/dist/project/components/NumericBadge/preview.html +2 -0
- package/dist/project/components/NumericBadge/stories.js +12 -0
- package/dist/project/components/OverflowList/README.md +324 -0
- package/dist/project/components/OverflowList/preview.html +2 -0
- package/dist/project/components/OverflowList/stories.js +12 -0
- package/dist/project/components/OverflowTooltip/README.md +421 -0
- package/dist/project/components/OverflowTooltip/preview.html +2 -0
- package/dist/project/components/OverflowTooltip/stories.js +12 -0
- package/dist/project/components/Overlay/README.md +95 -0
- package/dist/project/components/Overlay/preview.html +2 -0
- package/dist/project/components/Overlay/stories.js +12 -0
- package/dist/project/components/Overview/README.md +445 -0
- package/dist/project/components/Overview/preview.html +2 -0
- package/dist/project/components/Overview/stories.js +12 -0
- package/dist/project/components/Page/README.md +215 -0
- package/dist/project/components/Page/preview.html +2 -0
- package/dist/project/components/Page/stories.js +12 -0
- package/dist/project/components/Pagination/README.md +302 -0
- package/dist/project/components/Pagination/preview.html +2 -0
- package/dist/project/components/Pagination/stories.js +12 -0
- package/dist/project/components/ParameterPath/README.md +284 -0
- package/dist/project/components/ParameterPath/preview.html +2 -0
- package/dist/project/components/ParameterPath/stories.js +12 -0
- package/dist/project/components/PasswordComplexity/README.md +171 -0
- package/dist/project/components/PasswordComplexity/preview.html +2 -0
- package/dist/project/components/PasswordComplexity/stories.js +12 -0
- package/dist/project/components/PasswordInput/README.md +254 -0
- package/dist/project/components/PasswordInput/preview.html +2 -0
- package/dist/project/components/PasswordInput/stories.js +12 -0
- package/dist/project/components/PieChart/README.md +895 -0
- package/dist/project/components/PieChart/preview.html +2 -0
- package/dist/project/components/PieChart/stories.js +12 -0
- package/dist/project/components/Popover/README.md +282 -0
- package/dist/project/components/Popover/preview.html +2 -0
- package/dist/project/components/Popover/stories.js +12 -0
- package/dist/project/components/Progress/README.md +174 -0
- package/dist/project/components/Progress/preview.html +2 -0
- package/dist/project/components/Progress/stories.js +12 -0
- package/dist/project/components/Radio/README.md +264 -0
- package/dist/project/components/Radio/preview.html +2 -0
- package/dist/project/components/Radio/stories.js +12 -0
- package/dist/project/components/RemoteShell/README.md +1758 -0
- package/dist/project/components/RemoteShell/preview.html +2 -0
- package/dist/project/components/RemoteShell/stories.js +28 -0
- package/dist/project/components/Resizable/README.md +290 -0
- package/dist/project/components/Resizable/preview.html +2 -0
- package/dist/project/components/Resizable/stories.js +12 -0
- package/dist/project/components/ResponseCode/README.md +205 -0
- package/dist/project/components/ResponseCode/preview.html +2 -0
- package/dist/project/components/ResponseCode/stories.js +12 -0
- package/dist/project/components/ScrollArea/README.md +234 -0
- package/dist/project/components/ScrollArea/preview.html +2 -0
- package/dist/project/components/ScrollArea/stories.js +12 -0
- package/dist/project/components/SearchInput/README.md +158 -0
- package/dist/project/components/SearchInput/preview.html +2 -0
- package/dist/project/components/SearchInput/stories.js +12 -0
- package/dist/project/components/SearchModal/README.md +836 -0
- package/dist/project/components/SearchModal/preview.html +2 -0
- package/dist/project/components/SearchModal/stories.js +12 -0
- package/dist/project/components/SegmentedControl/README.md +382 -0
- package/dist/project/components/SegmentedControl/preview.html +2 -0
- package/dist/project/components/SegmentedControl/stories.js +12 -0
- package/dist/project/components/SegmentedTabs/README.md +390 -0
- package/dist/project/components/SegmentedTabs/preview.html +2 -0
- package/dist/project/components/SegmentedTabs/stories.js +12 -0
- package/dist/project/components/Select/README.md +1311 -0
- package/dist/project/components/Select/preview.html +2 -0
- package/dist/project/components/Select/stories.js +12 -0
- package/dist/project/components/Selection/README.md +648 -0
- package/dist/project/components/Selection/preview.html +2 -0
- package/dist/project/components/Selection/stories.js +12 -0
- package/dist/project/components/Separator/README.md +77 -0
- package/dist/project/components/Separator/preview.html +2 -0
- package/dist/project/components/Separator/stories.js +12 -0
- package/dist/project/components/Skeleton/README.md +358 -0
- package/dist/project/components/Skeleton/preview.html +2 -0
- package/dist/project/components/Skeleton/stories.js +12 -0
- package/dist/project/components/Slider/README.md +433 -0
- package/dist/project/components/Slider/preview.html +2 -0
- package/dist/project/components/Slider/stories.js +12 -0
- package/dist/project/components/SplashScreen/README.md +192 -0
- package/dist/project/components/SplashScreen/preview.html +2 -0
- package/dist/project/components/SplashScreen/stories.js +12 -0
- package/dist/project/components/SplitButton/README.md +402 -0
- package/dist/project/components/SplitButton/preview.html +2 -0
- package/dist/project/components/SplitButton/stories.js +12 -0
- package/dist/project/components/Stack/README.md +269 -0
- package/dist/project/components/Stack/preview.html +2 -0
- package/dist/project/components/Stack/stories.js +12 -0
- package/dist/project/components/Stepper/README.md +682 -0
- package/dist/project/components/Stepper/preview.html +2 -0
- package/dist/project/components/Stepper/stories.js +12 -0
- package/dist/project/components/Switch/README.md +236 -0
- package/dist/project/components/Switch/preview.html +2 -0
- package/dist/project/components/Switch/stories.js +12 -0
- package/dist/project/components/Table/README.md +471 -0
- package/dist/project/components/Table/preview.html +2 -0
- package/dist/project/components/Table/stories.js +12 -0
- package/dist/project/components/Table/stories.md +3697 -0
- package/dist/project/components/Tabs/README.md +443 -0
- package/dist/project/components/Tabs/preview.html +2 -0
- package/dist/project/components/Tabs/stories.js +12 -0
- package/dist/project/components/Tag/README.md +324 -0
- package/dist/project/components/Tag/preview.html +2 -0
- package/dist/project/components/Tag/stories.js +12 -0
- package/dist/project/components/Text/README.md +200 -0
- package/dist/project/components/Text/preview.html +2 -0
- package/dist/project/components/Text/stories.js +12 -0
- package/dist/project/components/Textarea/README.md +273 -0
- package/dist/project/components/Textarea/preview.html +2 -0
- package/dist/project/components/Textarea/stories.js +12 -0
- package/dist/project/components/TimeInput/README.md +359 -0
- package/dist/project/components/TimeInput/preview.html +2 -0
- package/dist/project/components/TimeInput/stories.js +12 -0
- package/dist/project/components/Timeline/README.md +204 -0
- package/dist/project/components/Timeline/preview.html +2 -0
- package/dist/project/components/Timeline/stories.js +12 -0
- package/dist/project/components/Toast/README.md +718 -0
- package/dist/project/components/Toast/preview.html +2 -0
- package/dist/project/components/Toast/stories.js +12 -0
- package/dist/project/components/ToggleButton/README.md +292 -0
- package/dist/project/components/ToggleButton/preview.html +2 -0
- package/dist/project/components/ToggleButton/stories.js +12 -0
- package/dist/project/components/Tooltip/README.md +199 -0
- package/dist/project/components/Tooltip/preview.html +2 -0
- package/dist/project/components/Tooltip/stories.js +12 -0
- package/dist/project/components/TopHeader/README.md +21 -0
- package/dist/project/components/Tour/README.md +532 -0
- package/dist/project/components/Tour/preview.html +2 -0
- package/dist/project/components/Tour/stories.js +12 -0
- package/dist/project/components/Tree/README.md +450 -0
- package/dist/project/components/Tree/preview.html +2 -0
- package/dist/project/components/Tree/stories.js +12 -0
- package/dist/project/components/TreeView/README.md +531 -0
- package/dist/project/components/TreeView/preview.html +2 -0
- package/dist/project/components/TreeView/stories.js +12 -0
- package/dist/project/components/UTILITIES.md +647 -0
- package/dist/project/components/UserMessage/README.md +166 -0
- package/dist/project/components/UserMessage/preview.html +2 -0
- package/dist/project/components/UserMessage/stories.js +12 -0
- package/dist/project/components/UtilityPage/README.md +147 -0
- package/dist/project/components/UtilityPage/preview.html +2 -0
- package/dist/project/components/UtilityPage/stories.js +12 -0
- package/dist/project/components/WallyAssistant/README.md +1380 -0
- package/dist/project/components/WallyAssistant/preview.html +2 -0
- package/dist/project/components/WallyAssistant/stories.js +12 -0
- package/dist/project/components/WallyIcon/README.md +130 -0
- package/dist/project/components/WallyIcon/preview.html +2 -0
- package/dist/project/components/WallyIcon/stories.js +12 -0
- package/dist/project/components/WallySkeleton/README.md +96 -0
- package/dist/project/components/WallySkeleton/preview.html +2 -0
- package/dist/project/components/WallySkeleton/stories.js +12 -0
- package/dist/project/components/bundle.css +18734 -0
- package/dist/project/components/bundle.js +167 -0
- package/dist/project/components/index.d.ts +27226 -0
- package/dist/project/design-system.json +35 -0
- package/dist/project/fonts/Geist-Variable.woff2 +0 -0
- package/dist/project/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/project/fonts/GeistPixel-Square.woff2 +0 -0
- package/dist/project/sampler.dc.html +238 -0
- package/dist/project/tokens.css +1395 -0
- package/dist/project/tokens.json +5235 -0
- package/dist/wallarm-sampler.dc.html +239 -0
- package/package.json +67 -3
|
@@ -0,0 +1,3697 @@
|
|
|
1
|
+
# Table story sources
|
|
2
|
+
|
|
3
|
+
### Table/Table.stories.tsx
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import type { FC } from 'react';
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import { arrayMove } from '@dnd-kit/sortable';
|
|
9
|
+
import { fn } from 'storybook/test';
|
|
10
|
+
import type { Meta, StoryFn } from 'storybook-react-rsbuild';
|
|
11
|
+
import {
|
|
12
|
+
ChevronDown,
|
|
13
|
+
ChevronRight,
|
|
14
|
+
Copy,
|
|
15
|
+
Database,
|
|
16
|
+
Ellipsis,
|
|
17
|
+
Filter,
|
|
18
|
+
FilterX,
|
|
19
|
+
Lock,
|
|
20
|
+
Plus,
|
|
21
|
+
SearchX,
|
|
22
|
+
Shield,
|
|
23
|
+
Trash2,
|
|
24
|
+
} from '../../icons';
|
|
25
|
+
import { Attribute, AttributeLabel, AttributeValue } from '../Attribute';
|
|
26
|
+
import { Badge } from '../Badge';
|
|
27
|
+
import {
|
|
28
|
+
BulkBarSummaryClear,
|
|
29
|
+
BulkBarSummaryCount,
|
|
30
|
+
BulkBarSummarySelectAll,
|
|
31
|
+
BulkBarSummarySeparator,
|
|
32
|
+
} from '../BulkBar';
|
|
33
|
+
import { Button } from '../Button';
|
|
34
|
+
import { InlineCodeSnippet } from '../CodeSnippet';
|
|
35
|
+
import { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '../Drawer';
|
|
36
|
+
import {
|
|
37
|
+
DropdownMenu,
|
|
38
|
+
DropdownMenuContent,
|
|
39
|
+
DropdownMenuContextTrigger,
|
|
40
|
+
DropdownMenuItem,
|
|
41
|
+
DropdownMenuItemContent,
|
|
42
|
+
DropdownMenuItemDescription,
|
|
43
|
+
DropdownMenuItemIcon,
|
|
44
|
+
DropdownMenuItemText,
|
|
45
|
+
DropdownMenuSeparator,
|
|
46
|
+
DropdownMenuTrigger,
|
|
47
|
+
} from '../DropdownMenu';
|
|
48
|
+
import {
|
|
49
|
+
EmptyState,
|
|
50
|
+
EmptyStateActions,
|
|
51
|
+
EmptyStateDescription,
|
|
52
|
+
EmptyStateIllustration,
|
|
53
|
+
EmptyStateMessage,
|
|
54
|
+
EmptyStateTitle,
|
|
55
|
+
} from '../EmptyState';
|
|
56
|
+
import { HttpMethod } from '../HttpMethod';
|
|
57
|
+
import { OverflowList } from '../OverflowList';
|
|
58
|
+
import {
|
|
59
|
+
Pagination,
|
|
60
|
+
PaginationList,
|
|
61
|
+
PaginationNext,
|
|
62
|
+
PaginationPageSize,
|
|
63
|
+
PaginationPrevious,
|
|
64
|
+
useClientPagination,
|
|
65
|
+
} from '../Pagination';
|
|
66
|
+
import type { SelectDataItem } from '../Select';
|
|
67
|
+
import { HStack, VStack } from '../Stack';
|
|
68
|
+
import { Tag } from '../Tag';
|
|
69
|
+
import { Text } from '../Text';
|
|
70
|
+
import { EDITABLE_CELL_COLUMN_META, EditableSelectCell, EditableTextCell } from './EditableCell';
|
|
71
|
+
import {
|
|
72
|
+
createLargeGroupedData,
|
|
73
|
+
createLargeSecurityEvents,
|
|
74
|
+
fullFeaturedColumns,
|
|
75
|
+
groupedHeaderData,
|
|
76
|
+
headerColumnIds,
|
|
77
|
+
headerColumns,
|
|
78
|
+
multiplySecurityEvents,
|
|
79
|
+
renderSecurityPreviewContent,
|
|
80
|
+
renderSecurityPreviewHeader,
|
|
81
|
+
type SecurityEvent,
|
|
82
|
+
type SecurityHeaderEntry,
|
|
83
|
+
securityColumnHelper,
|
|
84
|
+
securityColumnIds,
|
|
85
|
+
securityColumns,
|
|
86
|
+
securityEvents,
|
|
87
|
+
useBidirectionalData,
|
|
88
|
+
useInfiniteData,
|
|
89
|
+
} from './mocks';
|
|
90
|
+
import { Table } from './Table';
|
|
91
|
+
import { TableActionBar, TableActionBarSelection } from './TableActionBar';
|
|
92
|
+
import {
|
|
93
|
+
TableColumnMenu,
|
|
94
|
+
TableColumnMenuHideItem,
|
|
95
|
+
TableColumnMenuMoveLeftItem,
|
|
96
|
+
TableColumnMenuMoveRightItem,
|
|
97
|
+
TableColumnMenuPinItem,
|
|
98
|
+
TableColumnMenuSortItem,
|
|
99
|
+
} from './TableColumnMenu';
|
|
100
|
+
import { TableEmptyState } from './TableEmptyState';
|
|
101
|
+
import {
|
|
102
|
+
TableScrollHandler,
|
|
103
|
+
TableScrollHandlerLeft,
|
|
104
|
+
TableScrollHandlerRight,
|
|
105
|
+
} from './TableScrollHandler';
|
|
106
|
+
import { TableSortTrigger } from './TableSortTrigger';
|
|
107
|
+
import type {
|
|
108
|
+
TableColumnDef,
|
|
109
|
+
TableColumnGroup,
|
|
110
|
+
TableColumnPinningState,
|
|
111
|
+
TableColumnSizingState,
|
|
112
|
+
TableExpandedState,
|
|
113
|
+
TableHandle,
|
|
114
|
+
TableRowReorderEvent,
|
|
115
|
+
TableRowSelectionState,
|
|
116
|
+
TableSortingState,
|
|
117
|
+
TableVisibilityState,
|
|
118
|
+
} from './types';
|
|
119
|
+
|
|
120
|
+
const onDuplicate = fn().mockName('onDuplicate');
|
|
121
|
+
const onDelete = fn().mockName('onDelete');
|
|
122
|
+
const onCopyValue = fn().mockName('onCopyValue');
|
|
123
|
+
const onShowOnly = fn().mockName('onShowOnly');
|
|
124
|
+
const onExclude = fn().mockName('onExclude');
|
|
125
|
+
const onExecute = fn().mockName('onExecute');
|
|
126
|
+
|
|
127
|
+
const DESCRIPTION = [
|
|
128
|
+
'The grid for many records of one shape — reach for a `Card` grid when a row reads better as an object than as a set of columns, and for `Attribute` when you are showing one record rather than many.',
|
|
129
|
+
'Everything interactive is controlled: sorting, selection, expansion, column order, pinning, sizing and visibility are each a state prop with a callback, so nothing moves unless you move it, and `manualSorting` hands the sort to the server.',
|
|
130
|
+
'Filtering belongs above the grid in a `FilterInput`, and a long collection scrolls rather than paginates — `Pagination` is the deliberate exception, for when a page number is part of the task.',
|
|
131
|
+
].join(' ');
|
|
132
|
+
|
|
133
|
+
const meta = {
|
|
134
|
+
title: 'Data Display/Table',
|
|
135
|
+
component: Table,
|
|
136
|
+
parameters: {
|
|
137
|
+
layout: 'padded',
|
|
138
|
+
docs: { description: { component: DESCRIPTION } },
|
|
139
|
+
},
|
|
140
|
+
tags: ['beta'],
|
|
141
|
+
} satisfies Meta<typeof Table>;
|
|
142
|
+
|
|
143
|
+
export default meta;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The minimum — `data`, `columns`, `getRowId` and a column to open sorted on. The header stays
|
|
147
|
+
* put as the body scrolls.
|
|
148
|
+
*/
|
|
149
|
+
export const Basic: StoryFn<typeof meta> = () => {
|
|
150
|
+
interface Endpoint {
|
|
151
|
+
id: string;
|
|
152
|
+
path: string;
|
|
153
|
+
method: string;
|
|
154
|
+
requests: number;
|
|
155
|
+
status: 'Active' | 'Deprecated';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const endpoints: Endpoint[] = [
|
|
159
|
+
{ id: '1', path: '/api/v1/users', method: 'GET', requests: 12840, status: 'Active' },
|
|
160
|
+
{ id: '2', path: '/api/v1/users/{id}', method: 'PATCH', requests: 3120, status: 'Active' },
|
|
161
|
+
{ id: '3', path: '/api/v1/orders', method: 'POST', requests: 8410, status: 'Active' },
|
|
162
|
+
{ id: '4', path: '/api/v0/login', method: 'POST', requests: 96, status: 'Deprecated' },
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const columns: TableColumnDef<Endpoint>[] = [
|
|
166
|
+
{
|
|
167
|
+
accessorKey: 'path',
|
|
168
|
+
header: 'Endpoint',
|
|
169
|
+
size: 320,
|
|
170
|
+
cell: ({ row }) => (
|
|
171
|
+
<HStack gap={8}>
|
|
172
|
+
<HttpMethod method={row.original.method} />
|
|
173
|
+
<Text size='sm'>{row.original.path}</Text>
|
|
174
|
+
</HStack>
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
accessorKey: 'status',
|
|
179
|
+
header: 'Status',
|
|
180
|
+
size: 140,
|
|
181
|
+
cell: ({ row }) => (
|
|
182
|
+
<Badge
|
|
183
|
+
variant='dotted'
|
|
184
|
+
type='secondary'
|
|
185
|
+
color={row.original.status === 'Active' ? 'green' : 'gray'}
|
|
186
|
+
>
|
|
187
|
+
{row.original.status}
|
|
188
|
+
</Badge>
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
accessorKey: 'requests',
|
|
193
|
+
header: 'Requests',
|
|
194
|
+
size: 120,
|
|
195
|
+
meta: { description: { type: 'tooltip', content: 'Requests in the last 24 hours' } },
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'requests', desc: true }]);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Table
|
|
203
|
+
aria-label='Endpoints'
|
|
204
|
+
data={endpoints}
|
|
205
|
+
columns={columns}
|
|
206
|
+
getRowId={row => row.id}
|
|
207
|
+
sorting={sorting}
|
|
208
|
+
onSortingChange={setSorting}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `stretch={false}` — columns keep their defined widths and do not expand to fill the container.
|
|
215
|
+
* Empty space appears on the right when the total column width is less than the container width.
|
|
216
|
+
*/
|
|
217
|
+
export const NoStretch: StoryFn<typeof meta> = () => {
|
|
218
|
+
const narrowColumns: TableColumnDef<SecurityEvent>[] = [
|
|
219
|
+
securityColumnHelper.accessor('objectName', {
|
|
220
|
+
header: 'Object name',
|
|
221
|
+
size: 200,
|
|
222
|
+
}),
|
|
223
|
+
securityColumnHelper.accessor('status', {
|
|
224
|
+
header: 'Status',
|
|
225
|
+
size: 120,
|
|
226
|
+
}),
|
|
227
|
+
securityColumnHelper.accessor('requests', {
|
|
228
|
+
header: 'Requests',
|
|
229
|
+
size: 100,
|
|
230
|
+
}),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Table data={securityEvents} columns={narrowColumns} getRowId={row => row.id} stretch={false} />
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// "Rows per page" + page navigation share a right-aligned footer below the table.
|
|
239
|
+
/**
|
|
240
|
+
* `useClientPagination` slices the rows and the pager sits right-aligned below the table,
|
|
241
|
+
* sharing its footer with the rows-per-page control.
|
|
242
|
+
*/
|
|
243
|
+
export const WithPagination: StoryFn<typeof meta> = () => {
|
|
244
|
+
const allData = useMemo(() => createLargeSecurityEvents(47), []);
|
|
245
|
+
const { pageData, ...pagination } = useClientPagination(allData, 10);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<VStack gap={12}>
|
|
249
|
+
<Table data={pageData} columns={securityColumns} getRowId={row => row.id} />
|
|
250
|
+
<Pagination {...pagination} align='right' aria-label='Security events'>
|
|
251
|
+
<PaginationPageSize options={[10, 25, 50]} />
|
|
252
|
+
<PaginationPrevious />
|
|
253
|
+
<PaginationList />
|
|
254
|
+
<PaginationNext />
|
|
255
|
+
</Pagination>
|
|
256
|
+
</VStack>
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Sorting held in state, so the arrow and the row order are the same fact — click a header to
|
|
262
|
+
* cycle through the directions.
|
|
263
|
+
*/
|
|
264
|
+
export const Sorting: StoryFn<typeof meta> = () => {
|
|
265
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<Table
|
|
269
|
+
data={securityEvents}
|
|
270
|
+
columns={securityColumns}
|
|
271
|
+
getRowId={row => row.id}
|
|
272
|
+
sorting={sorting}
|
|
273
|
+
onSortingChange={setSorting}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Demo of server-side sorting: the consumer (this story) owns sort. TanStack
|
|
279
|
+
// only fires `onSortingChange`; we re-derive `data` ourselves to mimic a
|
|
280
|
+
// re-fetch that returns pre-sorted rows.
|
|
281
|
+
const sortSecurityEvents = (sorting: TableSortingState): SecurityEvent[] => {
|
|
282
|
+
if (sorting.length === 0) return securityEvents;
|
|
283
|
+
const { id, desc } = sorting[0]!;
|
|
284
|
+
const key = id as keyof SecurityEvent;
|
|
285
|
+
const direction = desc ? -1 : 1;
|
|
286
|
+
return [...securityEvents].sort((a, b) => {
|
|
287
|
+
const av = a[key];
|
|
288
|
+
const bv = b[key];
|
|
289
|
+
if (av < bv) return -1 * direction;
|
|
290
|
+
if (av > bv) return 1 * direction;
|
|
291
|
+
return 0;
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* `manualSorting` stops the table sorting anything itself: it only reports the change, and the
|
|
297
|
+
* story re-derives `data` the way a refetch would.
|
|
298
|
+
*/
|
|
299
|
+
export const ManualSorting: StoryFn<typeof meta> = () => {
|
|
300
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
301
|
+
const data = useMemo(() => sortSecurityEvents(sorting), [sorting]);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<Table
|
|
305
|
+
data={data}
|
|
306
|
+
columns={securityColumns}
|
|
307
|
+
getRowId={row => row.id}
|
|
308
|
+
sorting={sorting}
|
|
309
|
+
onSortingChange={setSorting}
|
|
310
|
+
manualSorting
|
|
311
|
+
data-testid='manual-sort-table'
|
|
312
|
+
/>
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Showcase: every analytics seam exposed by `<Table>` today, end-to-end.
|
|
317
|
+
//
|
|
318
|
+
// DOM seams (attribute lands on the real interactive target, document-level
|
|
319
|
+
// SDKs resolve via `closest('[data-analytics-id]')`):
|
|
320
|
+
// • Sort trigger — `<TableSortTrigger column={...} data-analytics-id=...>`
|
|
321
|
+
// rendered as a direct child of `header(ctx)`.
|
|
322
|
+
// • Column header menu — `<TableColumnMenu column={...} data-analytics-id=...>`
|
|
323
|
+
// on the trigger button, plus per-item analytics on
|
|
324
|
+
// `<TableColumnMenuSortItem>`, `<TableColumnMenuPinItem>`,
|
|
325
|
+
// `<TableColumnMenuHideItem>`, `<TableColumnMenuMoveLeftItem>`,
|
|
326
|
+
// `<TableColumnMenuMoveRightItem>`.
|
|
327
|
+
// • Cell content — consumer JSX from `column.cell(ctx)`; analytics
|
|
328
|
+
// sit on the rendered `<a>` / `<button>`.
|
|
329
|
+
// • Per-row actions — consumer JSX from `meta.renderMenuAction(row)`;
|
|
330
|
+
// analytics sit on the trigger and each menu item.
|
|
331
|
+
// • TableActionBar — consumer-rendered bulk action buttons (children).
|
|
332
|
+
// • Selection summary — `<TableActionBarSelection>` override composed from
|
|
333
|
+
// `<BulkBarSummarySelectAll>` / `<BulkBarSummaryClear>`;
|
|
334
|
+
// analytics sit on the real "Select all" / "Clear"
|
|
335
|
+
// `<button>`s (DS keeps toggle / reset wiring).
|
|
336
|
+
// • Scroll controls — `<TableScrollHandler>` as a `Table` child, with
|
|
337
|
+
// `<TableScrollHandlerLeft>` / `<TableScrollHandlerRight>`
|
|
338
|
+
// carrying analytics; portaled into the master header.
|
|
339
|
+
// • Expanded row — consumer JSX from `renderExpandedRow(row)`.
|
|
340
|
+
// • Wrapper testid — `<Table data-testid=...>` lands on the
|
|
341
|
+
// scroll-container `<div>`.
|
|
342
|
+
//
|
|
343
|
+
// Callback seams (state-level signal; richer than a click event because the
|
|
344
|
+
// payload carries the resulting state — direction, visibility, order, etc.):
|
|
345
|
+
// • `onSortingChange(u)` — resulting sort column + direction
|
|
346
|
+
// • `onRowSelectionChange(u)` — selected row map
|
|
347
|
+
// • `onExpandedChange(u)` — expanded row map
|
|
348
|
+
// • `onColumnVisibilityChange(u)` — visibility map (Settings menu)
|
|
349
|
+
// • `onColumnOrderChange(u)` — column order (Settings menu DnD)
|
|
350
|
+
// • `onColumnPinningChange(u)` — pin state (column-header menu)
|
|
351
|
+
// • `onColumnSizingChange(u)` — final width (resize handle)
|
|
352
|
+
// • `onMasterCellClick(rowId)` — master-cell click
|
|
353
|
+
//
|
|
354
|
+
// Pending DS work (no DOM seam yet — callback-only): selection checkbox,
|
|
355
|
+
// expand toggle, master-cell click, resize handle.
|
|
356
|
+
/**
|
|
357
|
+
* Every analytics seam at once — `TableSortTrigger`, `TableColumnMenu` and each of its items,
|
|
358
|
+
* the action bar, the scroll handlers — plus the state callbacks, which carry the resulting
|
|
359
|
+
* sort, selection and column layout rather than just a click.
|
|
360
|
+
*/
|
|
361
|
+
export const WithAnalytics: StoryFn<typeof meta> = () => {
|
|
362
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
363
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
364
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({});
|
|
365
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
366
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
367
|
+
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({});
|
|
368
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
369
|
+
|
|
370
|
+
// Pretend SDK call — every analytics signal in this story funnels through
|
|
371
|
+
// it so the Storybook actions panel shows one consistent stream.
|
|
372
|
+
const track = useCallback(
|
|
373
|
+
(event: string, props?: Record<string, unknown>) =>
|
|
374
|
+
// biome-ignore lint/suspicious/noConsole: demo story stand-in for an SDK call
|
|
375
|
+
console.info('[analytics]', event, props ?? {}),
|
|
376
|
+
[],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const columnsWithAnalytics = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
380
|
+
() => [
|
|
381
|
+
// Master column — sortable header, link cell with analytics, per-row
|
|
382
|
+
// menu action with analytics on every menu item.
|
|
383
|
+
securityColumnHelper.accessor('objectName', {
|
|
384
|
+
header: ({ column }) => (
|
|
385
|
+
<TableSortTrigger
|
|
386
|
+
column={column}
|
|
387
|
+
data-analytics-id='SECURITY_SORT_OBJECT_NAME'
|
|
388
|
+
data-analytics-props='{"surface":"events-table"}'
|
|
389
|
+
>
|
|
390
|
+
Object name
|
|
391
|
+
</TableSortTrigger>
|
|
392
|
+
),
|
|
393
|
+
size: 320,
|
|
394
|
+
enableSorting: true,
|
|
395
|
+
meta: {
|
|
396
|
+
sortType: 'text' as const,
|
|
397
|
+
resizeType: 'cut' as const,
|
|
398
|
+
renderMenuAction: row => (
|
|
399
|
+
<DropdownMenu>
|
|
400
|
+
<DropdownMenuTrigger asChild>
|
|
401
|
+
<Button
|
|
402
|
+
variant='ghost'
|
|
403
|
+
color='neutral'
|
|
404
|
+
size='small'
|
|
405
|
+
aria-label='Row actions'
|
|
406
|
+
data-analytics-id='SECURITY_ROW_MENU_OPEN'
|
|
407
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
408
|
+
>
|
|
409
|
+
<Ellipsis />
|
|
410
|
+
</Button>
|
|
411
|
+
</DropdownMenuTrigger>
|
|
412
|
+
<DropdownMenuContent>
|
|
413
|
+
<DropdownMenuItem
|
|
414
|
+
data-analytics-id='SECURITY_ROW_COPY_NAME'
|
|
415
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
416
|
+
onSelect={() => navigator.clipboard.writeText(row.original.objectName)}
|
|
417
|
+
>
|
|
418
|
+
<DropdownMenuItemIcon>
|
|
419
|
+
<Copy />
|
|
420
|
+
</DropdownMenuItemIcon>
|
|
421
|
+
<DropdownMenuItemText>Copy name</DropdownMenuItemText>
|
|
422
|
+
</DropdownMenuItem>
|
|
423
|
+
<DropdownMenuSeparator />
|
|
424
|
+
<DropdownMenuItem
|
|
425
|
+
data-analytics-id='SECURITY_ROW_FILTER'
|
|
426
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
427
|
+
onSelect={() => track('SECURITY_ROW_FILTER_APPLIED', { rowId: row.original.id })}
|
|
428
|
+
>
|
|
429
|
+
<DropdownMenuItemIcon>
|
|
430
|
+
<Filter />
|
|
431
|
+
</DropdownMenuItemIcon>
|
|
432
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
433
|
+
</DropdownMenuItem>
|
|
434
|
+
<DropdownMenuItem
|
|
435
|
+
data-analytics-id='SECURITY_ROW_EXCLUDE'
|
|
436
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
437
|
+
onSelect={() => track('SECURITY_ROW_EXCLUDE_APPLIED', { rowId: row.original.id })}
|
|
438
|
+
>
|
|
439
|
+
<DropdownMenuItemIcon>
|
|
440
|
+
<FilterX />
|
|
441
|
+
</DropdownMenuItemIcon>
|
|
442
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
443
|
+
</DropdownMenuItem>
|
|
444
|
+
</DropdownMenuContent>
|
|
445
|
+
</DropdownMenu>
|
|
446
|
+
),
|
|
447
|
+
},
|
|
448
|
+
cell: ({ row }) => (
|
|
449
|
+
<a
|
|
450
|
+
href={`#/events/${row.original.id}`}
|
|
451
|
+
data-analytics-id='SECURITY_ROW_OPEN'
|
|
452
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
453
|
+
>
|
|
454
|
+
{row.original.objectName}
|
|
455
|
+
</a>
|
|
456
|
+
),
|
|
457
|
+
}),
|
|
458
|
+
// Other sortable columns — each one declares its own sort target.
|
|
459
|
+
securityColumnHelper.accessor('requests', {
|
|
460
|
+
header: ({ column }) => (
|
|
461
|
+
<TableSortTrigger column={column} data-analytics-id='SECURITY_SORT_REQUESTS'>
|
|
462
|
+
Requests
|
|
463
|
+
</TableSortTrigger>
|
|
464
|
+
),
|
|
465
|
+
size: 140,
|
|
466
|
+
enableSorting: true,
|
|
467
|
+
}),
|
|
468
|
+
securityColumnHelper.accessor('sourceIp', {
|
|
469
|
+
header: ({ column }) => (
|
|
470
|
+
<TableSortTrigger column={column} data-analytics-id='SECURITY_SORT_SOURCE_IP'>
|
|
471
|
+
Source
|
|
472
|
+
</TableSortTrigger>
|
|
473
|
+
),
|
|
474
|
+
size: 180,
|
|
475
|
+
enableSorting: true,
|
|
476
|
+
meta: { sortType: 'text' as const },
|
|
477
|
+
}),
|
|
478
|
+
// Status column — demonstrates the column header menu seam with
|
|
479
|
+
// per-item analytics, rendered as a sibling of the sort trigger inside
|
|
480
|
+
// a Fragment.
|
|
481
|
+
securityColumnHelper.accessor('status', {
|
|
482
|
+
header: ({ column }) => (
|
|
483
|
+
<>
|
|
484
|
+
<TableSortTrigger column={column} data-analytics-id='SECURITY_SORT_STATUS'>
|
|
485
|
+
Status
|
|
486
|
+
</TableSortTrigger>
|
|
487
|
+
<TableColumnMenu column={column} data-analytics-id='SECURITY_COLMENU_STATUS'>
|
|
488
|
+
<TableColumnMenuSortItem
|
|
489
|
+
direction='asc'
|
|
490
|
+
data-analytics-id='SECURITY_COLMENU_STATUS_SORT_ASC'
|
|
491
|
+
/>
|
|
492
|
+
<TableColumnMenuSortItem
|
|
493
|
+
direction='desc'
|
|
494
|
+
data-analytics-id='SECURITY_COLMENU_STATUS_SORT_DESC'
|
|
495
|
+
/>
|
|
496
|
+
<TableColumnMenuPinItem data-analytics-id='SECURITY_COLMENU_STATUS_PIN' />
|
|
497
|
+
<TableColumnMenuHideItem data-analytics-id='SECURITY_COLMENU_STATUS_HIDE' />
|
|
498
|
+
<TableColumnMenuMoveLeftItem data-analytics-id='SECURITY_COLMENU_STATUS_MOVE_LEFT' />
|
|
499
|
+
<TableColumnMenuMoveRightItem data-analytics-id='SECURITY_COLMENU_STATUS_MOVE_RIGHT' />
|
|
500
|
+
</TableColumnMenu>
|
|
501
|
+
</>
|
|
502
|
+
),
|
|
503
|
+
size: 130,
|
|
504
|
+
enableSorting: true,
|
|
505
|
+
meta: { sortType: 'text' as const, resizeType: 'cut' as const },
|
|
506
|
+
}),
|
|
507
|
+
securityColumnHelper.accessor('firstDetected', {
|
|
508
|
+
header: ({ column }) => (
|
|
509
|
+
<TableSortTrigger column={column} data-analytics-id='SECURITY_SORT_FIRST_DETECTED'>
|
|
510
|
+
First detected
|
|
511
|
+
</TableSortTrigger>
|
|
512
|
+
),
|
|
513
|
+
size: 160,
|
|
514
|
+
enableSorting: true,
|
|
515
|
+
meta: { sortType: 'date' as const, resizeType: 'cut' as const },
|
|
516
|
+
}),
|
|
517
|
+
],
|
|
518
|
+
[track],
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<Table
|
|
523
|
+
data-testid='security-events-table'
|
|
524
|
+
data={securityEvents}
|
|
525
|
+
columns={columnsWithAnalytics}
|
|
526
|
+
getRowId={row => row.id}
|
|
527
|
+
// Sort: DOM seam on the icon (TableSortTrigger) + callback for direction.
|
|
528
|
+
sorting={sorting}
|
|
529
|
+
onSortingChange={updater => {
|
|
530
|
+
setSorting(prev => {
|
|
531
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
532
|
+
track('SECURITY_TABLE_SORT_CHANGED', { columnId: next[0]?.id, desc: !!next[0]?.desc });
|
|
533
|
+
return next;
|
|
534
|
+
});
|
|
535
|
+
}}
|
|
536
|
+
// Selection / expand / settings menu — no DOM seam yet; callbacks carry
|
|
537
|
+
// the resulting state.
|
|
538
|
+
rowSelection={rowSelection}
|
|
539
|
+
onRowSelectionChange={updater => {
|
|
540
|
+
setRowSelection(prev => {
|
|
541
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
542
|
+
track('SECURITY_TABLE_SELECTION_CHANGED', { selectedIds: Object.keys(next) });
|
|
543
|
+
return next;
|
|
544
|
+
});
|
|
545
|
+
}}
|
|
546
|
+
expanded={expanded}
|
|
547
|
+
onExpandedChange={updater => {
|
|
548
|
+
setExpanded(prev => {
|
|
549
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
550
|
+
track('SECURITY_TABLE_EXPAND_CHANGED', { state: next });
|
|
551
|
+
return next;
|
|
552
|
+
});
|
|
553
|
+
}}
|
|
554
|
+
columnVisibility={columnVisibility}
|
|
555
|
+
onColumnVisibilityChange={updater => {
|
|
556
|
+
setColumnVisibility(prev => {
|
|
557
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
558
|
+
track('SECURITY_TABLE_COLUMNS_VISIBILITY_CHANGED', next);
|
|
559
|
+
return next;
|
|
560
|
+
});
|
|
561
|
+
}}
|
|
562
|
+
columnOrder={columnOrder}
|
|
563
|
+
onColumnOrderChange={updater => {
|
|
564
|
+
setColumnOrder(prev => {
|
|
565
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
566
|
+
track('SECURITY_TABLE_COLUMNS_REORDERED', { order: next });
|
|
567
|
+
return next;
|
|
568
|
+
});
|
|
569
|
+
}}
|
|
570
|
+
columnPinning={columnPinning}
|
|
571
|
+
onColumnPinningChange={updater => {
|
|
572
|
+
setColumnPinning(prev => {
|
|
573
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
574
|
+
track('SECURITY_TABLE_COLUMNS_PINNED', next);
|
|
575
|
+
return next;
|
|
576
|
+
});
|
|
577
|
+
}}
|
|
578
|
+
columnSizing={columnSizing}
|
|
579
|
+
onColumnSizingChange={updater => {
|
|
580
|
+
setColumnSizing(prev => {
|
|
581
|
+
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
582
|
+
track('SECURITY_TABLE_COLUMN_RESIZED', next);
|
|
583
|
+
return next;
|
|
584
|
+
});
|
|
585
|
+
}}
|
|
586
|
+
onMasterCellClick={rowId => track('SECURITY_TABLE_MASTER_CELL_CLICKED', { rowId })}
|
|
587
|
+
renderExpandedRow={row => (
|
|
588
|
+
<div className='p-16 bg-states-primary-default-alt rounded-8'>
|
|
589
|
+
<HStack justify='between' align='center'>
|
|
590
|
+
<VStack gap={2}>
|
|
591
|
+
<Text size='xs' color='secondary'>
|
|
592
|
+
Source IP
|
|
593
|
+
</Text>
|
|
594
|
+
<Text size='sm'>{row.original.sourceIp}</Text>
|
|
595
|
+
</VStack>
|
|
596
|
+
<Button
|
|
597
|
+
variant='outline'
|
|
598
|
+
color='neutral'
|
|
599
|
+
size='small'
|
|
600
|
+
data-analytics-id='SECURITY_EXPANDED_VIEW_DETAILS'
|
|
601
|
+
data-analytics-props={`{"rowId":"${row.original.id}"}`}
|
|
602
|
+
onClick={() =>
|
|
603
|
+
track('SECURITY_EXPANDED_VIEW_DETAILS_CLICKED', { rowId: row.original.id })
|
|
604
|
+
}
|
|
605
|
+
>
|
|
606
|
+
View full details
|
|
607
|
+
</Button>
|
|
608
|
+
</HStack>
|
|
609
|
+
</div>
|
|
610
|
+
)}
|
|
611
|
+
>
|
|
612
|
+
<TableActionBar>
|
|
613
|
+
{/*
|
|
614
|
+
Selection summary — override the default block to attach analytics to
|
|
615
|
+
the "Select all" / "Clear" controls. The DS keeps the wiring (toggle /
|
|
616
|
+
reset, disabled, count); the consumer onClick runs first and feeds the
|
|
617
|
+
same `track` stream (call preventDefault() to skip the DS action).
|
|
618
|
+
*/}
|
|
619
|
+
<TableActionBarSelection>
|
|
620
|
+
<BulkBarSummaryCount />
|
|
621
|
+
<BulkBarSummarySelectAll
|
|
622
|
+
data-analytics-id='SECURITY_BULK_SELECT_ALL'
|
|
623
|
+
onClick={() => track('SECURITY_BULK_SELECT_ALL_CLICKED')}
|
|
624
|
+
/>
|
|
625
|
+
<BulkBarSummarySeparator />
|
|
626
|
+
<BulkBarSummaryClear
|
|
627
|
+
data-analytics-id='SECURITY_BULK_CLEAR'
|
|
628
|
+
onClick={() => track('SECURITY_BULK_CLEAR_CLICKED')}
|
|
629
|
+
/>
|
|
630
|
+
</TableActionBarSelection>
|
|
631
|
+
|
|
632
|
+
<Button
|
|
633
|
+
variant='ghost'
|
|
634
|
+
color='neutral-alt'
|
|
635
|
+
data-analytics-id='SECURITY_BULK_DUPLICATE'
|
|
636
|
+
data-analytics-props={`{"count":${Object.keys(rowSelection).length}}`}
|
|
637
|
+
onClick={() =>
|
|
638
|
+
track('SECURITY_BULK_DUPLICATE_CLICKED', {
|
|
639
|
+
selectedIds: Object.keys(rowSelection),
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
>
|
|
643
|
+
<Copy /> Duplicate
|
|
644
|
+
</Button>
|
|
645
|
+
<Button
|
|
646
|
+
color='destructive'
|
|
647
|
+
data-analytics-id='SECURITY_BULK_DELETE'
|
|
648
|
+
data-analytics-props={`{"count":${Object.keys(rowSelection).length}}`}
|
|
649
|
+
onClick={() =>
|
|
650
|
+
track('SECURITY_BULK_DELETE_CLICKED', {
|
|
651
|
+
selectedIds: Object.keys(rowSelection),
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
>
|
|
655
|
+
<Trash2 /> Delete
|
|
656
|
+
</Button>
|
|
657
|
+
</TableActionBar>
|
|
658
|
+
|
|
659
|
+
<TableScrollHandler>
|
|
660
|
+
<TableScrollHandlerLeft data-analytics-id='SECURITY_SCROLL_LEFT' />
|
|
661
|
+
<TableScrollHandlerRight data-analytics-id='SECURITY_SCROLL_RIGHT' />
|
|
662
|
+
</TableScrollHandler>
|
|
663
|
+
</Table>
|
|
664
|
+
);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* `isLoading` with nothing to show yet: skeleton rows under a real header, so the columns do
|
|
669
|
+
* not jump when the data lands.
|
|
670
|
+
*/
|
|
671
|
+
export const LoadingState: StoryFn<typeof meta> = () => (
|
|
672
|
+
<Table data={[]} columns={securityColumns} isLoading />
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The same flag with rows already present appends the skeletons below them — this is the shape
|
|
677
|
+
* for fetching the next batch, not for replacing what is on screen.
|
|
678
|
+
*/
|
|
679
|
+
export const LoadingWithData: StoryFn<typeof meta> = () => (
|
|
680
|
+
<Table
|
|
681
|
+
className='h-500'
|
|
682
|
+
data={securityEvents}
|
|
683
|
+
columns={securityColumns}
|
|
684
|
+
getRowId={row => row.id}
|
|
685
|
+
isLoading
|
|
686
|
+
/>
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* `TableEmptyState` holding an `EmptyState` of `type='collection-empty'`: nothing exists yet,
|
|
691
|
+
* so the action creates the first thing.
|
|
692
|
+
*/
|
|
693
|
+
export const EmptyCollection: StoryFn<typeof meta> = () => {
|
|
694
|
+
interface Source {
|
|
695
|
+
id: string;
|
|
696
|
+
name: string;
|
|
697
|
+
events: number;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const columns: TableColumnDef<Source>[] = [
|
|
701
|
+
{ accessorKey: 'name', header: 'Source', size: 320 },
|
|
702
|
+
{ accessorKey: 'events', header: 'Events', size: 120 },
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
<Table aria-label='Event sources' data={[]} columns={columns} getRowId={row => row.id}>
|
|
707
|
+
<TableEmptyState>
|
|
708
|
+
<EmptyState type='collection-empty'>
|
|
709
|
+
<EmptyStateIllustration>
|
|
710
|
+
<Database />
|
|
711
|
+
</EmptyStateIllustration>
|
|
712
|
+
<EmptyStateMessage>
|
|
713
|
+
<EmptyStateTitle>No events yet</EmptyStateTitle>
|
|
714
|
+
<EmptyStateDescription>
|
|
715
|
+
Security events will appear here as soon as Wallarm detects traffic.
|
|
716
|
+
</EmptyStateDescription>
|
|
717
|
+
</EmptyStateMessage>
|
|
718
|
+
<EmptyStateActions>
|
|
719
|
+
<Button size='medium'>Configure source</Button>
|
|
720
|
+
</EmptyStateActions>
|
|
721
|
+
</EmptyState>
|
|
722
|
+
</TableEmptyState>
|
|
723
|
+
</Table>
|
|
724
|
+
);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* When data exists but the current filters or search matched nothing, stay at
|
|
729
|
+
* the `collection-empty` scale — a table body is a page-level surface, and
|
|
730
|
+
* `no-results` is the compact treatment for a card or menu. What changes is the
|
|
731
|
+
* action: a neutral one that clears the filters, never a primary "create".
|
|
732
|
+
*/
|
|
733
|
+
export const NoResults: StoryFn<typeof meta> = () => (
|
|
734
|
+
<Table data={[]} columns={securityColumns} getRowId={row => row.id}>
|
|
735
|
+
<TableEmptyState>
|
|
736
|
+
<EmptyState type='collection-empty'>
|
|
737
|
+
<EmptyStateIllustration>
|
|
738
|
+
<SearchX />
|
|
739
|
+
</EmptyStateIllustration>
|
|
740
|
+
<EmptyStateMessage>
|
|
741
|
+
<EmptyStateTitle>No results found</EmptyStateTitle>
|
|
742
|
+
<EmptyStateDescription>
|
|
743
|
+
No events match your current filters. Try adjusting or resetting them.
|
|
744
|
+
</EmptyStateDescription>
|
|
745
|
+
</EmptyStateMessage>
|
|
746
|
+
<EmptyStateActions>
|
|
747
|
+
<Button size='medium' variant='outline' color='neutral'>
|
|
748
|
+
<FilterX />
|
|
749
|
+
Reset filters
|
|
750
|
+
</Button>
|
|
751
|
+
</EmptyStateActions>
|
|
752
|
+
</EmptyState>
|
|
753
|
+
</TableEmptyState>
|
|
754
|
+
</Table>
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Drag a header edge to resize. `columnSizing` is state like everything else, so a width can
|
|
759
|
+
* be remembered between visits.
|
|
760
|
+
*/
|
|
761
|
+
export const ColumnResizing: StoryFn<typeof meta> = () => {
|
|
762
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
763
|
+
|
|
764
|
+
return (
|
|
765
|
+
<Table
|
|
766
|
+
data={securityEvents}
|
|
767
|
+
columns={securityColumns}
|
|
768
|
+
getRowId={row => row.id}
|
|
769
|
+
columnSizing={columnSizing}
|
|
770
|
+
onColumnSizingChange={setColumnSizing}
|
|
771
|
+
/>
|
|
772
|
+
);
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* A resizable column whose cell holds an `OverflowList`: the tags re-measure as the column
|
|
777
|
+
* narrows instead of being clipped by it.
|
|
778
|
+
*/
|
|
779
|
+
export const ColumnResizingWithOverflowList: StoryFn<typeof meta> = () => {
|
|
780
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
781
|
+
|
|
782
|
+
const columns = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
783
|
+
() => [
|
|
784
|
+
...securityColumns.slice(0, 1),
|
|
785
|
+
securityColumnHelper.display({
|
|
786
|
+
id: 'tags',
|
|
787
|
+
header: 'Tags',
|
|
788
|
+
size: 240,
|
|
789
|
+
cell: ({ row }) => (
|
|
790
|
+
<OverflowList
|
|
791
|
+
data-testid='tags-overflow-list'
|
|
792
|
+
className='gap-4'
|
|
793
|
+
items={row.original.tags}
|
|
794
|
+
itemRenderer={(item: string) => (
|
|
795
|
+
<Tag key={item} data-testid='tag'>
|
|
796
|
+
{item}
|
|
797
|
+
</Tag>
|
|
798
|
+
)}
|
|
799
|
+
/>
|
|
800
|
+
),
|
|
801
|
+
}),
|
|
802
|
+
...securityColumns.slice(1),
|
|
803
|
+
],
|
|
804
|
+
[],
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
return (
|
|
808
|
+
<Table
|
|
809
|
+
data={securityEvents}
|
|
810
|
+
columns={columns}
|
|
811
|
+
getRowId={row => row.id}
|
|
812
|
+
columnSizing={columnSizing}
|
|
813
|
+
onColumnSizingChange={setColumnSizing}
|
|
814
|
+
data-testid='resizing-overflow-table'
|
|
815
|
+
/>
|
|
816
|
+
);
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Resizing alongside a pinned column — the pinned one holds its position while the rest of the
|
|
821
|
+
* row moves underneath it.
|
|
822
|
+
*/
|
|
823
|
+
export const ColumnResizingWithPinning: StoryFn<typeof meta> = () => {
|
|
824
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
825
|
+
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({
|
|
826
|
+
left: ['objectName'],
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
return (
|
|
830
|
+
<Table
|
|
831
|
+
className='max-w-920'
|
|
832
|
+
data={securityEvents}
|
|
833
|
+
columns={securityColumns}
|
|
834
|
+
getRowId={row => row.id}
|
|
835
|
+
columnSizing={columnSizing}
|
|
836
|
+
onColumnSizingChange={setColumnSizing}
|
|
837
|
+
columnPinning={columnPinning}
|
|
838
|
+
onColumnPinningChange={setColumnPinning}
|
|
839
|
+
/>
|
|
840
|
+
);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* `columnPinning` freezes a column to the left, so the name of the thing stays readable
|
|
845
|
+
* however far sideways the table scrolls.
|
|
846
|
+
*/
|
|
847
|
+
export const ColumnPinning: StoryFn<typeof meta> = () => {
|
|
848
|
+
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({
|
|
849
|
+
left: ['objectName'],
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return (
|
|
853
|
+
<Table
|
|
854
|
+
className='max-w-920'
|
|
855
|
+
data={securityEvents}
|
|
856
|
+
columns={securityColumns}
|
|
857
|
+
getRowId={row => row.id}
|
|
858
|
+
columnPinning={columnPinning}
|
|
859
|
+
onColumnPinningChange={setColumnPinning}
|
|
860
|
+
/>
|
|
861
|
+
);
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Drag a header to reorder the columns. `columnOrder` is the same state the settings menu
|
|
866
|
+
* writes, so the two never disagree.
|
|
867
|
+
*/
|
|
868
|
+
export const ColumnDragAndDrop: StoryFn<typeof meta> = () => {
|
|
869
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
870
|
+
|
|
871
|
+
return (
|
|
872
|
+
<Table
|
|
873
|
+
data={securityEvents}
|
|
874
|
+
columns={securityColumns}
|
|
875
|
+
getRowId={row => row.id}
|
|
876
|
+
columnOrder={columnOrder}
|
|
877
|
+
onColumnOrderChange={setColumnOrder}
|
|
878
|
+
/>
|
|
879
|
+
);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* The per-column menu that appears on hover — sort, pin, hide, move — which is where the
|
|
884
|
+
* column controls live once a header is too narrow to show them.
|
|
885
|
+
*/
|
|
886
|
+
export const ColumnHeaderMenu: StoryFn<typeof meta> = () => {
|
|
887
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
888
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
889
|
+
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({});
|
|
890
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<Table
|
|
894
|
+
data={securityEvents}
|
|
895
|
+
columns={securityColumns}
|
|
896
|
+
getRowId={row => row.id}
|
|
897
|
+
sorting={sorting}
|
|
898
|
+
onSortingChange={setSorting}
|
|
899
|
+
columnVisibility={columnVisibility}
|
|
900
|
+
onColumnVisibilityChange={setColumnVisibility}
|
|
901
|
+
columnPinning={columnPinning}
|
|
902
|
+
onColumnPinningChange={setColumnPinning}
|
|
903
|
+
columnOrder={columnOrder}
|
|
904
|
+
onColumnOrderChange={setColumnOrder}
|
|
905
|
+
/>
|
|
906
|
+
);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Checkboxes plus `TableActionBar`, which rises from the bottom once a row is ticked and
|
|
911
|
+
* carries the verbs that apply to the whole selection.
|
|
912
|
+
*/
|
|
913
|
+
export const RowSelection: StoryFn<typeof meta> = () => {
|
|
914
|
+
interface Rule {
|
|
915
|
+
id: string;
|
|
916
|
+
name: string;
|
|
917
|
+
mode: 'Block' | 'Monitor';
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const columns: TableColumnDef<Rule>[] = [
|
|
921
|
+
{ accessorKey: 'name', header: 'Rule', size: 320 },
|
|
922
|
+
{
|
|
923
|
+
accessorKey: 'mode',
|
|
924
|
+
header: 'Mode',
|
|
925
|
+
size: 140,
|
|
926
|
+
cell: ({ row }) => (
|
|
927
|
+
<Badge
|
|
928
|
+
variant='dotted'
|
|
929
|
+
type='secondary'
|
|
930
|
+
color={row.original.mode === 'Block' ? 'red' : 'yellow'}
|
|
931
|
+
>
|
|
932
|
+
{row.original.mode}
|
|
933
|
+
</Badge>
|
|
934
|
+
),
|
|
935
|
+
},
|
|
936
|
+
];
|
|
937
|
+
|
|
938
|
+
const [rules, setRules] = useState<Rule[]>([
|
|
939
|
+
{ id: '1', name: 'Block SQL injection', mode: 'Block' },
|
|
940
|
+
{ id: '2', name: 'Rate limit /login', mode: 'Block' },
|
|
941
|
+
{ id: '3', name: 'Watch admin endpoints', mode: 'Monitor' },
|
|
942
|
+
{ id: '4', name: 'Mask card numbers', mode: 'Monitor' },
|
|
943
|
+
]);
|
|
944
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
945
|
+
|
|
946
|
+
const handleDuplicate = () => {
|
|
947
|
+
const copies = rules
|
|
948
|
+
.filter(rule => rowSelection[rule.id])
|
|
949
|
+
.map(rule => ({
|
|
950
|
+
...rule,
|
|
951
|
+
id: `${rule.id}-copy-${rules.length}`,
|
|
952
|
+
name: `${rule.name} (copy)`,
|
|
953
|
+
}));
|
|
954
|
+
setRules([...rules, ...copies]);
|
|
955
|
+
setRowSelection({});
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const handleDelete = () => {
|
|
959
|
+
// A real app confirms first — see the confirmation-dialogs guideline.
|
|
960
|
+
setRules(rules.filter(rule => !rowSelection[rule.id]));
|
|
961
|
+
setRowSelection({});
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<Table
|
|
966
|
+
aria-label='Rules'
|
|
967
|
+
data={rules}
|
|
968
|
+
columns={columns}
|
|
969
|
+
getRowId={row => row.id}
|
|
970
|
+
rowSelection={rowSelection}
|
|
971
|
+
onRowSelectionChange={setRowSelection}
|
|
972
|
+
>
|
|
973
|
+
<TableActionBar>
|
|
974
|
+
<Button variant='ghost' color='neutral-alt' onClick={handleDuplicate}>
|
|
975
|
+
<Copy /> Duplicate
|
|
976
|
+
</Button>
|
|
977
|
+
<Button color='destructive' onClick={handleDelete}>
|
|
978
|
+
<Trash2 /> Delete
|
|
979
|
+
</Button>
|
|
980
|
+
</TableActionBar>
|
|
981
|
+
</Table>
|
|
982
|
+
);
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* `onSelectAllRows` separates the master checkbox from a hand-made full selection, which the
|
|
987
|
+
* row state alone cannot tell apart. Here the gesture means "every row matching the filter",
|
|
988
|
+
* including the ones the server has not sent — so the bar jumps to the filter's total, and any
|
|
989
|
+
* manual change drops back to the rows actually loaded.
|
|
990
|
+
*/
|
|
991
|
+
export const SelectAllRowsGesture: StoryFn<typeof meta> = () => {
|
|
992
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
993
|
+
const [filterWide, setFilterWide] = useState(false);
|
|
994
|
+
const MATCHING_THE_FILTER = 1474;
|
|
995
|
+
|
|
996
|
+
const selectedCount = filterWide ? MATCHING_THE_FILTER : Object.keys(rowSelection).length;
|
|
997
|
+
|
|
998
|
+
return (
|
|
999
|
+
<Table
|
|
1000
|
+
data={securityEvents}
|
|
1001
|
+
columns={securityColumns}
|
|
1002
|
+
getRowId={row => row.id}
|
|
1003
|
+
rowSelection={rowSelection}
|
|
1004
|
+
onRowSelectionChange={updater => {
|
|
1005
|
+
setRowSelection(updater);
|
|
1006
|
+
// "Everything matching the filter, minus this one row" is not a filter,
|
|
1007
|
+
// so any hand-made change falls back to the explicit list.
|
|
1008
|
+
setFilterWide(false);
|
|
1009
|
+
}}
|
|
1010
|
+
onSelectAllRows={setFilterWide}
|
|
1011
|
+
>
|
|
1012
|
+
<TableActionBar>
|
|
1013
|
+
<TableActionBarSelection>
|
|
1014
|
+
<BulkBarSummaryCount>
|
|
1015
|
+
{selectedCount} {selectedCount === 1 ? 'event' : 'events'} selected
|
|
1016
|
+
</BulkBarSummaryCount>
|
|
1017
|
+
<BulkBarSummaryClear />
|
|
1018
|
+
</TableActionBarSelection>
|
|
1019
|
+
<Button color='destructive' onClick={onDelete}>
|
|
1020
|
+
<Trash2 /> Delete
|
|
1021
|
+
</Button>
|
|
1022
|
+
</TableActionBar>
|
|
1023
|
+
</Table>
|
|
1024
|
+
);
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* `enableSelectAllRows={false}` drops the master checkbox while keeping the column, for a
|
|
1029
|
+
* listing where "all rows" is not a scope the consumer can act on. Per-row checkboxes stay,
|
|
1030
|
+
* shift-click range selection included.
|
|
1031
|
+
*/
|
|
1032
|
+
export const SelectAllRowsDisabled: StoryFn<typeof meta> = () => {
|
|
1033
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
1034
|
+
|
|
1035
|
+
return (
|
|
1036
|
+
<Table
|
|
1037
|
+
data={securityEvents}
|
|
1038
|
+
columns={securityColumns}
|
|
1039
|
+
getRowId={row => row.id}
|
|
1040
|
+
rowSelection={rowSelection}
|
|
1041
|
+
onRowSelectionChange={setRowSelection}
|
|
1042
|
+
enableSelectAllRows={false}
|
|
1043
|
+
/>
|
|
1044
|
+
);
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* A selection that outlives the table scrolling away: under `virtualized='window'` the bar is
|
|
1049
|
+
* pinned to the viewport rather than to the table.
|
|
1050
|
+
*/
|
|
1051
|
+
export const RowSelectionWindowScroll: StoryFn<typeof meta> = () => {
|
|
1052
|
+
const largeData = useMemo(() => createLargeSecurityEvents(100), []);
|
|
1053
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
1054
|
+
|
|
1055
|
+
return (
|
|
1056
|
+
<>
|
|
1057
|
+
<Table
|
|
1058
|
+
data-testid='row-selection-window-scroll-table'
|
|
1059
|
+
data={largeData}
|
|
1060
|
+
columns={securityColumns}
|
|
1061
|
+
getRowId={row => row.id}
|
|
1062
|
+
virtualized='window'
|
|
1063
|
+
rowSelection={rowSelection}
|
|
1064
|
+
onRowSelectionChange={setRowSelection}
|
|
1065
|
+
>
|
|
1066
|
+
<TableActionBar>
|
|
1067
|
+
<Button variant='ghost' color='neutral-alt' onClick={onDuplicate}>
|
|
1068
|
+
<Copy /> Duplicate
|
|
1069
|
+
</Button>
|
|
1070
|
+
<Button color='destructive' onClick={onDelete}>
|
|
1071
|
+
<Trash2 /> Delete
|
|
1072
|
+
</Button>
|
|
1073
|
+
</TableActionBar>
|
|
1074
|
+
</Table>
|
|
1075
|
+
{/* Filler so the e2e suite can scroll the table fully out of view while a
|
|
1076
|
+
selection stays active — this story's only content otherwise fills
|
|
1077
|
+
exactly to the table's own height, so max scroll would always leave
|
|
1078
|
+
the table's tail in view. Generous margin over the viewport height
|
|
1079
|
+
(800px in the e2e config) so the anchor is unambiguously out of view
|
|
1080
|
+
at max scroll, not just mostly. */}
|
|
1081
|
+
<div style={{ height: 3000 }} />
|
|
1082
|
+
</>
|
|
1083
|
+
);
|
|
1084
|
+
};
|
|
1085
|
+
RowSelectionWindowScroll.tags = ['!autodocs'];
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* `getSubRows` turns flat data into groups — each group row counts its children and collapses
|
|
1089
|
+
* them, keyed in the same `expanded` state a row detail uses.
|
|
1090
|
+
*/
|
|
1091
|
+
export const RowGrouping: StoryFn<typeof meta> = () => {
|
|
1092
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({ 'group-cto': true });
|
|
1093
|
+
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
|
|
1094
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
1095
|
+
|
|
1096
|
+
return (
|
|
1097
|
+
<Table<SecurityHeaderEntry>
|
|
1098
|
+
data={groupedHeaderData}
|
|
1099
|
+
columns={headerColumns}
|
|
1100
|
+
getRowId={row => row.id}
|
|
1101
|
+
getSubRows={row => row.children}
|
|
1102
|
+
expanded={expanded}
|
|
1103
|
+
onExpandedChange={setExpanded}
|
|
1104
|
+
sorting={sorting}
|
|
1105
|
+
onSortingChange={setSorting}
|
|
1106
|
+
rowSelection={rowSelection}
|
|
1107
|
+
onRowSelectionChange={setRowSelection}
|
|
1108
|
+
/>
|
|
1109
|
+
);
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* `renderExpandedRow` draws a panel beneath the row, for the two or three fields that do not
|
|
1114
|
+
* deserve columns of their own.
|
|
1115
|
+
*/
|
|
1116
|
+
export const RowExpanding: StoryFn<typeof meta> = () => {
|
|
1117
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({});
|
|
1118
|
+
|
|
1119
|
+
return (
|
|
1120
|
+
<Table
|
|
1121
|
+
data={securityEvents}
|
|
1122
|
+
columns={securityColumns}
|
|
1123
|
+
getRowId={row => row.id}
|
|
1124
|
+
expanded={expanded}
|
|
1125
|
+
onExpandedChange={setExpanded}
|
|
1126
|
+
renderExpandedRow={row => (
|
|
1127
|
+
<div className='p-16 bg-states-primary-default-alt rounded-8'>
|
|
1128
|
+
<Text size='sm' weight='medium'>
|
|
1129
|
+
Event Details
|
|
1130
|
+
</Text>
|
|
1131
|
+
<div className='grid grid-cols-3 gap-8 mt-8'>
|
|
1132
|
+
<VStack gap={2}>
|
|
1133
|
+
<Text size='xs' color='secondary'>
|
|
1134
|
+
Source IP
|
|
1135
|
+
</Text>
|
|
1136
|
+
<Text size='sm'>{row.original.sourceIp}</Text>
|
|
1137
|
+
</VStack>
|
|
1138
|
+
<VStack gap={2}>
|
|
1139
|
+
<Text size='xs' color='secondary'>
|
|
1140
|
+
Provider
|
|
1141
|
+
</Text>
|
|
1142
|
+
<Text size='sm'>{row.original.sourceProvider}</Text>
|
|
1143
|
+
</VStack>
|
|
1144
|
+
<VStack gap={2}>
|
|
1145
|
+
<Text size='xs' color='secondary'>
|
|
1146
|
+
Endpoint
|
|
1147
|
+
</Text>
|
|
1148
|
+
<HStack gap={4}>
|
|
1149
|
+
<HttpMethod method={row.original.endpointMethod} />
|
|
1150
|
+
<Text size='sm'>{row.original.endpointPath}</Text>
|
|
1151
|
+
</HStack>
|
|
1152
|
+
</VStack>
|
|
1153
|
+
</div>
|
|
1154
|
+
</div>
|
|
1155
|
+
)}
|
|
1156
|
+
/>
|
|
1157
|
+
);
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Right-click inside a cell: `DropdownMenuContextTrigger` puts that column's verbs where the
|
|
1162
|
+
* pointer already is, so each column can offer its own.
|
|
1163
|
+
*/
|
|
1164
|
+
export const ContextMenu: StoryFn<typeof meta> = () => {
|
|
1165
|
+
const contextMenuColumns: TableColumnDef<(typeof securityEvents)[number]>[] = [
|
|
1166
|
+
securityColumnHelper.accessor('objectName', {
|
|
1167
|
+
header: 'Object name',
|
|
1168
|
+
cell: ({ getValue }) => (
|
|
1169
|
+
<DropdownMenu>
|
|
1170
|
+
<DropdownMenuContextTrigger>
|
|
1171
|
+
<Text size='sm'>{getValue()}</Text>
|
|
1172
|
+
</DropdownMenuContextTrigger>
|
|
1173
|
+
<DropdownMenuContent>
|
|
1174
|
+
<DropdownMenuItem onSelect={onCopyValue}>
|
|
1175
|
+
<DropdownMenuItemIcon>
|
|
1176
|
+
<Copy />
|
|
1177
|
+
</DropdownMenuItemIcon>
|
|
1178
|
+
<DropdownMenuItemText>Copy value</DropdownMenuItemText>
|
|
1179
|
+
</DropdownMenuItem>
|
|
1180
|
+
|
|
1181
|
+
<DropdownMenuSeparator />
|
|
1182
|
+
|
|
1183
|
+
<DropdownMenuItem onSelect={onShowOnly}>
|
|
1184
|
+
<DropdownMenuItemIcon>
|
|
1185
|
+
<Filter />
|
|
1186
|
+
</DropdownMenuItemIcon>
|
|
1187
|
+
<DropdownMenuItemContent>
|
|
1188
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
1189
|
+
<DropdownMenuItemDescription>Filter to matching rows</DropdownMenuItemDescription>
|
|
1190
|
+
</DropdownMenuItemContent>
|
|
1191
|
+
</DropdownMenuItem>
|
|
1192
|
+
|
|
1193
|
+
<DropdownMenuItem onSelect={onExclude}>
|
|
1194
|
+
<DropdownMenuItemIcon>
|
|
1195
|
+
<FilterX />
|
|
1196
|
+
</DropdownMenuItemIcon>
|
|
1197
|
+
<DropdownMenuItemContent>
|
|
1198
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
1199
|
+
<DropdownMenuItemDescription>Filter out matching rows</DropdownMenuItemDescription>
|
|
1200
|
+
</DropdownMenuItemContent>
|
|
1201
|
+
</DropdownMenuItem>
|
|
1202
|
+
</DropdownMenuContent>
|
|
1203
|
+
</DropdownMenu>
|
|
1204
|
+
),
|
|
1205
|
+
}),
|
|
1206
|
+
securityColumnHelper.accessor('parameter', {
|
|
1207
|
+
header: 'Parameters',
|
|
1208
|
+
cell: ({ getValue }) => (
|
|
1209
|
+
<DropdownMenu>
|
|
1210
|
+
<DropdownMenuContextTrigger>
|
|
1211
|
+
<InlineCodeSnippet code={getValue()} size='sm' copyable={false} />
|
|
1212
|
+
</DropdownMenuContextTrigger>
|
|
1213
|
+
<DropdownMenuContent>
|
|
1214
|
+
<DropdownMenuItem onSelect={onExecute}>
|
|
1215
|
+
<DropdownMenuItemIcon>
|
|
1216
|
+
<Copy />
|
|
1217
|
+
</DropdownMenuItemIcon>
|
|
1218
|
+
<DropdownMenuItemText>Execute</DropdownMenuItemText>
|
|
1219
|
+
</DropdownMenuItem>
|
|
1220
|
+
</DropdownMenuContent>
|
|
1221
|
+
</DropdownMenu>
|
|
1222
|
+
),
|
|
1223
|
+
}),
|
|
1224
|
+
securityColumnHelper.accessor('status', {
|
|
1225
|
+
header: 'Status',
|
|
1226
|
+
cell: ({ getValue }) => {
|
|
1227
|
+
const status = getValue();
|
|
1228
|
+
return (
|
|
1229
|
+
<Badge
|
|
1230
|
+
variant='dotted'
|
|
1231
|
+
color={status === 'Blocked' ? 'red' : 'yellow'}
|
|
1232
|
+
type='secondary'
|
|
1233
|
+
size='medium'
|
|
1234
|
+
>
|
|
1235
|
+
{status}
|
|
1236
|
+
</Badge>
|
|
1237
|
+
);
|
|
1238
|
+
},
|
|
1239
|
+
}),
|
|
1240
|
+
securityColumnHelper.accessor('cweId', {
|
|
1241
|
+
header: 'Security info',
|
|
1242
|
+
cell: ({ getValue }) => (
|
|
1243
|
+
<Badge color='rose' type='secondary' size='medium' textVariant='code'>
|
|
1244
|
+
{getValue()}
|
|
1245
|
+
</Badge>
|
|
1246
|
+
),
|
|
1247
|
+
}),
|
|
1248
|
+
];
|
|
1249
|
+
|
|
1250
|
+
return <Table data={securityEvents} columns={contextMenuColumns} getRowId={row => row.id} />;
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The gear at the end of the header — search the columns, show and hide them, reorder them,
|
|
1255
|
+
* reset — writing the same visibility and order state as the header menu.
|
|
1256
|
+
*/
|
|
1257
|
+
export const SettingsMenu: StoryFn<typeof meta> = () => {
|
|
1258
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
1259
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
1260
|
+
|
|
1261
|
+
return (
|
|
1262
|
+
<Table
|
|
1263
|
+
data={securityEvents}
|
|
1264
|
+
columns={securityColumns}
|
|
1265
|
+
getRowId={row => row.id}
|
|
1266
|
+
columnVisibility={columnVisibility}
|
|
1267
|
+
onColumnVisibilityChange={setColumnVisibility}
|
|
1268
|
+
columnOrder={columnOrder}
|
|
1269
|
+
onColumnOrderChange={setColumnOrder}
|
|
1270
|
+
defaultColumnVisibility={{}}
|
|
1271
|
+
defaultColumnOrder={securityColumnIds}
|
|
1272
|
+
/>
|
|
1273
|
+
);
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* `deferColumnVisibility` holds switch toggles and "Reset to default" while the
|
|
1278
|
+
* settings menu is open and applies them as one `columnVisibility` change on
|
|
1279
|
+
* close — for consumers that fetch a column's data only once the menu closes,
|
|
1280
|
+
* so a new column never sits empty behind the open menu.
|
|
1281
|
+
*/
|
|
1282
|
+
export const SettingsMenuDeferred: StoryFn<typeof meta> = () => {
|
|
1283
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
1284
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
1285
|
+
|
|
1286
|
+
return (
|
|
1287
|
+
<Table
|
|
1288
|
+
data={securityEvents}
|
|
1289
|
+
columns={securityColumns}
|
|
1290
|
+
getRowId={row => row.id}
|
|
1291
|
+
columnVisibility={columnVisibility}
|
|
1292
|
+
onColumnVisibilityChange={setColumnVisibility}
|
|
1293
|
+
columnOrder={columnOrder}
|
|
1294
|
+
onColumnOrderChange={setColumnOrder}
|
|
1295
|
+
defaultColumnVisibility={{}}
|
|
1296
|
+
defaultColumnOrder={securityColumnIds}
|
|
1297
|
+
deferColumnVisibility
|
|
1298
|
+
/>
|
|
1299
|
+
);
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* `columnGroups` turns the settings menu into labeled, non-reorderable sections
|
|
1304
|
+
* (the FilterInput field-menu pattern): no drag handle, columns rendered under
|
|
1305
|
+
* their group header in the given order. Column ids absent from every group
|
|
1306
|
+
* (`cweId` here) fall into a trailing headerless section.
|
|
1307
|
+
*/
|
|
1308
|
+
export const SettingsMenuGrouped: StoryFn<typeof meta> = () => {
|
|
1309
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
1310
|
+
|
|
1311
|
+
const columnGroups: TableColumnGroup[] = [
|
|
1312
|
+
{
|
|
1313
|
+
label: 'Threat classification',
|
|
1314
|
+
columns: ['objectName', 'status', 'firstDetected', 'lastSeen'],
|
|
1315
|
+
},
|
|
1316
|
+
{ label: 'Request features', columns: ['sourceIp', 'parameter', 'endpoint', 'requests'] },
|
|
1317
|
+
];
|
|
1318
|
+
|
|
1319
|
+
return (
|
|
1320
|
+
<Table
|
|
1321
|
+
data={securityEvents}
|
|
1322
|
+
columns={securityColumns}
|
|
1323
|
+
getRowId={row => row.id}
|
|
1324
|
+
columnVisibility={columnVisibility}
|
|
1325
|
+
onColumnVisibilityChange={setColumnVisibility}
|
|
1326
|
+
columnGroups={columnGroups}
|
|
1327
|
+
/>
|
|
1328
|
+
);
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* `virtualized='container'` inside a fixed height renders only the rows in view, which is what
|
|
1333
|
+
* lets a thousand rows behave like ten.
|
|
1334
|
+
*/
|
|
1335
|
+
export const Virtualization: StoryFn<typeof meta> = () => {
|
|
1336
|
+
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
|
|
1337
|
+
|
|
1338
|
+
return (
|
|
1339
|
+
<Table
|
|
1340
|
+
className='h-500'
|
|
1341
|
+
data={largeData}
|
|
1342
|
+
columns={securityColumns}
|
|
1343
|
+
getRowId={row => row.id}
|
|
1344
|
+
virtualized='container'
|
|
1345
|
+
/>
|
|
1346
|
+
);
|
|
1347
|
+
};
|
|
1348
|
+
Virtualization.tags = ['!autodocs'];
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* `virtualized='window'` measures against the page's own scroll instead, for a table that is
|
|
1352
|
+
* the page rather than a panel inside one.
|
|
1353
|
+
*/
|
|
1354
|
+
export const WindowVirtualization: StoryFn<typeof meta> = () => {
|
|
1355
|
+
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
|
|
1356
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
1357
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
1358
|
+
|
|
1359
|
+
return (
|
|
1360
|
+
<Table
|
|
1361
|
+
data={largeData}
|
|
1362
|
+
columns={securityColumns}
|
|
1363
|
+
getRowId={row => row.id}
|
|
1364
|
+
virtualized='window'
|
|
1365
|
+
columnOrder={columnOrder}
|
|
1366
|
+
onColumnOrderChange={setColumnOrder}
|
|
1367
|
+
columnSizing={columnSizing}
|
|
1368
|
+
onColumnSizingChange={setColumnSizing}
|
|
1369
|
+
/>
|
|
1370
|
+
);
|
|
1371
|
+
};
|
|
1372
|
+
WindowVirtualization.tags = ['!autodocs'];
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The imperative escape hatch: `scrollToRow(id, { align })` off the ref, returning false for
|
|
1376
|
+
* an id the table does not have so a deep link can fall back.
|
|
1377
|
+
*/
|
|
1378
|
+
export const ScrollToRow: StoryFn<typeof meta> = () => {
|
|
1379
|
+
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
|
|
1380
|
+
const tableRef = useRef<TableHandle>(null);
|
|
1381
|
+
const [lastResult, setLastResult] = useState<string | null>(null);
|
|
1382
|
+
|
|
1383
|
+
const scroll = (index: number, align: 'start' | 'center' | 'end' | 'auto' = 'center') => {
|
|
1384
|
+
const row = largeData[index];
|
|
1385
|
+
if (!row) return;
|
|
1386
|
+
const ok = tableRef.current?.scrollToRow(row.id, { align, behavior: 'smooth' }) ?? false;
|
|
1387
|
+
setLastResult(`scrollToRow("${row.id}", { align: "${align}" }) → ${ok}`);
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
return (
|
|
1391
|
+
<VStack gap={8}>
|
|
1392
|
+
<HStack gap={8}>
|
|
1393
|
+
<Button variant='outline' color='neutral' onClick={() => scroll(0, 'start')}>
|
|
1394
|
+
Top
|
|
1395
|
+
</Button>
|
|
1396
|
+
<Button variant='outline' color='neutral' onClick={() => scroll(499, 'center')}>
|
|
1397
|
+
Middle
|
|
1398
|
+
</Button>
|
|
1399
|
+
<Button variant='outline' color='neutral' onClick={() => scroll(999, 'end')}>
|
|
1400
|
+
Bottom
|
|
1401
|
+
</Button>
|
|
1402
|
+
<Button
|
|
1403
|
+
variant='secondary'
|
|
1404
|
+
color='neutral'
|
|
1405
|
+
onClick={() => {
|
|
1406
|
+
const ok = tableRef.current?.scrollToRow('does-not-exist') ?? false;
|
|
1407
|
+
setLastResult(`scrollToRow("does-not-exist") → ${ok}`);
|
|
1408
|
+
}}
|
|
1409
|
+
>
|
|
1410
|
+
Missing id (returns false)
|
|
1411
|
+
</Button>
|
|
1412
|
+
</HStack>
|
|
1413
|
+
{lastResult && (
|
|
1414
|
+
<Text size='sm' color='secondary'>
|
|
1415
|
+
{lastResult}
|
|
1416
|
+
</Text>
|
|
1417
|
+
)}
|
|
1418
|
+
<Table
|
|
1419
|
+
ref={tableRef}
|
|
1420
|
+
className='h-500'
|
|
1421
|
+
data={largeData}
|
|
1422
|
+
columns={securityColumns}
|
|
1423
|
+
getRowId={row => row.id}
|
|
1424
|
+
virtualized='container'
|
|
1425
|
+
/>
|
|
1426
|
+
</VStack>
|
|
1427
|
+
);
|
|
1428
|
+
};
|
|
1429
|
+
ScrollToRow.tags = ['!autodocs'];
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* `onEndReached` with `isLoading` — the house default for a long collection, where rows arrive
|
|
1433
|
+
* as you reach them and there is no page number to lose.
|
|
1434
|
+
*/
|
|
1435
|
+
export const InfiniteScroll: StoryFn<typeof meta> = () => {
|
|
1436
|
+
const { data, isFetching, hasMore, totalItems, fetchNextPage } = useInfiniteData();
|
|
1437
|
+
|
|
1438
|
+
return (
|
|
1439
|
+
<VStack gap={8}>
|
|
1440
|
+
<Text size='sm' color='secondary'>
|
|
1441
|
+
Loaded {data.length} of {totalItems} rows {isFetching && '— loading...'}
|
|
1442
|
+
{!hasMore && ' — all loaded'}
|
|
1443
|
+
</Text>
|
|
1444
|
+
<Table
|
|
1445
|
+
className='h-500'
|
|
1446
|
+
data={data}
|
|
1447
|
+
columns={securityColumns}
|
|
1448
|
+
getRowId={row => row.id}
|
|
1449
|
+
virtualized='container'
|
|
1450
|
+
isLoading={isFetching}
|
|
1451
|
+
onEndReached={fetchNextPage}
|
|
1452
|
+
onEndReachedThreshold={200}
|
|
1453
|
+
/>
|
|
1454
|
+
</VStack>
|
|
1455
|
+
);
|
|
1456
|
+
};
|
|
1457
|
+
InfiniteScroll.tags = ['!autodocs'];
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The same fetch-on-approach against the page's scroll, where the trigger has to account for
|
|
1461
|
+
* everything else on the page as well as the table.
|
|
1462
|
+
*/
|
|
1463
|
+
export const InfiniteScrollWindow: StoryFn<typeof meta> = () => {
|
|
1464
|
+
const { data, isFetching, hasMore, totalItems, fetchNextPage } = useInfiniteData();
|
|
1465
|
+
|
|
1466
|
+
return (
|
|
1467
|
+
<VStack gap={8}>
|
|
1468
|
+
<Text size='sm' color='secondary'>
|
|
1469
|
+
Loaded {data.length} of {totalItems} rows {isFetching && '— loading...'}
|
|
1470
|
+
{!hasMore && ' — all loaded'}
|
|
1471
|
+
</Text>
|
|
1472
|
+
<Table
|
|
1473
|
+
data={data}
|
|
1474
|
+
columns={securityColumns}
|
|
1475
|
+
getRowId={row => row.id}
|
|
1476
|
+
virtualized='window'
|
|
1477
|
+
isLoading={isFetching}
|
|
1478
|
+
onEndReached={fetchNextPage}
|
|
1479
|
+
onEndReachedThreshold={300}
|
|
1480
|
+
/>
|
|
1481
|
+
</VStack>
|
|
1482
|
+
);
|
|
1483
|
+
};
|
|
1484
|
+
InfiniteScrollWindow.tags = ['!autodocs'];
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Both ends at once: `onStartReached` prepends, and the scroll position is compensated so the
|
|
1488
|
+
* row you were reading does not slide out from under you.
|
|
1489
|
+
*/
|
|
1490
|
+
export const BidirectionalInfiniteScroll: StoryFn<typeof meta> = () => {
|
|
1491
|
+
const {
|
|
1492
|
+
data,
|
|
1493
|
+
anchorId,
|
|
1494
|
+
isFetchingPrev,
|
|
1495
|
+
isFetchingNext,
|
|
1496
|
+
hasPrev,
|
|
1497
|
+
hasNext,
|
|
1498
|
+
fetchPrevPage,
|
|
1499
|
+
fetchNextPage,
|
|
1500
|
+
} = useBidirectionalData();
|
|
1501
|
+
|
|
1502
|
+
return (
|
|
1503
|
+
<VStack gap={8}>
|
|
1504
|
+
<Text size='sm' color='secondary'>
|
|
1505
|
+
Window of {data.length} rows around the anchor — scroll up for top skeletons, down for
|
|
1506
|
+
bottom ones{(isFetchingPrev || isFetchingNext) && ' — loading...'}
|
|
1507
|
+
{!hasPrev && ' — top reached'}
|
|
1508
|
+
{!hasNext && ' — bottom reached'}
|
|
1509
|
+
</Text>
|
|
1510
|
+
<Table
|
|
1511
|
+
className='h-500'
|
|
1512
|
+
data={data}
|
|
1513
|
+
columns={securityColumns}
|
|
1514
|
+
getRowId={row => row.id}
|
|
1515
|
+
virtualized='container'
|
|
1516
|
+
isLoading={isFetchingNext}
|
|
1517
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1518
|
+
initialScrollToRowId={anchorId}
|
|
1519
|
+
onStartReached={fetchPrevPage}
|
|
1520
|
+
onStartReachedThreshold={200}
|
|
1521
|
+
onEndReached={fetchNextPage}
|
|
1522
|
+
onEndReachedThreshold={200}
|
|
1523
|
+
/>
|
|
1524
|
+
</VStack>
|
|
1525
|
+
);
|
|
1526
|
+
};
|
|
1527
|
+
BidirectionalInfiniteScroll.tags = ['!autodocs'];
|
|
1528
|
+
|
|
1529
|
+
// Same dataset/anchor as BidirectionalInfiniteScroll, but the document itself
|
|
1530
|
+
// scrolls — the regression mode for prepend compensation, where unrelated page
|
|
1531
|
+
// content shares the scrollHeight the old delta diffed against.
|
|
1532
|
+
/**
|
|
1533
|
+
* The prepend case against the document scroll, where unrelated page content shares the height
|
|
1534
|
+
* being measured — the regression this story exists to catch.
|
|
1535
|
+
*/
|
|
1536
|
+
export const BidirectionalInfiniteScrollWindow: StoryFn<typeof meta> = () => {
|
|
1537
|
+
const {
|
|
1538
|
+
data,
|
|
1539
|
+
anchorId,
|
|
1540
|
+
isFetchingPrev,
|
|
1541
|
+
isFetchingNext,
|
|
1542
|
+
hasPrev,
|
|
1543
|
+
hasNext,
|
|
1544
|
+
fetchPrevPage,
|
|
1545
|
+
fetchNextPage,
|
|
1546
|
+
} = useBidirectionalData();
|
|
1547
|
+
|
|
1548
|
+
return (
|
|
1549
|
+
<VStack gap={8}>
|
|
1550
|
+
<Text size='sm' color='secondary'>
|
|
1551
|
+
Window of {data.length} rows around the anchor
|
|
1552
|
+
{(isFetchingPrev || isFetchingNext) && ' — loading...'}
|
|
1553
|
+
{!hasPrev && ' — top reached'}
|
|
1554
|
+
{!hasNext && ' — bottom reached'}
|
|
1555
|
+
</Text>
|
|
1556
|
+
<Table
|
|
1557
|
+
data={data}
|
|
1558
|
+
columns={securityColumns}
|
|
1559
|
+
getRowId={row => row.id}
|
|
1560
|
+
virtualized='window'
|
|
1561
|
+
isLoading={isFetchingNext}
|
|
1562
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1563
|
+
initialScrollToRowId={anchorId}
|
|
1564
|
+
onStartReached={fetchPrevPage}
|
|
1565
|
+
onStartReachedThreshold={200}
|
|
1566
|
+
onEndReached={fetchNextPage}
|
|
1567
|
+
onEndReachedThreshold={200}
|
|
1568
|
+
/>
|
|
1569
|
+
</VStack>
|
|
1570
|
+
);
|
|
1571
|
+
};
|
|
1572
|
+
BidirectionalInfiniteScrollWindow.tags = ['!autodocs'];
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* `virtualized='window'` in an app shell that scrolls a content pane instead of the document: the
|
|
1576
|
+
* table follows the pane. The `overflow-x: hidden` wrapper in between never scrolls (its
|
|
1577
|
+
* `overflow-y` only computes to `auto`), so it must not be taken for the scroll root.
|
|
1578
|
+
*/
|
|
1579
|
+
export const InfiniteScrollWindowInPane: StoryFn<typeof meta> = () => {
|
|
1580
|
+
const { data, isFetchingPrev, isFetchingNext, hasPrev, hasNext, fetchPrevPage, fetchNextPage } =
|
|
1581
|
+
useBidirectionalData();
|
|
1582
|
+
|
|
1583
|
+
return (
|
|
1584
|
+
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
|
|
1585
|
+
<div style={{ overflowX: 'hidden' }}>
|
|
1586
|
+
<VStack gap={8}>
|
|
1587
|
+
<Text size='sm' color='secondary'>
|
|
1588
|
+
Window of {data.length} rows around the anchor
|
|
1589
|
+
{(isFetchingPrev || isFetchingNext) && ' — loading...'}
|
|
1590
|
+
{!hasPrev && ' — top reached'}
|
|
1591
|
+
{!hasNext && ' — bottom reached'}
|
|
1592
|
+
</Text>
|
|
1593
|
+
<Table
|
|
1594
|
+
data={data}
|
|
1595
|
+
columns={securityColumns}
|
|
1596
|
+
getRowId={row => row.id}
|
|
1597
|
+
virtualized='window'
|
|
1598
|
+
isLoading={isFetchingNext}
|
|
1599
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1600
|
+
onStartReached={fetchPrevPage}
|
|
1601
|
+
onStartReachedThreshold={200}
|
|
1602
|
+
onEndReached={fetchNextPage}
|
|
1603
|
+
onEndReachedThreshold={200}
|
|
1604
|
+
/>
|
|
1605
|
+
</VStack>
|
|
1606
|
+
</div>
|
|
1607
|
+
</div>
|
|
1608
|
+
);
|
|
1609
|
+
};
|
|
1610
|
+
InfiniteScrollWindowInPane.tags = ['!autodocs'];
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* A share-link landing in a pane host: the window-mode table opens scrolled to its anchor row,
|
|
1614
|
+
* inside the pane — the document stays put.
|
|
1615
|
+
*/
|
|
1616
|
+
export const InfiniteScrollWindowInPaneAnchored: StoryFn<typeof meta> = () => {
|
|
1617
|
+
const { data, anchorId, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
|
|
1618
|
+
useBidirectionalData();
|
|
1619
|
+
|
|
1620
|
+
return (
|
|
1621
|
+
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
|
|
1622
|
+
<Table
|
|
1623
|
+
data={data}
|
|
1624
|
+
columns={securityColumns}
|
|
1625
|
+
getRowId={row => row.id}
|
|
1626
|
+
virtualized='window'
|
|
1627
|
+
isLoading={isFetchingNext}
|
|
1628
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1629
|
+
initialScrollToRowId={anchorId}
|
|
1630
|
+
onStartReached={fetchPrevPage}
|
|
1631
|
+
onStartReachedThreshold={200}
|
|
1632
|
+
onEndReached={fetchNextPage}
|
|
1633
|
+
onEndReachedThreshold={200}
|
|
1634
|
+
/>
|
|
1635
|
+
</div>
|
|
1636
|
+
);
|
|
1637
|
+
};
|
|
1638
|
+
InfiniteScrollWindowInPaneAnchored.tags = ['!autodocs'];
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* A pane host that mounts the table before its data: rows arrive after a delay. Neither edge may
|
|
1642
|
+
* fire against the window meanwhile — only the edge the user scrolls to.
|
|
1643
|
+
*/
|
|
1644
|
+
export const InfiniteScrollWindowInPaneLateRows: StoryFn<typeof meta> = () => {
|
|
1645
|
+
const { data, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
|
|
1646
|
+
useBidirectionalData();
|
|
1647
|
+
const [hasLoaded, setHasLoaded] = useState(false);
|
|
1648
|
+
const [calls, setCalls] = useState({ start: 0, end: 0 });
|
|
1649
|
+
useEffect(() => {
|
|
1650
|
+
const timer = setTimeout(() => setHasLoaded(true), 500);
|
|
1651
|
+
return () => clearTimeout(timer);
|
|
1652
|
+
}, []);
|
|
1653
|
+
|
|
1654
|
+
return (
|
|
1655
|
+
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
|
|
1656
|
+
<Text size='sm' color='secondary'>
|
|
1657
|
+
Edge calls: start {calls.start}, end {calls.end}
|
|
1658
|
+
</Text>
|
|
1659
|
+
<Table
|
|
1660
|
+
data={hasLoaded ? data : []}
|
|
1661
|
+
columns={securityColumns}
|
|
1662
|
+
getRowId={row => row.id}
|
|
1663
|
+
virtualized='window'
|
|
1664
|
+
isLoading={!hasLoaded || isFetchingNext}
|
|
1665
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1666
|
+
// Nothing to page from before the first page — as with a real cursor.
|
|
1667
|
+
onStartReached={
|
|
1668
|
+
hasLoaded
|
|
1669
|
+
? () => {
|
|
1670
|
+
setCalls(prev => ({ ...prev, start: prev.start + 1 }));
|
|
1671
|
+
fetchPrevPage();
|
|
1672
|
+
}
|
|
1673
|
+
: undefined
|
|
1674
|
+
}
|
|
1675
|
+
onStartReachedThreshold={200}
|
|
1676
|
+
onEndReached={
|
|
1677
|
+
hasLoaded
|
|
1678
|
+
? () => {
|
|
1679
|
+
setCalls(prev => ({ ...prev, end: prev.end + 1 }));
|
|
1680
|
+
fetchNextPage();
|
|
1681
|
+
}
|
|
1682
|
+
: undefined
|
|
1683
|
+
}
|
|
1684
|
+
onEndReachedThreshold={200}
|
|
1685
|
+
/>
|
|
1686
|
+
</div>
|
|
1687
|
+
);
|
|
1688
|
+
};
|
|
1689
|
+
InfiniteScrollWindowInPaneLateRows.tags = ['!autodocs'];
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* A share-link landing in a pane that starts hidden (a closed tab or drawer): the anchor waits
|
|
1693
|
+
* until the table is shown and laid out, then lands inside the pane.
|
|
1694
|
+
*/
|
|
1695
|
+
export const InfiniteScrollWindowInHiddenPaneAnchored: StoryFn<typeof meta> = () => {
|
|
1696
|
+
const [isShown, setIsShown] = useState(false);
|
|
1697
|
+
const { data, anchorId, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
|
|
1698
|
+
useBidirectionalData();
|
|
1699
|
+
|
|
1700
|
+
return (
|
|
1701
|
+
<VStack gap={8}>
|
|
1702
|
+
<Button data-testid='show-table' onClick={() => setIsShown(true)}>
|
|
1703
|
+
Show table
|
|
1704
|
+
</Button>
|
|
1705
|
+
<div
|
|
1706
|
+
data-testid='scroll-pane'
|
|
1707
|
+
style={{ height: 480, overflowY: 'auto', display: isShown ? 'block' : 'none' }}
|
|
1708
|
+
>
|
|
1709
|
+
<Table
|
|
1710
|
+
data={data}
|
|
1711
|
+
columns={securityColumns}
|
|
1712
|
+
getRowId={row => row.id}
|
|
1713
|
+
virtualized='window'
|
|
1714
|
+
isLoading={isFetchingNext}
|
|
1715
|
+
isLoadingPrevious={isFetchingPrev}
|
|
1716
|
+
initialScrollToRowId={anchorId}
|
|
1717
|
+
onStartReached={fetchPrevPage}
|
|
1718
|
+
onStartReachedThreshold={200}
|
|
1719
|
+
onEndReached={fetchNextPage}
|
|
1720
|
+
onEndReachedThreshold={200}
|
|
1721
|
+
/>
|
|
1722
|
+
</div>
|
|
1723
|
+
</VStack>
|
|
1724
|
+
);
|
|
1725
|
+
};
|
|
1726
|
+
InfiniteScrollWindowInHiddenPaneAnchored.tags = ['!autodocs'];
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* A window-mode table mounting into a pane the user already scrolled (content above it): the pane
|
|
1730
|
+
* keeps its position — adopting the root must not reset it.
|
|
1731
|
+
*/
|
|
1732
|
+
export const InfiniteScrollWindowInScrolledPane: StoryFn<typeof meta> = () => {
|
|
1733
|
+
const [isShown, setIsShown] = useState(false);
|
|
1734
|
+
const { data, isFetchingNext, fetchNextPage } = useBidirectionalData();
|
|
1735
|
+
|
|
1736
|
+
return (
|
|
1737
|
+
<VStack gap={8}>
|
|
1738
|
+
<Button data-testid='show-table' onClick={() => setIsShown(true)}>
|
|
1739
|
+
Show table
|
|
1740
|
+
</Button>
|
|
1741
|
+
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
|
|
1742
|
+
<div style={{ height: 800 }}>
|
|
1743
|
+
<Text size='sm' color='secondary'>
|
|
1744
|
+
Content above the table
|
|
1745
|
+
</Text>
|
|
1746
|
+
</div>
|
|
1747
|
+
{isShown && (
|
|
1748
|
+
<Table
|
|
1749
|
+
data={data}
|
|
1750
|
+
columns={securityColumns}
|
|
1751
|
+
getRowId={row => row.id}
|
|
1752
|
+
virtualized='window'
|
|
1753
|
+
isLoading={isFetchingNext}
|
|
1754
|
+
onEndReached={fetchNextPage}
|
|
1755
|
+
onEndReachedThreshold={200}
|
|
1756
|
+
/>
|
|
1757
|
+
)}
|
|
1758
|
+
</div>
|
|
1759
|
+
</VStack>
|
|
1760
|
+
);
|
|
1761
|
+
};
|
|
1762
|
+
InfiniteScrollWindowInScrolledPane.tags = ['!autodocs'];
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* A column can explain itself: `description` renders as a second line under the title, or as a
|
|
1766
|
+
* tooltip behind a dashed underline when the header has no room for one.
|
|
1767
|
+
*/
|
|
1768
|
+
export const HeaderColumnDescription: StoryFn<typeof meta> = () => {
|
|
1769
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
1770
|
+
|
|
1771
|
+
const columns = useMemo<TableColumnDef<(typeof securityEvents)[number]>[]>(
|
|
1772
|
+
() =>
|
|
1773
|
+
securityColumns.map(col => {
|
|
1774
|
+
const key = 'accessorKey' in col ? col.accessorKey : undefined;
|
|
1775
|
+
if (key === 'objectName') {
|
|
1776
|
+
return {
|
|
1777
|
+
...col,
|
|
1778
|
+
meta: {
|
|
1779
|
+
...col.meta,
|
|
1780
|
+
description: { type: 'text' as const, content: 'Target resource' },
|
|
1781
|
+
},
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
if (key === 'sourceIp') {
|
|
1785
|
+
return {
|
|
1786
|
+
...col,
|
|
1787
|
+
meta: {
|
|
1788
|
+
...col.meta,
|
|
1789
|
+
description: { type: 'tooltip' as const, content: 'Request origin IP' },
|
|
1790
|
+
},
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
if (key === 'requests') {
|
|
1794
|
+
return {
|
|
1795
|
+
...col,
|
|
1796
|
+
meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Total hits' } },
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
if (key === 'parameter') {
|
|
1800
|
+
return {
|
|
1801
|
+
...col,
|
|
1802
|
+
meta: {
|
|
1803
|
+
...col.meta,
|
|
1804
|
+
description: { type: 'text' as const, content: 'Affected param' },
|
|
1805
|
+
},
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
return col;
|
|
1809
|
+
}),
|
|
1810
|
+
[],
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
return (
|
|
1814
|
+
<Table
|
|
1815
|
+
data={securityEvents}
|
|
1816
|
+
columns={columns}
|
|
1817
|
+
getRowId={row => row.id}
|
|
1818
|
+
sorting={sorting}
|
|
1819
|
+
onSortingChange={setSorting}
|
|
1820
|
+
/>
|
|
1821
|
+
);
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* The first column is the master cell — pinned, and the place per-row actions appear on hover,
|
|
1826
|
+
* so every row keeps its verbs in one predictable spot.
|
|
1827
|
+
*/
|
|
1828
|
+
export const MasterCellWithActions: StoryFn<typeof meta> = () => {
|
|
1829
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
1830
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
1831
|
+
|
|
1832
|
+
const data = useMemo(() => multiplySecurityEvents(), []);
|
|
1833
|
+
|
|
1834
|
+
const columns = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
1835
|
+
() =>
|
|
1836
|
+
securityColumns.map((col, i) =>
|
|
1837
|
+
i === 0
|
|
1838
|
+
? {
|
|
1839
|
+
...col,
|
|
1840
|
+
meta: {
|
|
1841
|
+
...col.meta,
|
|
1842
|
+
size: 400,
|
|
1843
|
+
resizeType: 'resize',
|
|
1844
|
+
renderMenuAction: (row: { original: SecurityEvent }) => (
|
|
1845
|
+
<DropdownMenu>
|
|
1846
|
+
<DropdownMenuTrigger asChild>
|
|
1847
|
+
<Button variant='ghost' color='neutral' size='small' aria-label='More'>
|
|
1848
|
+
<Ellipsis />
|
|
1849
|
+
</Button>
|
|
1850
|
+
</DropdownMenuTrigger>
|
|
1851
|
+
<DropdownMenuContent>
|
|
1852
|
+
<DropdownMenuItem
|
|
1853
|
+
onSelect={() => navigator.clipboard.writeText(row.original.objectName)}
|
|
1854
|
+
>
|
|
1855
|
+
<DropdownMenuItemIcon>
|
|
1856
|
+
<Copy />
|
|
1857
|
+
</DropdownMenuItemIcon>
|
|
1858
|
+
<DropdownMenuItemText>Copy name</DropdownMenuItemText>
|
|
1859
|
+
</DropdownMenuItem>
|
|
1860
|
+
<DropdownMenuSeparator />
|
|
1861
|
+
<DropdownMenuItem onSelect={() => onShowOnly(row.original.objectName)}>
|
|
1862
|
+
<DropdownMenuItemIcon>
|
|
1863
|
+
<Filter />
|
|
1864
|
+
</DropdownMenuItemIcon>
|
|
1865
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
1866
|
+
</DropdownMenuItem>
|
|
1867
|
+
<DropdownMenuItem onSelect={() => onExclude(row.original.objectName)}>
|
|
1868
|
+
<DropdownMenuItemIcon>
|
|
1869
|
+
<FilterX />
|
|
1870
|
+
</DropdownMenuItemIcon>
|
|
1871
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
1872
|
+
</DropdownMenuItem>
|
|
1873
|
+
</DropdownMenuContent>
|
|
1874
|
+
</DropdownMenu>
|
|
1875
|
+
),
|
|
1876
|
+
},
|
|
1877
|
+
}
|
|
1878
|
+
: col,
|
|
1879
|
+
),
|
|
1880
|
+
[],
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
return (
|
|
1884
|
+
<Table
|
|
1885
|
+
className='max-w-920'
|
|
1886
|
+
data={data}
|
|
1887
|
+
columns={columns}
|
|
1888
|
+
getRowId={row => row.id}
|
|
1889
|
+
sorting={sorting}
|
|
1890
|
+
onSortingChange={setSorting}
|
|
1891
|
+
columnSizing={columnSizing}
|
|
1892
|
+
onColumnSizingChange={setColumnSizing}
|
|
1893
|
+
/>
|
|
1894
|
+
);
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* `onMasterCellClick` with `activeRowId`: the master cell opens a `Drawer` and the row it
|
|
1899
|
+
* belongs to stays marked while the panel is open.
|
|
1900
|
+
*/
|
|
1901
|
+
export const MasterCellWithDrawer: StoryFn<typeof meta> = () => {
|
|
1902
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
1903
|
+
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
|
1904
|
+
// The last opened row stays rendered while the drawer animates closed.
|
|
1905
|
+
const [shownRowId, setShownRowId] = useState<string | null>(null);
|
|
1906
|
+
|
|
1907
|
+
const data = useMemo(
|
|
1908
|
+
() =>
|
|
1909
|
+
Array.from({ length: 4 }, (_, batch) =>
|
|
1910
|
+
securityEvents.map(row => ({
|
|
1911
|
+
...row,
|
|
1912
|
+
id: `${row.id}-${batch}`,
|
|
1913
|
+
objectName: batch === 0 ? row.objectName : `${row.objectName} (${batch + 1})`,
|
|
1914
|
+
})),
|
|
1915
|
+
).flat(),
|
|
1916
|
+
[],
|
|
1917
|
+
);
|
|
1918
|
+
|
|
1919
|
+
const columns = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
1920
|
+
() =>
|
|
1921
|
+
securityColumns.map((col, i) =>
|
|
1922
|
+
i === 0
|
|
1923
|
+
? {
|
|
1924
|
+
...col,
|
|
1925
|
+
cell: ({ row }: { row: { original: SecurityEvent } }) => (
|
|
1926
|
+
<Text size='xs'>{row.original.objectName}</Text>
|
|
1927
|
+
),
|
|
1928
|
+
}
|
|
1929
|
+
: col,
|
|
1930
|
+
),
|
|
1931
|
+
[],
|
|
1932
|
+
);
|
|
1933
|
+
|
|
1934
|
+
const handleMasterCellClick = useCallback((rowId: string) => {
|
|
1935
|
+
setActiveRowId(prev => (prev === rowId ? null : rowId));
|
|
1936
|
+
setShownRowId(rowId);
|
|
1937
|
+
}, []);
|
|
1938
|
+
|
|
1939
|
+
const handleOpenChange = (open: boolean) => {
|
|
1940
|
+
if (!open) setActiveRowId(null);
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
const shownRow = data.find(d => d.id === shownRowId);
|
|
1944
|
+
|
|
1945
|
+
return (
|
|
1946
|
+
<>
|
|
1947
|
+
<Table
|
|
1948
|
+
className='max-w-920'
|
|
1949
|
+
data={data}
|
|
1950
|
+
columns={columns}
|
|
1951
|
+
getRowId={row => row.id}
|
|
1952
|
+
sorting={sorting}
|
|
1953
|
+
onSortingChange={setSorting}
|
|
1954
|
+
onMasterCellClick={handleMasterCellClick}
|
|
1955
|
+
activeRowId={activeRowId}
|
|
1956
|
+
/>
|
|
1957
|
+
<Drawer
|
|
1958
|
+
open={activeRowId !== null}
|
|
1959
|
+
onOpenChange={handleOpenChange}
|
|
1960
|
+
modal={false}
|
|
1961
|
+
overlay={false}
|
|
1962
|
+
closeOnOutsideClick={false}
|
|
1963
|
+
width={640}
|
|
1964
|
+
>
|
|
1965
|
+
<DrawerContent>
|
|
1966
|
+
<DrawerHeader>
|
|
1967
|
+
<DrawerTitle>{shownRow?.objectName}</DrawerTitle>
|
|
1968
|
+
</DrawerHeader>
|
|
1969
|
+
<DrawerBody>
|
|
1970
|
+
<VStack gap={6}>
|
|
1971
|
+
<Attribute orientation='horizontal'>
|
|
1972
|
+
<AttributeLabel width={140}>Status</AttributeLabel>
|
|
1973
|
+
<AttributeValue>
|
|
1974
|
+
<Badge
|
|
1975
|
+
variant='dotted'
|
|
1976
|
+
type='secondary'
|
|
1977
|
+
color={shownRow?.status === 'Blocked' ? 'red' : 'yellow'}
|
|
1978
|
+
>
|
|
1979
|
+
{shownRow?.status}
|
|
1980
|
+
</Badge>
|
|
1981
|
+
</AttributeValue>
|
|
1982
|
+
</Attribute>
|
|
1983
|
+
<Attribute orientation='horizontal'>
|
|
1984
|
+
<AttributeLabel width={140}>Requests</AttributeLabel>
|
|
1985
|
+
<AttributeValue>{shownRow?.requests.toLocaleString()}</AttributeValue>
|
|
1986
|
+
</Attribute>
|
|
1987
|
+
<Attribute orientation='horizontal'>
|
|
1988
|
+
<AttributeLabel width={140}>First detected</AttributeLabel>
|
|
1989
|
+
<AttributeValue>{shownRow?.firstDetected}</AttributeValue>
|
|
1990
|
+
</Attribute>
|
|
1991
|
+
</VStack>
|
|
1992
|
+
</DrawerBody>
|
|
1993
|
+
</DrawerContent>
|
|
1994
|
+
</Drawer>
|
|
1995
|
+
</>
|
|
1996
|
+
);
|
|
1997
|
+
};
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* Master cell with both an action menu and a drawer:
|
|
2001
|
+
* clicking the cell body opens the drawer, clicking the action button opens the menu
|
|
2002
|
+
* without triggering the drawer.
|
|
2003
|
+
*/
|
|
2004
|
+
export const MasterCellWithActionsAndDrawer: StoryFn<typeof meta> = () => {
|
|
2005
|
+
const [sorting, setSorting] = useState<TableSortingState>([]);
|
|
2006
|
+
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
|
2007
|
+
// The last opened row stays rendered while the drawer animates closed.
|
|
2008
|
+
const [shownRowId, setShownRowId] = useState<string | null>(null);
|
|
2009
|
+
|
|
2010
|
+
const data = useMemo(() => multiplySecurityEvents(), []);
|
|
2011
|
+
|
|
2012
|
+
const columns = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
2013
|
+
() =>
|
|
2014
|
+
securityColumns.map((col, i) =>
|
|
2015
|
+
i === 0
|
|
2016
|
+
? {
|
|
2017
|
+
...col,
|
|
2018
|
+
cell: ({ row }: { row: { original: SecurityEvent } }) => (
|
|
2019
|
+
<Text size='xs'>{row.original.objectName}</Text>
|
|
2020
|
+
),
|
|
2021
|
+
meta: {
|
|
2022
|
+
...col.meta,
|
|
2023
|
+
resizeType: 'resize',
|
|
2024
|
+
renderMenuAction: (row: { original: SecurityEvent }) => (
|
|
2025
|
+
<DropdownMenu>
|
|
2026
|
+
<DropdownMenuTrigger asChild>
|
|
2027
|
+
<Button variant='ghost' color='neutral' size='small' aria-label='More'>
|
|
2028
|
+
<Ellipsis />
|
|
2029
|
+
</Button>
|
|
2030
|
+
</DropdownMenuTrigger>
|
|
2031
|
+
<DropdownMenuContent>
|
|
2032
|
+
<DropdownMenuItem
|
|
2033
|
+
onSelect={() => navigator.clipboard.writeText(row.original.objectName)}
|
|
2034
|
+
>
|
|
2035
|
+
<DropdownMenuItemIcon>
|
|
2036
|
+
<Copy />
|
|
2037
|
+
</DropdownMenuItemIcon>
|
|
2038
|
+
<DropdownMenuItemText>Copy name</DropdownMenuItemText>
|
|
2039
|
+
</DropdownMenuItem>
|
|
2040
|
+
<DropdownMenuSeparator />
|
|
2041
|
+
<DropdownMenuItem onSelect={() => onShowOnly(row.original.objectName)}>
|
|
2042
|
+
<DropdownMenuItemIcon>
|
|
2043
|
+
<Filter />
|
|
2044
|
+
</DropdownMenuItemIcon>
|
|
2045
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
2046
|
+
</DropdownMenuItem>
|
|
2047
|
+
<DropdownMenuItem onSelect={() => onExclude(row.original.objectName)}>
|
|
2048
|
+
<DropdownMenuItemIcon>
|
|
2049
|
+
<FilterX />
|
|
2050
|
+
</DropdownMenuItemIcon>
|
|
2051
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
2052
|
+
</DropdownMenuItem>
|
|
2053
|
+
</DropdownMenuContent>
|
|
2054
|
+
</DropdownMenu>
|
|
2055
|
+
),
|
|
2056
|
+
},
|
|
2057
|
+
}
|
|
2058
|
+
: col,
|
|
2059
|
+
),
|
|
2060
|
+
[],
|
|
2061
|
+
);
|
|
2062
|
+
|
|
2063
|
+
const handleMasterCellClick = useCallback((rowId: string) => {
|
|
2064
|
+
setActiveRowId(prev => (prev === rowId ? null : rowId));
|
|
2065
|
+
setShownRowId(rowId);
|
|
2066
|
+
}, []);
|
|
2067
|
+
|
|
2068
|
+
const handleOpenChange = (open: boolean) => {
|
|
2069
|
+
if (!open) setActiveRowId(null);
|
|
2070
|
+
};
|
|
2071
|
+
|
|
2072
|
+
const shownRow = data.find(d => d.id === shownRowId);
|
|
2073
|
+
|
|
2074
|
+
return (
|
|
2075
|
+
<>
|
|
2076
|
+
<Table
|
|
2077
|
+
className='max-w-920'
|
|
2078
|
+
data={data}
|
|
2079
|
+
columns={columns}
|
|
2080
|
+
getRowId={row => row.id}
|
|
2081
|
+
sorting={sorting}
|
|
2082
|
+
onSortingChange={setSorting}
|
|
2083
|
+
onMasterCellClick={handleMasterCellClick}
|
|
2084
|
+
activeRowId={activeRowId}
|
|
2085
|
+
/>
|
|
2086
|
+
<Drawer
|
|
2087
|
+
open={activeRowId !== null}
|
|
2088
|
+
onOpenChange={handleOpenChange}
|
|
2089
|
+
modal={false}
|
|
2090
|
+
overlay={false}
|
|
2091
|
+
closeOnOutsideClick={false}
|
|
2092
|
+
width={960}
|
|
2093
|
+
>
|
|
2094
|
+
<DrawerContent>
|
|
2095
|
+
{shownRow && renderSecurityPreviewHeader({ original: shownRow })}
|
|
2096
|
+
<DrawerBody>
|
|
2097
|
+
{shownRow && renderSecurityPreviewContent({ original: shownRow })}
|
|
2098
|
+
</DrawerBody>
|
|
2099
|
+
</DrawerContent>
|
|
2100
|
+
</Drawer>
|
|
2101
|
+
</>
|
|
2102
|
+
);
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Grouping, expansion, selection, pinning, virtualization and the action bar in one table —
|
|
2107
|
+
* the honest test of whether the features cooperate rather than merely exist.
|
|
2108
|
+
*/
|
|
2109
|
+
export const FullFeatured: StoryFn<typeof meta> = () => {
|
|
2110
|
+
const data = useMemo(() => createLargeGroupedData(12, 50), []);
|
|
2111
|
+
|
|
2112
|
+
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
|
|
2113
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
2114
|
+
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
|
|
2115
|
+
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({
|
|
2116
|
+
left: ['objectName'],
|
|
2117
|
+
});
|
|
2118
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
2119
|
+
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
|
|
2120
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({
|
|
2121
|
+
'group-0': true,
|
|
2122
|
+
'group-1': true,
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
return (
|
|
2126
|
+
<Table<SecurityHeaderEntry>
|
|
2127
|
+
className='h-500'
|
|
2128
|
+
data={data}
|
|
2129
|
+
columns={fullFeaturedColumns}
|
|
2130
|
+
getRowId={row => row.id}
|
|
2131
|
+
getSubRows={row => row.children}
|
|
2132
|
+
// Grouping
|
|
2133
|
+
expanded={expanded}
|
|
2134
|
+
onExpandedChange={setExpanded}
|
|
2135
|
+
// Sorting
|
|
2136
|
+
sorting={sorting}
|
|
2137
|
+
onSortingChange={setSorting}
|
|
2138
|
+
// Row selection
|
|
2139
|
+
rowSelection={rowSelection}
|
|
2140
|
+
onRowSelectionChange={setRowSelection}
|
|
2141
|
+
// Column resizing
|
|
2142
|
+
columnSizing={columnSizing}
|
|
2143
|
+
onColumnSizingChange={setColumnSizing}
|
|
2144
|
+
// Column pinning
|
|
2145
|
+
columnPinning={columnPinning}
|
|
2146
|
+
onColumnPinningChange={setColumnPinning}
|
|
2147
|
+
// Column DnD ordering
|
|
2148
|
+
columnOrder={columnOrder}
|
|
2149
|
+
onColumnOrderChange={setColumnOrder}
|
|
2150
|
+
// Column visibility + settings
|
|
2151
|
+
columnVisibility={columnVisibility}
|
|
2152
|
+
onColumnVisibilityChange={setColumnVisibility}
|
|
2153
|
+
defaultColumnVisibility={{}}
|
|
2154
|
+
defaultColumnOrder={headerColumnIds}
|
|
2155
|
+
// Virtual scrolling
|
|
2156
|
+
virtualized='container'
|
|
2157
|
+
>
|
|
2158
|
+
<TableActionBar>
|
|
2159
|
+
<Button variant='ghost' color='neutral-alt' onClick={onDuplicate}>
|
|
2160
|
+
<Copy /> Duplicate
|
|
2161
|
+
</Button>
|
|
2162
|
+
<Button color='destructive' onClick={onDelete}>
|
|
2163
|
+
<Trash2 /> Delete
|
|
2164
|
+
</Button>
|
|
2165
|
+
</TableActionBar>
|
|
2166
|
+
</Table>
|
|
2167
|
+
);
|
|
2168
|
+
};
|
|
2169
|
+
FullFeatured.tags = ['!autodocs'];
|
|
2170
|
+
|
|
2171
|
+
// ---------------------------------------------------------------------------
|
|
2172
|
+
// Inline cell editing (WDS-157)
|
|
2173
|
+
// ---------------------------------------------------------------------------
|
|
2174
|
+
// Table has no dedicated "editable cell" API — a column's `cell` renderer may
|
|
2175
|
+
// return any React node. Here that node is the purpose-built `EditableCell`
|
|
2176
|
+
// (see ./EditableCell), which owns the full body-cell box so nothing shifts
|
|
2177
|
+
// across idle / hover / editing: the whole cell highlights grey on hover and
|
|
2178
|
+
// draws a brand-orange border while editing (text) or open (select). The text
|
|
2179
|
+
// editor swaps in a borderless input in place; the select cell IS the trigger,
|
|
2180
|
+
// so its read view never gets replaced.
|
|
2181
|
+
|
|
2182
|
+
const statusItems: SelectDataItem[] = [
|
|
2183
|
+
{ label: 'Blocked', value: 'Blocked' },
|
|
2184
|
+
{ label: 'Monitoring', value: 'Monitoring' },
|
|
2185
|
+
];
|
|
2186
|
+
|
|
2187
|
+
// Empty-by-default select — the placeholder text is supplied by the consumer.
|
|
2188
|
+
const categoryItems: SelectDataItem[] = [
|
|
2189
|
+
{ label: 'Category A', value: 'a' },
|
|
2190
|
+
{ label: 'Category B', value: 'b' },
|
|
2191
|
+
{ label: 'Category C', value: 'c' },
|
|
2192
|
+
{ label: 'Category D', value: 'd' },
|
|
2193
|
+
];
|
|
2194
|
+
|
|
2195
|
+
const StatusBadge: FC<{ value: SecurityEvent['status'] }> = ({ value }) => (
|
|
2196
|
+
<Badge
|
|
2197
|
+
variant='dotted'
|
|
2198
|
+
color={value === 'Blocked' ? 'red' : 'yellow'}
|
|
2199
|
+
type='secondary'
|
|
2200
|
+
size='medium'
|
|
2201
|
+
>
|
|
2202
|
+
{value}
|
|
2203
|
+
</Badge>
|
|
2204
|
+
);
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* `EditableTextCell` and `EditableSelectCell` in the plain columns, with the master column
|
|
2208
|
+
* deliberately read-only — editing in place suits a value the user owns, not one the system
|
|
2209
|
+
* reports. Hovering highlights the whole cell, clicking opens it without the content shifting,
|
|
2210
|
+
* and Enter or blur commits while Escape reverts.
|
|
2211
|
+
*/
|
|
2212
|
+
/**
|
|
2213
|
+
* Drag-handle column appears when `onRowReorder` is provided. The consumer uses `arrayMove`
|
|
2214
|
+
* (or equivalent) in the callback to reorder its data array.
|
|
2215
|
+
*/
|
|
2216
|
+
export const RowReordering: StoryFn<typeof meta> = () => {
|
|
2217
|
+
const [data, setData] = useState(() => securityEvents.slice(0, 6));
|
|
2218
|
+
|
|
2219
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2220
|
+
setData(prev => {
|
|
2221
|
+
const oldIndex = prev.findIndex(row => row.id === event.activeRowId);
|
|
2222
|
+
const newIndex = prev.findIndex(row => row.id === event.overRowId);
|
|
2223
|
+
if (oldIndex === -1 || newIndex === -1) return prev;
|
|
2224
|
+
return arrayMove(prev, oldIndex, newIndex);
|
|
2225
|
+
});
|
|
2226
|
+
}, []);
|
|
2227
|
+
|
|
2228
|
+
return (
|
|
2229
|
+
<Table
|
|
2230
|
+
data={data}
|
|
2231
|
+
columns={securityColumns}
|
|
2232
|
+
getRowId={row => row.id}
|
|
2233
|
+
onRowReorder={handleRowReorder}
|
|
2234
|
+
data-testid='row-reorder-table'
|
|
2235
|
+
/>
|
|
2236
|
+
);
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
/**
|
|
2240
|
+
* With `getSubRows` grouping, a row moves only among the rows of its own group; group rows stay
|
|
2241
|
+
* put. `showEmptyGroups` keeps a group whose `getSubRows` returns `[]` on screen as a group row.
|
|
2242
|
+
*/
|
|
2243
|
+
export const RowReorderingInGroups: StoryFn<typeof meta> = () => {
|
|
2244
|
+
const [data, setData] = useState<SecurityHeaderEntry[]>(() => [
|
|
2245
|
+
...groupedHeaderData,
|
|
2246
|
+
{ ...groupedHeaderData[0]!, id: 'group-empty', objectName: 'Nothing here yet', children: [] },
|
|
2247
|
+
]);
|
|
2248
|
+
const [expanded, setExpanded] = useState<TableExpandedState>(true);
|
|
2249
|
+
|
|
2250
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2251
|
+
setData(prev =>
|
|
2252
|
+
prev.map(group => {
|
|
2253
|
+
const children = group.children ?? [];
|
|
2254
|
+
const oldIndex = children.findIndex(row => row.id === event.activeRowId);
|
|
2255
|
+
const newIndex = children.findIndex(row => row.id === event.overRowId);
|
|
2256
|
+
if (oldIndex === -1 || newIndex === -1) return group;
|
|
2257
|
+
return { ...group, children: arrayMove(children, oldIndex, newIndex) };
|
|
2258
|
+
}),
|
|
2259
|
+
);
|
|
2260
|
+
}, []);
|
|
2261
|
+
|
|
2262
|
+
return (
|
|
2263
|
+
<Table<SecurityHeaderEntry>
|
|
2264
|
+
data={data}
|
|
2265
|
+
columns={headerColumns}
|
|
2266
|
+
getRowId={row => row.id}
|
|
2267
|
+
getSubRows={row => row.children}
|
|
2268
|
+
showEmptyGroups
|
|
2269
|
+
expanded={expanded}
|
|
2270
|
+
onExpandedChange={setExpanded}
|
|
2271
|
+
onRowReorder={handleRowReorder}
|
|
2272
|
+
data-testid='row-reorder-groups-table'
|
|
2273
|
+
/>
|
|
2274
|
+
);
|
|
2275
|
+
};
|
|
2276
|
+
|
|
2277
|
+
const BANNER_ROW_PREFIX = 'banner-';
|
|
2278
|
+
const LOCKED_ROW_IDS = new Set(['csp-1']);
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* `groupRowSpan: 'full'` draws `renderGroupRow` across every data column — title on the left,
|
|
2282
|
+
* actions on the right edge; the content owns the collapse toggle. `renderFullWidthRow` turns a
|
|
2283
|
+
* regular row into a single spanning banner, and `getRowCanDrag` keeps the banner and the locked
|
|
2284
|
+
* first header in place while the other rows of the group reorder around them.
|
|
2285
|
+
*/
|
|
2286
|
+
export const GroupedWithFullWidthRows: StoryFn<typeof meta> = () => {
|
|
2287
|
+
const [data, setData] = useState<SecurityHeaderEntry[]>(() => [
|
|
2288
|
+
{
|
|
2289
|
+
...groupedHeaderData[0]!,
|
|
2290
|
+
children: [
|
|
2291
|
+
{ ...groupedHeaderData[0]!, id: `${BANNER_ROW_PREFIX}csp`, children: undefined },
|
|
2292
|
+
...(groupedHeaderData[0]!.children ?? []),
|
|
2293
|
+
],
|
|
2294
|
+
},
|
|
2295
|
+
...groupedHeaderData.slice(1, 3),
|
|
2296
|
+
{ ...groupedHeaderData[0]!, id: 'group-empty', objectName: 'Nothing here yet', children: [] },
|
|
2297
|
+
]);
|
|
2298
|
+
const [expanded, setExpanded] = useState<TableExpandedState>(true);
|
|
2299
|
+
|
|
2300
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2301
|
+
setData(prev =>
|
|
2302
|
+
prev.map(group => {
|
|
2303
|
+
const children = group.children ?? [];
|
|
2304
|
+
const oldIndex = children.findIndex(row => row.id === event.activeRowId);
|
|
2305
|
+
const newIndex = children.findIndex(row => row.id === event.overRowId);
|
|
2306
|
+
if (oldIndex === -1 || newIndex === -1) return group;
|
|
2307
|
+
return { ...group, children: arrayMove(children, oldIndex, newIndex) };
|
|
2308
|
+
}),
|
|
2309
|
+
);
|
|
2310
|
+
}, []);
|
|
2311
|
+
|
|
2312
|
+
return (
|
|
2313
|
+
<Table<SecurityHeaderEntry>
|
|
2314
|
+
data={data}
|
|
2315
|
+
columns={headerColumns}
|
|
2316
|
+
getRowId={row => row.id}
|
|
2317
|
+
getSubRows={row => row.children}
|
|
2318
|
+
showEmptyGroups
|
|
2319
|
+
expanded={expanded}
|
|
2320
|
+
onExpandedChange={setExpanded}
|
|
2321
|
+
onRowReorder={handleRowReorder}
|
|
2322
|
+
groupRowSpan='full'
|
|
2323
|
+
renderGroupRow={row => {
|
|
2324
|
+
const isExpanded = row.getIsExpanded();
|
|
2325
|
+
return (
|
|
2326
|
+
<HStack gap={8} align='center'>
|
|
2327
|
+
<Button
|
|
2328
|
+
variant='ghost'
|
|
2329
|
+
color='neutral'
|
|
2330
|
+
size='small'
|
|
2331
|
+
aria-expanded={isExpanded}
|
|
2332
|
+
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
|
2333
|
+
onClick={row.getToggleExpandedHandler()}
|
|
2334
|
+
>
|
|
2335
|
+
{isExpanded ? <ChevronDown /> : <ChevronRight />}
|
|
2336
|
+
</Button>
|
|
2337
|
+
<Text weight='medium'>{row.original.objectName}</Text>
|
|
2338
|
+
<Badge color='gray' type='secondary' size='medium'>
|
|
2339
|
+
{row.subRows.filter(r => !r.id.startsWith(BANNER_ROW_PREFIX)).length}
|
|
2340
|
+
</Badge>
|
|
2341
|
+
<HStack gap={4} className='ml-auto'>
|
|
2342
|
+
<Button variant='ghost' color='neutral' size='small'>
|
|
2343
|
+
<Plus />
|
|
2344
|
+
Add header
|
|
2345
|
+
</Button>
|
|
2346
|
+
<Button variant='ghost' color='neutral' size='small' aria-label='More actions'>
|
|
2347
|
+
<Ellipsis />
|
|
2348
|
+
</Button>
|
|
2349
|
+
</HStack>
|
|
2350
|
+
</HStack>
|
|
2351
|
+
);
|
|
2352
|
+
}}
|
|
2353
|
+
renderFullWidthRow={row =>
|
|
2354
|
+
row.id.startsWith(BANNER_ROW_PREFIX) ? (
|
|
2355
|
+
<HStack gap={8} align='center'>
|
|
2356
|
+
<Shield size='sm' />
|
|
2357
|
+
<Text size='sm' weight='medium'>
|
|
2358
|
+
Wallarm baseline headers
|
|
2359
|
+
</Text>
|
|
2360
|
+
<Text size='sm' color='secondary'>
|
|
2361
|
+
· applied first · built-in, always on
|
|
2362
|
+
</Text>
|
|
2363
|
+
<Button variant='ghost' color='neutral' size='small' className='ml-auto'>
|
|
2364
|
+
Manage baseline →
|
|
2365
|
+
</Button>
|
|
2366
|
+
</HStack>
|
|
2367
|
+
) : undefined
|
|
2368
|
+
}
|
|
2369
|
+
getRowCanDrag={row => !row.id.startsWith(BANNER_ROW_PREFIX) && !LOCKED_ROW_IDS.has(row.id)}
|
|
2370
|
+
data-testid='grouped-full-width-table'
|
|
2371
|
+
/>
|
|
2372
|
+
);
|
|
2373
|
+
};
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* `groupExpandColumn` keeps the expand column in grouping mode: the toggle sits in its own column,
|
|
2377
|
+
* first, and every row of a group — drag handle included — sits to the right of it, while
|
|
2378
|
+
* `renderGroupRow` keeps the title and the actions.
|
|
2379
|
+
*/
|
|
2380
|
+
export const GroupedWithExpandColumn: StoryFn<typeof meta> = () => {
|
|
2381
|
+
const [data, setData] = useState<SecurityHeaderEntry[]>(() => [
|
|
2382
|
+
...groupedHeaderData,
|
|
2383
|
+
{ ...groupedHeaderData[0]!, id: 'group-empty', objectName: 'Nothing here yet', children: [] },
|
|
2384
|
+
]);
|
|
2385
|
+
const [expanded, setExpanded] = useState<TableExpandedState>(true);
|
|
2386
|
+
|
|
2387
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2388
|
+
setData(prev =>
|
|
2389
|
+
prev.map(group => {
|
|
2390
|
+
const children = group.children ?? [];
|
|
2391
|
+
const oldIndex = children.findIndex(row => row.id === event.activeRowId);
|
|
2392
|
+
const newIndex = children.findIndex(row => row.id === event.overRowId);
|
|
2393
|
+
if (oldIndex === -1 || newIndex === -1) return group;
|
|
2394
|
+
return { ...group, children: arrayMove(children, oldIndex, newIndex) };
|
|
2395
|
+
}),
|
|
2396
|
+
);
|
|
2397
|
+
}, []);
|
|
2398
|
+
|
|
2399
|
+
return (
|
|
2400
|
+
<Table<SecurityHeaderEntry>
|
|
2401
|
+
data={data}
|
|
2402
|
+
columns={headerColumns}
|
|
2403
|
+
getRowId={row => row.id}
|
|
2404
|
+
getSubRows={row => row.children}
|
|
2405
|
+
showEmptyGroups
|
|
2406
|
+
expanded={expanded}
|
|
2407
|
+
onExpandedChange={setExpanded}
|
|
2408
|
+
onRowReorder={handleRowReorder}
|
|
2409
|
+
groupRowSpan='full'
|
|
2410
|
+
groupExpandColumn
|
|
2411
|
+
renderGroupRow={row => (
|
|
2412
|
+
<HStack gap={8} align='center'>
|
|
2413
|
+
<Text weight='medium'>{row.original.objectName}</Text>
|
|
2414
|
+
<Badge color='gray' type='secondary' size='medium'>
|
|
2415
|
+
{row.subRows.length}
|
|
2416
|
+
</Badge>
|
|
2417
|
+
<Button variant='ghost' color='neutral' size='small' className='ml-auto'>
|
|
2418
|
+
<Plus />
|
|
2419
|
+
Add header
|
|
2420
|
+
</Button>
|
|
2421
|
+
</HStack>
|
|
2422
|
+
)}
|
|
2423
|
+
data-testid='grouped-expand-column-table'
|
|
2424
|
+
/>
|
|
2425
|
+
);
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Row reordering coexists with row selection — each feature has its own column and they
|
|
2430
|
+
* don't interfere with each other.
|
|
2431
|
+
*/
|
|
2432
|
+
export const RowReorderingWithSelection: StoryFn<typeof meta> = () => {
|
|
2433
|
+
const [data, setData] = useState(() => securityEvents.slice(0, 6));
|
|
2434
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
2435
|
+
|
|
2436
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2437
|
+
setData(prev => {
|
|
2438
|
+
const oldIndex = prev.findIndex(row => row.id === event.activeRowId);
|
|
2439
|
+
const newIndex = prev.findIndex(row => row.id === event.overRowId);
|
|
2440
|
+
if (oldIndex === -1 || newIndex === -1) return prev;
|
|
2441
|
+
return arrayMove(prev, oldIndex, newIndex);
|
|
2442
|
+
});
|
|
2443
|
+
}, []);
|
|
2444
|
+
|
|
2445
|
+
return (
|
|
2446
|
+
<Table
|
|
2447
|
+
data={data}
|
|
2448
|
+
columns={securityColumns}
|
|
2449
|
+
getRowId={row => row.id}
|
|
2450
|
+
onRowReorder={handleRowReorder}
|
|
2451
|
+
rowSelection={rowSelection}
|
|
2452
|
+
onRowSelectionChange={setRowSelection}
|
|
2453
|
+
/>
|
|
2454
|
+
);
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* Row drag-and-drop with container virtualization — the `DragOverlay` renders the dragged row
|
|
2459
|
+
* outside the virtualizer so it stays visible even when scrolled out of the virtual window.
|
|
2460
|
+
*/
|
|
2461
|
+
export const RowReorderingVirtualized: StoryFn<typeof meta> = () => {
|
|
2462
|
+
const [data, setData] = useState(() => createLargeSecurityEvents(100));
|
|
2463
|
+
|
|
2464
|
+
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
|
|
2465
|
+
setData(prev => {
|
|
2466
|
+
const oldIndex = prev.findIndex(row => row.id === event.activeRowId);
|
|
2467
|
+
const newIndex = prev.findIndex(row => row.id === event.overRowId);
|
|
2468
|
+
if (oldIndex === -1 || newIndex === -1) return prev;
|
|
2469
|
+
return arrayMove(prev, oldIndex, newIndex);
|
|
2470
|
+
});
|
|
2471
|
+
}, []);
|
|
2472
|
+
|
|
2473
|
+
return (
|
|
2474
|
+
<Table
|
|
2475
|
+
data={data}
|
|
2476
|
+
columns={securityColumns}
|
|
2477
|
+
getRowId={row => row.id}
|
|
2478
|
+
onRowReorder={handleRowReorder}
|
|
2479
|
+
virtualized='container'
|
|
2480
|
+
estimateRowHeight={() => 40}
|
|
2481
|
+
/>
|
|
2482
|
+
);
|
|
2483
|
+
};
|
|
2484
|
+
RowReorderingVirtualized.tags = ['!autodocs'];
|
|
2485
|
+
|
|
2486
|
+
export const InlineCellEditing: StoryFn<typeof meta> = () => {
|
|
2487
|
+
const [data, setData] = useState<SecurityEvent[]>(() => securityEvents.slice(0, 6));
|
|
2488
|
+
// Category starts unselected for every row, so the select shows its placeholder.
|
|
2489
|
+
const [categories, setCategories] = useState<Record<string, string>>({});
|
|
2490
|
+
|
|
2491
|
+
const updateCell = useCallback(
|
|
2492
|
+
<K extends keyof SecurityEvent>(rowId: string, key: K, value: SecurityEvent[K]) => {
|
|
2493
|
+
setData(prev => prev.map(row => (row.id === rowId ? { ...row, [key]: value } : row)));
|
|
2494
|
+
},
|
|
2495
|
+
[],
|
|
2496
|
+
);
|
|
2497
|
+
|
|
2498
|
+
const columns = useMemo<TableColumnDef<SecurityEvent>[]>(
|
|
2499
|
+
() => [
|
|
2500
|
+
// The first column is the Table's pinned "master" column — keep it
|
|
2501
|
+
// read-only; editable cells live in the plain columns after it.
|
|
2502
|
+
securityColumnHelper.accessor('sourceIp', {
|
|
2503
|
+
header: 'Source',
|
|
2504
|
+
size: 200,
|
|
2505
|
+
meta: { resizeType: 'cut' as const },
|
|
2506
|
+
cell: ({ getValue }) => <Text size='sm'>{getValue()}</Text>,
|
|
2507
|
+
}),
|
|
2508
|
+
securityColumnHelper.accessor('objectName', {
|
|
2509
|
+
header: 'Object name',
|
|
2510
|
+
size: 400,
|
|
2511
|
+
meta: EDITABLE_CELL_COLUMN_META,
|
|
2512
|
+
cell: ({ row, getValue }) => (
|
|
2513
|
+
<EditableTextCell
|
|
2514
|
+
data-testid='editable-text-cell'
|
|
2515
|
+
value={getValue()}
|
|
2516
|
+
aria-label='Edit object name'
|
|
2517
|
+
onCommit={value => updateCell(row.id, 'objectName', value)}
|
|
2518
|
+
/>
|
|
2519
|
+
),
|
|
2520
|
+
}),
|
|
2521
|
+
securityColumnHelper.accessor('status', {
|
|
2522
|
+
header: 'Status',
|
|
2523
|
+
size: 200,
|
|
2524
|
+
meta: EDITABLE_CELL_COLUMN_META,
|
|
2525
|
+
cell: ({ row, getValue }) => (
|
|
2526
|
+
<EditableSelectCell
|
|
2527
|
+
data-testid='editable-select-cell'
|
|
2528
|
+
value={getValue()}
|
|
2529
|
+
items={statusItems}
|
|
2530
|
+
onCommit={value => updateCell(row.id, 'status', value as SecurityEvent['status'])}
|
|
2531
|
+
>
|
|
2532
|
+
<StatusBadge value={getValue()} />
|
|
2533
|
+
</EditableSelectCell>
|
|
2534
|
+
),
|
|
2535
|
+
}),
|
|
2536
|
+
// Empty-by-default select: consumer supplies the `placeholder` text.
|
|
2537
|
+
securityColumnHelper.display({
|
|
2538
|
+
id: 'category',
|
|
2539
|
+
header: 'Category',
|
|
2540
|
+
size: 200,
|
|
2541
|
+
meta: EDITABLE_CELL_COLUMN_META,
|
|
2542
|
+
cell: ({ row }) => {
|
|
2543
|
+
const value = categories[row.id] ?? '';
|
|
2544
|
+
return (
|
|
2545
|
+
<EditableSelectCell
|
|
2546
|
+
value={value}
|
|
2547
|
+
items={categoryItems}
|
|
2548
|
+
placeholder='Select'
|
|
2549
|
+
aria-label='Edit category'
|
|
2550
|
+
onCommit={next => setCategories(prev => ({ ...prev, [row.id]: next }))}
|
|
2551
|
+
>
|
|
2552
|
+
{categoryItems.find(item => item.value === value)?.label}
|
|
2553
|
+
</EditableSelectCell>
|
|
2554
|
+
);
|
|
2555
|
+
},
|
|
2556
|
+
}),
|
|
2557
|
+
securityColumnHelper.accessor('parameter', {
|
|
2558
|
+
header: 'Parameters',
|
|
2559
|
+
size: 240,
|
|
2560
|
+
meta: { resizeType: 'cut' as const },
|
|
2561
|
+
cell: ({ getValue }) => <InlineCodeSnippet code={getValue()} size='sm' copyable={false} />,
|
|
2562
|
+
}),
|
|
2563
|
+
],
|
|
2564
|
+
[updateCell, categories],
|
|
2565
|
+
);
|
|
2566
|
+
|
|
2567
|
+
return (
|
|
2568
|
+
<VStack gap={12} align='stretch'>
|
|
2569
|
+
<span className='sb-annotation'>click a cell to edit it</span>
|
|
2570
|
+
<Table data={data} columns={columns} getRowId={row => row.id} />
|
|
2571
|
+
</VStack>
|
|
2572
|
+
);
|
|
2573
|
+
};
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* The group parent row sticks below the header while its children scroll — scroll through
|
|
2577
|
+
* a group to see the parent pin in place, then release when the group boundary passes.
|
|
2578
|
+
*/
|
|
2579
|
+
export const StickyGroupParentStory: StoryFn<typeof meta> = () => {
|
|
2580
|
+
const data = useMemo(() => createLargeGroupedData(4, 20), []);
|
|
2581
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({
|
|
2582
|
+
'group-0': true,
|
|
2583
|
+
'group-1': true,
|
|
2584
|
+
'group-2': true,
|
|
2585
|
+
'group-3': true,
|
|
2586
|
+
});
|
|
2587
|
+
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
|
|
2588
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
2589
|
+
|
|
2590
|
+
return (
|
|
2591
|
+
<Table<SecurityHeaderEntry>
|
|
2592
|
+
className='h-300'
|
|
2593
|
+
data={data}
|
|
2594
|
+
columns={headerColumns}
|
|
2595
|
+
getRowId={row => row.id}
|
|
2596
|
+
getSubRows={row => row.children}
|
|
2597
|
+
expanded={expanded}
|
|
2598
|
+
onExpandedChange={setExpanded}
|
|
2599
|
+
sorting={sorting}
|
|
2600
|
+
onSortingChange={setSorting}
|
|
2601
|
+
rowSelection={rowSelection}
|
|
2602
|
+
onRowSelectionChange={setRowSelection}
|
|
2603
|
+
data-testid='sticky-group-parent-table'
|
|
2604
|
+
/>
|
|
2605
|
+
);
|
|
2606
|
+
};
|
|
2607
|
+
StickyGroupParentStory.storyName = 'Sticky Group Parent';
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* The same sticky-parent behavior under container virtualization with large grouped data —
|
|
2611
|
+
* the overlay stays in the DOM even when the real parent row is unmounted by the virtualizer.
|
|
2612
|
+
*/
|
|
2613
|
+
export const StickyGroupParentVirtualized: StoryFn<typeof meta> = () => {
|
|
2614
|
+
const data = useMemo(() => createLargeGroupedData(12, 50), []);
|
|
2615
|
+
const [expanded, setExpanded] = useState<TableExpandedState>({
|
|
2616
|
+
'group-0': true,
|
|
2617
|
+
'group-1': true,
|
|
2618
|
+
'group-2': true,
|
|
2619
|
+
});
|
|
2620
|
+
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
|
|
2621
|
+
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
|
|
2622
|
+
|
|
2623
|
+
return (
|
|
2624
|
+
<Table<SecurityHeaderEntry>
|
|
2625
|
+
className='h-500'
|
|
2626
|
+
data={data}
|
|
2627
|
+
columns={headerColumns}
|
|
2628
|
+
getRowId={row => row.id}
|
|
2629
|
+
getSubRows={row => row.children}
|
|
2630
|
+
expanded={expanded}
|
|
2631
|
+
onExpandedChange={setExpanded}
|
|
2632
|
+
sorting={sorting}
|
|
2633
|
+
onSortingChange={setSorting}
|
|
2634
|
+
rowSelection={rowSelection}
|
|
2635
|
+
onRowSelectionChange={setRowSelection}
|
|
2636
|
+
virtualized='container'
|
|
2637
|
+
data-testid='sticky-group-parent-virt-table'
|
|
2638
|
+
/>
|
|
2639
|
+
);
|
|
2640
|
+
};
|
|
2641
|
+
StickyGroupParentVirtualized.tags = ['!autodocs'];
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
### Table/mocks.tsx
|
|
2645
|
+
|
|
2646
|
+
```tsx
|
|
2647
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2648
|
+
import { Check, Copy, Filter, FilterX } from '../../icons';
|
|
2649
|
+
import { abbreviateNumber } from '../../utils/abbreviateNumber';
|
|
2650
|
+
import { Badge } from '../Badge';
|
|
2651
|
+
import { InlineCodeSnippet } from '../CodeSnippet';
|
|
2652
|
+
import type { CountryCode } from '../Country';
|
|
2653
|
+
import { DrawerHeader } from '../Drawer';
|
|
2654
|
+
import {
|
|
2655
|
+
DropdownMenu,
|
|
2656
|
+
DropdownMenuContent,
|
|
2657
|
+
DropdownMenuContextTrigger,
|
|
2658
|
+
DropdownMenuItem,
|
|
2659
|
+
DropdownMenuItemContent,
|
|
2660
|
+
DropdownMenuItemDescription,
|
|
2661
|
+
DropdownMenuItemIcon,
|
|
2662
|
+
DropdownMenuItemText,
|
|
2663
|
+
DropdownMenuSeparator,
|
|
2664
|
+
} from '../DropdownMenu';
|
|
2665
|
+
import { FormatDateTime } from '../FormatDateTime';
|
|
2666
|
+
import { HttpMethod, type HttpMethodName } from '../HttpMethod';
|
|
2667
|
+
import { Ip, IpAddress, IpCountry, IpProvider, type SourceKey, sourceLabels } from '../Ip';
|
|
2668
|
+
import { Link } from '../Link';
|
|
2669
|
+
import {
|
|
2670
|
+
OverflowTooltip,
|
|
2671
|
+
OverflowTooltipContent,
|
|
2672
|
+
OverflowTooltipTrigger,
|
|
2673
|
+
} from '../OverflowTooltip';
|
|
2674
|
+
import { HStack, VStack } from '../Stack';
|
|
2675
|
+
import { Switch, SwitchControl } from '../Switch';
|
|
2676
|
+
import { Text } from '../Text';
|
|
2677
|
+
import { createTableColumnHelper } from './lib';
|
|
2678
|
+
import type { TableColumnDef } from './types';
|
|
2679
|
+
|
|
2680
|
+
// ---------------------------------------------------------------------------
|
|
2681
|
+
// Interfaces
|
|
2682
|
+
// ---------------------------------------------------------------------------
|
|
2683
|
+
|
|
2684
|
+
export interface SecurityEvent {
|
|
2685
|
+
id: string;
|
|
2686
|
+
objectName: string;
|
|
2687
|
+
tags: string[];
|
|
2688
|
+
isActive: boolean;
|
|
2689
|
+
requests: number;
|
|
2690
|
+
sourceIp: string;
|
|
2691
|
+
sourceCountry: CountryCode;
|
|
2692
|
+
sourceProvider: string;
|
|
2693
|
+
parameter: string;
|
|
2694
|
+
status: 'Blocked' | 'Monitoring';
|
|
2695
|
+
firstDetected: string;
|
|
2696
|
+
cweId: string;
|
|
2697
|
+
lastSeen: string;
|
|
2698
|
+
endpointMethod: HttpMethodName;
|
|
2699
|
+
endpointPath: string;
|
|
2700
|
+
state: boolean;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
export interface SecurityHeaderEntry {
|
|
2704
|
+
id: string;
|
|
2705
|
+
objectName: string;
|
|
2706
|
+
ip: string | null;
|
|
2707
|
+
ipCountry: CountryCode | null;
|
|
2708
|
+
provider: string;
|
|
2709
|
+
requests: number;
|
|
2710
|
+
status: 'New' | 'Active' | 'Resolved';
|
|
2711
|
+
lastEdited: string;
|
|
2712
|
+
lastSynced: string;
|
|
2713
|
+
version: 'Up to date' | 'Outdated';
|
|
2714
|
+
children?: SecurityHeaderEntry[];
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// ---------------------------------------------------------------------------
|
|
2718
|
+
// Utilities
|
|
2719
|
+
// ---------------------------------------------------------------------------
|
|
2720
|
+
|
|
2721
|
+
// ---------------------------------------------------------------------------
|
|
2722
|
+
// Security Events — flat data
|
|
2723
|
+
// ---------------------------------------------------------------------------
|
|
2724
|
+
|
|
2725
|
+
export const securityEvents: SecurityEvent[] = [
|
|
2726
|
+
{
|
|
2727
|
+
id: '1',
|
|
2728
|
+
objectName: 'Rate limiting abuse on the payment endpoint',
|
|
2729
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2730
|
+
isActive: false,
|
|
2731
|
+
requests: 22000,
|
|
2732
|
+
sourceIp: '34.74.73.20',
|
|
2733
|
+
sourceCountry: 'US',
|
|
2734
|
+
sourceProvider: 'aws',
|
|
2735
|
+
parameter: 'header.X-SESSION-ID',
|
|
2736
|
+
status: 'Blocked',
|
|
2737
|
+
firstDetected: '2026-01-28T15:00:00',
|
|
2738
|
+
cweId: 'CWE-287',
|
|
2739
|
+
lastSeen: '5 days ago',
|
|
2740
|
+
endpointMethod: 'POST',
|
|
2741
|
+
endpointPath: '/api/v1/payments',
|
|
2742
|
+
state: true,
|
|
2743
|
+
},
|
|
2744
|
+
{
|
|
2745
|
+
id: '2',
|
|
2746
|
+
objectName: 'Mass assignment vulnerability in user profile',
|
|
2747
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2748
|
+
isActive: false,
|
|
2749
|
+
requests: 25000,
|
|
2750
|
+
sourceIp: '34.74.73.20',
|
|
2751
|
+
sourceCountry: 'US',
|
|
2752
|
+
sourceProvider: 'aws',
|
|
2753
|
+
parameter: 'header.X-DEVICE-ID',
|
|
2754
|
+
status: 'Blocked',
|
|
2755
|
+
firstDetected: '2026-01-27T16:20:10',
|
|
2756
|
+
cweId: 'CWE-416',
|
|
2757
|
+
lastSeen: '6 days ago',
|
|
2758
|
+
endpointMethod: 'PUT',
|
|
2759
|
+
endpointPath: '/api/v1/users/profile',
|
|
2760
|
+
state: false,
|
|
2761
|
+
},
|
|
2762
|
+
{
|
|
2763
|
+
id: '3',
|
|
2764
|
+
objectName: 'Insecure direct object reference in user data',
|
|
2765
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2766
|
+
isActive: true,
|
|
2767
|
+
requests: 30000,
|
|
2768
|
+
sourceIp: '34.74.73.20',
|
|
2769
|
+
sourceCountry: 'US',
|
|
2770
|
+
sourceProvider: 'aws',
|
|
2771
|
+
parameter: 'header.X-CLIENT-VERSION',
|
|
2772
|
+
status: 'Monitoring',
|
|
2773
|
+
firstDetected: '2026-01-26T17:35:25',
|
|
2774
|
+
cweId: 'CWE-89',
|
|
2775
|
+
lastSeen: '7 days ago',
|
|
2776
|
+
endpointMethod: 'GET',
|
|
2777
|
+
endpointPath: '/api/v1/users/:id',
|
|
2778
|
+
state: true,
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
id: '4',
|
|
2782
|
+
objectName: 'Improper error handling leading to info leak',
|
|
2783
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2784
|
+
isActive: true,
|
|
2785
|
+
requests: 35000,
|
|
2786
|
+
sourceIp: '34.74.73.20',
|
|
2787
|
+
sourceCountry: 'US',
|
|
2788
|
+
sourceProvider: 'aws',
|
|
2789
|
+
parameter: 'header.X-ORIGIN',
|
|
2790
|
+
status: 'Blocked',
|
|
2791
|
+
firstDetected: '2026-01-25T18:50:15',
|
|
2792
|
+
cweId: 'CWE-22',
|
|
2793
|
+
lastSeen: '1 week ago',
|
|
2794
|
+
endpointMethod: 'GET',
|
|
2795
|
+
endpointPath: '/api/v1/errors/debug',
|
|
2796
|
+
state: true,
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
id: '5',
|
|
2800
|
+
objectName: 'Broken authentication in the login API',
|
|
2801
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2802
|
+
isActive: true,
|
|
2803
|
+
requests: 20000,
|
|
2804
|
+
sourceIp: '34.74.73.20',
|
|
2805
|
+
sourceCountry: 'US',
|
|
2806
|
+
sourceProvider: 'aws',
|
|
2807
|
+
parameter: 'header.X-TIMESTAMP',
|
|
2808
|
+
status: 'Blocked',
|
|
2809
|
+
firstDetected: '2026-01-29T14:45:30',
|
|
2810
|
+
cweId: 'CWE-200',
|
|
2811
|
+
lastSeen: '4 days ago',
|
|
2812
|
+
endpointMethod: 'POST',
|
|
2813
|
+
endpointPath: '/api/v1/auth/login',
|
|
2814
|
+
state: false,
|
|
2815
|
+
},
|
|
2816
|
+
{
|
|
2817
|
+
id: '6',
|
|
2818
|
+
objectName: 'Lack of resource validation in file upload',
|
|
2819
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2820
|
+
isActive: false,
|
|
2821
|
+
requests: 40000,
|
|
2822
|
+
sourceIp: '34.74.73.20',
|
|
2823
|
+
sourceCountry: 'US',
|
|
2824
|
+
sourceProvider: 'aws',
|
|
2825
|
+
parameter: 'header.X-REQUEST-TYPE',
|
|
2826
|
+
status: 'Blocked',
|
|
2827
|
+
firstDetected: '2026-01-24T19:05:45',
|
|
2828
|
+
cweId: 'CWE-352',
|
|
2829
|
+
lastSeen: '2 days ago',
|
|
2830
|
+
endpointMethod: 'POST',
|
|
2831
|
+
endpointPath: '/api/v1/files/upload',
|
|
2832
|
+
state: true,
|
|
2833
|
+
},
|
|
2834
|
+
{
|
|
2835
|
+
id: '7',
|
|
2836
|
+
objectName: 'Server-side request forgery in image proxy',
|
|
2837
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2838
|
+
isActive: false,
|
|
2839
|
+
requests: 50000,
|
|
2840
|
+
sourceIp: '34.74.73.20',
|
|
2841
|
+
sourceCountry: 'US',
|
|
2842
|
+
sourceProvider: 'aws',
|
|
2843
|
+
parameter: 'header.X-CLIENT-ID',
|
|
2844
|
+
status: 'Monitoring',
|
|
2845
|
+
firstDetected: '2026-01-23T20:30:00',
|
|
2846
|
+
cweId: 'CWE-125',
|
|
2847
|
+
lastSeen: '3 days ago',
|
|
2848
|
+
endpointMethod: 'GET',
|
|
2849
|
+
endpointPath: '/api/v1/images/proxy',
|
|
2850
|
+
state: false,
|
|
2851
|
+
},
|
|
2852
|
+
{
|
|
2853
|
+
id: '8',
|
|
2854
|
+
objectName: 'Unvalidated redirects and forwards in auth flow',
|
|
2855
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2856
|
+
isActive: true,
|
|
2857
|
+
requests: 75000,
|
|
2858
|
+
sourceIp: '34.74.73.20',
|
|
2859
|
+
sourceCountry: 'US',
|
|
2860
|
+
sourceProvider: 'aws',
|
|
2861
|
+
parameter: 'header.X-RESPONSE-FORMAT',
|
|
2862
|
+
status: 'Monitoring',
|
|
2863
|
+
firstDetected: '2026-01-22T21:45:50',
|
|
2864
|
+
cweId: 'CWE-134',
|
|
2865
|
+
lastSeen: '4 days ago',
|
|
2866
|
+
endpointMethod: 'GET',
|
|
2867
|
+
endpointPath: '/api/v1/auth/redirect',
|
|
2868
|
+
state: true,
|
|
2869
|
+
},
|
|
2870
|
+
{
|
|
2871
|
+
id: '9',
|
|
2872
|
+
objectName: 'SQL injection in the user ID',
|
|
2873
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2874
|
+
isActive: false,
|
|
2875
|
+
requests: 15000,
|
|
2876
|
+
sourceIp: '34.74.73.20',
|
|
2877
|
+
sourceCountry: 'US',
|
|
2878
|
+
sourceProvider: 'aws',
|
|
2879
|
+
parameter: 'header.X-API-KEY',
|
|
2880
|
+
status: 'Blocked',
|
|
2881
|
+
firstDetected: '2026-01-31T12:15:00',
|
|
2882
|
+
cweId: 'CWE-79',
|
|
2883
|
+
lastSeen: '1 day ago',
|
|
2884
|
+
endpointMethod: 'DELETE',
|
|
2885
|
+
endpointPath: '/api/v1/users/:id',
|
|
2886
|
+
state: false,
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
id: '10',
|
|
2890
|
+
objectName: 'Cross-site scripting in the API endpoint',
|
|
2891
|
+
tags: ['api-abuse', 'account-takeover', 'credential-stuffing', 'scanner', 'brute-force'],
|
|
2892
|
+
isActive: false,
|
|
2893
|
+
requests: 18000,
|
|
2894
|
+
sourceIp: '34.74.73.20',
|
|
2895
|
+
sourceCountry: 'US',
|
|
2896
|
+
sourceProvider: 'aws',
|
|
2897
|
+
parameter: 'header.X-USER-ID',
|
|
2898
|
+
status: 'Blocked',
|
|
2899
|
+
firstDetected: '2026-01-30T13:30:45',
|
|
2900
|
+
cweId: 'CWE-89',
|
|
2901
|
+
lastSeen: '3 days ago',
|
|
2902
|
+
endpointMethod: 'PATCH',
|
|
2903
|
+
endpointPath: '/api/v1/content',
|
|
2904
|
+
state: true,
|
|
2905
|
+
},
|
|
2906
|
+
];
|
|
2907
|
+
|
|
2908
|
+
// ---------------------------------------------------------------------------
|
|
2909
|
+
// Security Events — columns
|
|
2910
|
+
// ---------------------------------------------------------------------------
|
|
2911
|
+
|
|
2912
|
+
export const securityColumnHelper = createTableColumnHelper<SecurityEvent>();
|
|
2913
|
+
|
|
2914
|
+
export const securityColumns: TableColumnDef<SecurityEvent>[] = [
|
|
2915
|
+
securityColumnHelper.accessor('objectName', {
|
|
2916
|
+
header: 'Object name',
|
|
2917
|
+
size: 300,
|
|
2918
|
+
enableSorting: true,
|
|
2919
|
+
meta: { sortType: 'text' as const, resizeType: 'cut' as const },
|
|
2920
|
+
cell: ({ row }) => (
|
|
2921
|
+
<OverflowTooltip>
|
|
2922
|
+
<OverflowTooltipTrigger asChild>
|
|
2923
|
+
<Link size='sm' type='table' weight='regular' href='#'>
|
|
2924
|
+
{row.original.objectName}
|
|
2925
|
+
</Link>
|
|
2926
|
+
</OverflowTooltipTrigger>
|
|
2927
|
+
<OverflowTooltipContent>{row.original.objectName}</OverflowTooltipContent>
|
|
2928
|
+
</OverflowTooltip>
|
|
2929
|
+
),
|
|
2930
|
+
}),
|
|
2931
|
+
securityColumnHelper.accessor('state', {
|
|
2932
|
+
header: 'State',
|
|
2933
|
+
size: 100,
|
|
2934
|
+
cell: ({ getValue }) => (
|
|
2935
|
+
<Switch checked={getValue()}>
|
|
2936
|
+
<SwitchControl />
|
|
2937
|
+
</Switch>
|
|
2938
|
+
),
|
|
2939
|
+
}),
|
|
2940
|
+
securityColumnHelper.accessor('requests', {
|
|
2941
|
+
header: 'Requests',
|
|
2942
|
+
size: 160,
|
|
2943
|
+
enableSorting: true,
|
|
2944
|
+
cell: ({ getValue }) => <Text size='sm'>{abbreviateNumber(getValue())}</Text>,
|
|
2945
|
+
}),
|
|
2946
|
+
securityColumnHelper.accessor('sourceIp', {
|
|
2947
|
+
header: 'Source',
|
|
2948
|
+
size: 200,
|
|
2949
|
+
enableSorting: true,
|
|
2950
|
+
meta: { sortType: 'text' as const },
|
|
2951
|
+
cell: ({ row }) => (
|
|
2952
|
+
<Ip>
|
|
2953
|
+
{row.original.sourceCountry && <IpCountry code={row.original.sourceCountry} />}
|
|
2954
|
+
<IpAddress>{row.original.sourceIp}</IpAddress>
|
|
2955
|
+
{row.original.sourceProvider && (
|
|
2956
|
+
<IpProvider>{sourceLabels[row.original.sourceProvider as SourceKey]}</IpProvider>
|
|
2957
|
+
)}
|
|
2958
|
+
</Ip>
|
|
2959
|
+
),
|
|
2960
|
+
}),
|
|
2961
|
+
securityColumnHelper.accessor('parameter', {
|
|
2962
|
+
header: 'Parameters',
|
|
2963
|
+
size: 220,
|
|
2964
|
+
enableSorting: true,
|
|
2965
|
+
meta: { sortType: 'text' as const, resizeType: 'cut' as const },
|
|
2966
|
+
cell: ({ getValue }) => <InlineCodeSnippet code={getValue()} size='sm' copyable={false} />,
|
|
2967
|
+
}),
|
|
2968
|
+
securityColumnHelper.accessor('status', {
|
|
2969
|
+
header: 'Status',
|
|
2970
|
+
size: 130,
|
|
2971
|
+
enableSorting: true,
|
|
2972
|
+
meta: { sortType: 'text' as const, resizeType: 'cut' as const },
|
|
2973
|
+
cell: ({ getValue }) => {
|
|
2974
|
+
const status = getValue();
|
|
2975
|
+
return (
|
|
2976
|
+
<Badge
|
|
2977
|
+
variant='dotted'
|
|
2978
|
+
color={status === 'Blocked' ? 'red' : 'yellow'}
|
|
2979
|
+
type='secondary'
|
|
2980
|
+
size='medium'
|
|
2981
|
+
>
|
|
2982
|
+
{status}
|
|
2983
|
+
</Badge>
|
|
2984
|
+
);
|
|
2985
|
+
},
|
|
2986
|
+
}),
|
|
2987
|
+
securityColumnHelper.accessor('firstDetected', {
|
|
2988
|
+
header: 'First detected',
|
|
2989
|
+
size: 160,
|
|
2990
|
+
enableSorting: true,
|
|
2991
|
+
meta: { sortType: 'date' as const, resizeType: 'cut' as const },
|
|
2992
|
+
cell: ({ getValue }) => <FormatDateTime value={getValue()} format='relative' />,
|
|
2993
|
+
}),
|
|
2994
|
+
securityColumnHelper.accessor('cweId', {
|
|
2995
|
+
header: 'Security info',
|
|
2996
|
+
size: 130,
|
|
2997
|
+
enableSorting: true,
|
|
2998
|
+
meta: { sortType: 'text' as const },
|
|
2999
|
+
cell: ({ getValue }) => (
|
|
3000
|
+
<Badge color='gray' type='secondary' size='medium' textVariant='code'>
|
|
3001
|
+
{getValue()}
|
|
3002
|
+
</Badge>
|
|
3003
|
+
),
|
|
3004
|
+
}),
|
|
3005
|
+
securityColumnHelper.accessor('lastSeen', {
|
|
3006
|
+
header: 'Last seen',
|
|
3007
|
+
size: 120,
|
|
3008
|
+
enableSorting: true,
|
|
3009
|
+
meta: { sortType: 'date' as const, resizeType: 'cut' as const },
|
|
3010
|
+
cell: ({ getValue }) => (
|
|
3011
|
+
<Text size='sm' color='secondary'>
|
|
3012
|
+
{getValue()}
|
|
3013
|
+
</Text>
|
|
3014
|
+
),
|
|
3015
|
+
}),
|
|
3016
|
+
securityColumnHelper.display({
|
|
3017
|
+
id: 'endpoint',
|
|
3018
|
+
header: 'Endpoints',
|
|
3019
|
+
size: 220,
|
|
3020
|
+
cell: ({ row }) => (
|
|
3021
|
+
<HStack gap={6}>
|
|
3022
|
+
<HttpMethod method={row.original.endpointMethod} />
|
|
3023
|
+
<Text size='sm' truncate>
|
|
3024
|
+
{row.original.endpointPath}
|
|
3025
|
+
</Text>
|
|
3026
|
+
</HStack>
|
|
3027
|
+
),
|
|
3028
|
+
}),
|
|
3029
|
+
];
|
|
3030
|
+
|
|
3031
|
+
export const securityColumnIds = securityColumns
|
|
3032
|
+
.map(c => ('accessorKey' in c ? (c.accessorKey as string) : (c as { id?: string }).id) as string)
|
|
3033
|
+
.filter(Boolean);
|
|
3034
|
+
|
|
3035
|
+
// ---------------------------------------------------------------------------
|
|
3036
|
+
// Grouped data — hierarchical with children
|
|
3037
|
+
// ---------------------------------------------------------------------------
|
|
3038
|
+
|
|
3039
|
+
const createHeaderEntries = (groupId: string, count: number): SecurityHeaderEntry[] =>
|
|
3040
|
+
Array.from({ length: count }, (_, i) => ({
|
|
3041
|
+
id: `${groupId}-${i + 1}`,
|
|
3042
|
+
objectName: '/v1/antibot/api/report/{parameter_1}',
|
|
3043
|
+
ip: '142.198.167.52/32',
|
|
3044
|
+
ipCountry: 'ES',
|
|
3045
|
+
provider: 'azure',
|
|
3046
|
+
requests: 23000,
|
|
3047
|
+
status: 'New' as const,
|
|
3048
|
+
lastEdited: '2 days ago',
|
|
3049
|
+
lastSynced: '3 months ago',
|
|
3050
|
+
version: 'Up to date' as const,
|
|
3051
|
+
}));
|
|
3052
|
+
|
|
3053
|
+
export const groupedHeaderData: SecurityHeaderEntry[] = [
|
|
3054
|
+
{
|
|
3055
|
+
id: 'group-csp',
|
|
3056
|
+
objectName: 'Content Security Policy',
|
|
3057
|
+
ip: '',
|
|
3058
|
+
ipCountry: null,
|
|
3059
|
+
provider: '',
|
|
3060
|
+
requests: 0,
|
|
3061
|
+
status: 'New',
|
|
3062
|
+
lastEdited: '',
|
|
3063
|
+
lastSynced: '',
|
|
3064
|
+
version: 'Up to date',
|
|
3065
|
+
children: createHeaderEntries('csp', 3),
|
|
3066
|
+
},
|
|
3067
|
+
{
|
|
3068
|
+
id: 'group-cto',
|
|
3069
|
+
objectName: 'Content-Type-Options',
|
|
3070
|
+
ip: '',
|
|
3071
|
+
ipCountry: null,
|
|
3072
|
+
provider: '',
|
|
3073
|
+
requests: 0,
|
|
3074
|
+
status: 'New',
|
|
3075
|
+
lastEdited: '',
|
|
3076
|
+
lastSynced: '',
|
|
3077
|
+
version: 'Up to date',
|
|
3078
|
+
children: createHeaderEntries('cto', 5),
|
|
3079
|
+
},
|
|
3080
|
+
{
|
|
3081
|
+
id: 'group-xfo',
|
|
3082
|
+
objectName: 'X-Frame-Options',
|
|
3083
|
+
ip: '',
|
|
3084
|
+
ipCountry: null,
|
|
3085
|
+
provider: '',
|
|
3086
|
+
requests: 0,
|
|
3087
|
+
status: 'New',
|
|
3088
|
+
lastEdited: '',
|
|
3089
|
+
lastSynced: '',
|
|
3090
|
+
version: 'Up to date',
|
|
3091
|
+
children: createHeaderEntries('xfo', 2),
|
|
3092
|
+
},
|
|
3093
|
+
{
|
|
3094
|
+
id: 'group-hsts',
|
|
3095
|
+
objectName: 'HTTP Strict Transport Security',
|
|
3096
|
+
ip: '',
|
|
3097
|
+
ipCountry: null,
|
|
3098
|
+
provider: '',
|
|
3099
|
+
requests: 0,
|
|
3100
|
+
status: 'New',
|
|
3101
|
+
lastEdited: '',
|
|
3102
|
+
lastSynced: '',
|
|
3103
|
+
version: 'Up to date',
|
|
3104
|
+
children: createHeaderEntries('hsts', 4),
|
|
3105
|
+
},
|
|
3106
|
+
];
|
|
3107
|
+
|
|
3108
|
+
// ---------------------------------------------------------------------------
|
|
3109
|
+
// Grouped data — columns
|
|
3110
|
+
// ---------------------------------------------------------------------------
|
|
3111
|
+
|
|
3112
|
+
export const headerColumnHelper = createTableColumnHelper<SecurityHeaderEntry>();
|
|
3113
|
+
|
|
3114
|
+
export const headerColumns: TableColumnDef<SecurityHeaderEntry>[] = [
|
|
3115
|
+
headerColumnHelper.accessor('objectName', {
|
|
3116
|
+
header: 'Object name',
|
|
3117
|
+
size: 300,
|
|
3118
|
+
enableSorting: true,
|
|
3119
|
+
meta: { sortType: 'text' as const, resizeType: 'cut' as const },
|
|
3120
|
+
cell: ({ getValue }) => (
|
|
3121
|
+
<Text size='sm' truncate>
|
|
3122
|
+
{getValue()}
|
|
3123
|
+
</Text>
|
|
3124
|
+
),
|
|
3125
|
+
}),
|
|
3126
|
+
headerColumnHelper.accessor('ip', {
|
|
3127
|
+
header: 'IP',
|
|
3128
|
+
size: 200,
|
|
3129
|
+
enableSorting: true,
|
|
3130
|
+
meta: { sortType: 'text' as const },
|
|
3131
|
+
cell: ({ row }) => (
|
|
3132
|
+
<Ip>
|
|
3133
|
+
{row.original.ipCountry && <IpCountry code={row.original.ipCountry} />}
|
|
3134
|
+
<IpAddress>{row.original.ip}</IpAddress>
|
|
3135
|
+
{row.original.provider && (
|
|
3136
|
+
<IpProvider>{sourceLabels[row.original.provider as SourceKey]}</IpProvider>
|
|
3137
|
+
)}
|
|
3138
|
+
</Ip>
|
|
3139
|
+
),
|
|
3140
|
+
}),
|
|
3141
|
+
headerColumnHelper.accessor('requests', {
|
|
3142
|
+
header: 'Requests',
|
|
3143
|
+
size: 100,
|
|
3144
|
+
enableSorting: true,
|
|
3145
|
+
cell: ({ getValue }) => <Text size='sm'>{abbreviateNumber(getValue())}</Text>,
|
|
3146
|
+
}),
|
|
3147
|
+
headerColumnHelper.accessor('status', {
|
|
3148
|
+
header: 'Status',
|
|
3149
|
+
size: 100,
|
|
3150
|
+
cell: ({ getValue }) => (
|
|
3151
|
+
<Badge color='blue' type='secondary' size='medium'>
|
|
3152
|
+
{getValue()}
|
|
3153
|
+
</Badge>
|
|
3154
|
+
),
|
|
3155
|
+
}),
|
|
3156
|
+
headerColumnHelper.accessor('lastEdited', {
|
|
3157
|
+
header: 'Last edited',
|
|
3158
|
+
size: 130,
|
|
3159
|
+
enableSorting: true,
|
|
3160
|
+
meta: { sortType: 'date' as const },
|
|
3161
|
+
cell: ({ getValue }) => (
|
|
3162
|
+
<Text size='sm' color='secondary'>
|
|
3163
|
+
{getValue()}
|
|
3164
|
+
</Text>
|
|
3165
|
+
),
|
|
3166
|
+
}),
|
|
3167
|
+
headerColumnHelper.accessor('lastSynced', {
|
|
3168
|
+
header: 'Last synced',
|
|
3169
|
+
size: 130,
|
|
3170
|
+
enableSorting: true,
|
|
3171
|
+
meta: { sortType: 'date' as const },
|
|
3172
|
+
cell: ({ getValue }) => (
|
|
3173
|
+
<Text size='sm' color='secondary'>
|
|
3174
|
+
{getValue()}
|
|
3175
|
+
</Text>
|
|
3176
|
+
),
|
|
3177
|
+
}),
|
|
3178
|
+
headerColumnHelper.display({
|
|
3179
|
+
id: 'version',
|
|
3180
|
+
header: 'Version',
|
|
3181
|
+
size: 140,
|
|
3182
|
+
cell: ({ row }) => (
|
|
3183
|
+
<HStack gap={4} align='center'>
|
|
3184
|
+
<Check size='sm' className='text-text-success' />
|
|
3185
|
+
<Text size='sm' color='inherit'>
|
|
3186
|
+
{row.original.version}
|
|
3187
|
+
</Text>
|
|
3188
|
+
</HStack>
|
|
3189
|
+
),
|
|
3190
|
+
}),
|
|
3191
|
+
];
|
|
3192
|
+
|
|
3193
|
+
export const headerColumnIds = headerColumns
|
|
3194
|
+
.map(c => ('accessorKey' in c ? (c.accessorKey as string) : (c as { id?: string }).id) as string)
|
|
3195
|
+
.filter(Boolean);
|
|
3196
|
+
|
|
3197
|
+
// ---------------------------------------------------------------------------
|
|
3198
|
+
// Large grouped data — for FullFeatured + virtualization
|
|
3199
|
+
// ---------------------------------------------------------------------------
|
|
3200
|
+
|
|
3201
|
+
const HEADER_GROUPS = [
|
|
3202
|
+
'Content Security Policy',
|
|
3203
|
+
'Content-Type-Options',
|
|
3204
|
+
'X-Frame-Options',
|
|
3205
|
+
'HTTP Strict Transport Security',
|
|
3206
|
+
'Referrer-Policy',
|
|
3207
|
+
'Permissions-Policy',
|
|
3208
|
+
'Cross-Origin-Opener-Policy',
|
|
3209
|
+
'Cross-Origin-Resource-Policy',
|
|
3210
|
+
'X-Content-Type-Options',
|
|
3211
|
+
'Cache-Control',
|
|
3212
|
+
'X-XSS-Protection',
|
|
3213
|
+
'Expect-CT',
|
|
3214
|
+
] as const;
|
|
3215
|
+
|
|
3216
|
+
const IPS = ['142.198.167.52/32', '10.0.42.7/24', '192.168.1.100/32', '172.16.0.55/16'] as const;
|
|
3217
|
+
const FLAGS = ['ES', 'US', 'DE', 'JP'] as const;
|
|
3218
|
+
const PROVIDERS = ['azure', 'aws', 'gce', 'aws'] as const;
|
|
3219
|
+
const STATUSES = ['New', 'Active', 'Resolved'] as const;
|
|
3220
|
+
const VERSIONS = ['Up to date', 'Outdated'] as const;
|
|
3221
|
+
const ENDPOINTS = [
|
|
3222
|
+
'/v1/antibot/api/report/{parameter_1}',
|
|
3223
|
+
'/v1/auth/verify/{token}',
|
|
3224
|
+
'/v1/users/{id}/settings',
|
|
3225
|
+
'/v1/webhooks/process/{event_type}',
|
|
3226
|
+
'/v1/analytics/track/{metric}',
|
|
3227
|
+
] as const;
|
|
3228
|
+
|
|
3229
|
+
export const createLargeGroupedData = (
|
|
3230
|
+
groupCount = 12,
|
|
3231
|
+
childrenPerGroup = 50,
|
|
3232
|
+
): SecurityHeaderEntry[] => {
|
|
3233
|
+
return Array.from({ length: groupCount }, (_, gi): SecurityHeaderEntry => {
|
|
3234
|
+
const groupName = HEADER_GROUPS.at(gi % HEADER_GROUPS.length)!;
|
|
3235
|
+
const groupId = `group-${gi}`;
|
|
3236
|
+
|
|
3237
|
+
return {
|
|
3238
|
+
id: groupId,
|
|
3239
|
+
objectName: groupName,
|
|
3240
|
+
ip: '',
|
|
3241
|
+
ipCountry: null,
|
|
3242
|
+
provider: '',
|
|
3243
|
+
requests: 0,
|
|
3244
|
+
status: 'New' as const,
|
|
3245
|
+
lastEdited: '',
|
|
3246
|
+
lastSynced: '',
|
|
3247
|
+
version: 'Up to date' as const,
|
|
3248
|
+
children: Array.from({ length: childrenPerGroup }, (_, ci) => ({
|
|
3249
|
+
id: `${groupId}-${ci}`,
|
|
3250
|
+
objectName: ENDPOINTS.at(ci % ENDPOINTS.length)!,
|
|
3251
|
+
ip: IPS.at(ci % IPS.length)!,
|
|
3252
|
+
ipCountry: FLAGS.at(ci % FLAGS.length)!,
|
|
3253
|
+
provider: PROVIDERS.at(ci % PROVIDERS.length)!,
|
|
3254
|
+
requests: 5000 + ci * 1000,
|
|
3255
|
+
status: STATUSES.at(ci % STATUSES.length)!,
|
|
3256
|
+
lastEdited: `${(ci % 14) + 1} days ago`,
|
|
3257
|
+
lastSynced: `${(ci % 6) + 1} months ago`,
|
|
3258
|
+
version: VERSIONS.at(ci % VERSIONS.length)!,
|
|
3259
|
+
})),
|
|
3260
|
+
};
|
|
3261
|
+
});
|
|
3262
|
+
};
|
|
3263
|
+
|
|
3264
|
+
// ---------------------------------------------------------------------------
|
|
3265
|
+
// Large flat data — for a Virtualized story
|
|
3266
|
+
// ---------------------------------------------------------------------------
|
|
3267
|
+
|
|
3268
|
+
/** Shared preview drawer header for security events */
|
|
3269
|
+
export const renderSecurityPreviewHeader = (row: { original: SecurityEvent }) => (
|
|
3270
|
+
<DrawerHeader>
|
|
3271
|
+
<Text size='lg' weight='medium'>
|
|
3272
|
+
{row.original.objectName}
|
|
3273
|
+
</Text>
|
|
3274
|
+
</DrawerHeader>
|
|
3275
|
+
);
|
|
3276
|
+
|
|
3277
|
+
/** Preview drawer content for security events */
|
|
3278
|
+
const SecurityPreviewContent = ({ row }: { row: { original: SecurityEvent } }) => (
|
|
3279
|
+
<VStack gap={16}>
|
|
3280
|
+
<HStack gap={8}>
|
|
3281
|
+
<Badge
|
|
3282
|
+
variant='dotted'
|
|
3283
|
+
color={row.original.status === 'Blocked' ? 'red' : 'yellow'}
|
|
3284
|
+
type='secondary'
|
|
3285
|
+
size='medium'
|
|
3286
|
+
>
|
|
3287
|
+
{row.original.status}
|
|
3288
|
+
</Badge>
|
|
3289
|
+
<Switch checked={row.original.state}>
|
|
3290
|
+
<SwitchControl />
|
|
3291
|
+
</Switch>
|
|
3292
|
+
<InlineCodeSnippet code={row.original.parameter} size='sm' copyable={false} />
|
|
3293
|
+
</HStack>
|
|
3294
|
+
<VStack gap={4}>
|
|
3295
|
+
<Text size='sm' color='secondary'>
|
|
3296
|
+
Source: {row.original.sourceCountry} · {row.original.sourceProvider}
|
|
3297
|
+
</Text>
|
|
3298
|
+
<Text size='sm' color='secondary'>
|
|
3299
|
+
First detected: {row.original.firstDetected}
|
|
3300
|
+
</Text>
|
|
3301
|
+
<Text size='sm' color='secondary'>
|
|
3302
|
+
Last seen: {row.original.lastSeen}
|
|
3303
|
+
</Text>
|
|
3304
|
+
<Text size='sm' color='secondary'>
|
|
3305
|
+
Security: {row.original.cweId}
|
|
3306
|
+
</Text>
|
|
3307
|
+
</VStack>
|
|
3308
|
+
</VStack>
|
|
3309
|
+
);
|
|
3310
|
+
|
|
3311
|
+
SecurityPreviewContent.displayName = 'SecurityPreviewContent';
|
|
3312
|
+
|
|
3313
|
+
/** Preview content (without title — title shown in DrawerHeader) */
|
|
3314
|
+
export const renderSecurityPreviewContent = (row: { original: SecurityEvent }) => (
|
|
3315
|
+
<SecurityPreviewContent row={row} />
|
|
3316
|
+
);
|
|
3317
|
+
|
|
3318
|
+
/** Duplicate securityEvents N times with unique IDs */
|
|
3319
|
+
export const multiplySecurityEvents = (times = 4): SecurityEvent[] =>
|
|
3320
|
+
Array.from({ length: times }, (_, batch) =>
|
|
3321
|
+
securityEvents.map(row => ({
|
|
3322
|
+
...row,
|
|
3323
|
+
id: `${row.id}-${batch}`,
|
|
3324
|
+
objectName: batch === 0 ? row.objectName : `${row.objectName} (${batch + 1})`,
|
|
3325
|
+
})),
|
|
3326
|
+
).flat();
|
|
3327
|
+
|
|
3328
|
+
export const createLargeSecurityEvents = (count = 1000): SecurityEvent[] =>
|
|
3329
|
+
Array.from({ length: count }, (_, i) => {
|
|
3330
|
+
const base = securityEvents.at(i % securityEvents.length)!;
|
|
3331
|
+
|
|
3332
|
+
return {
|
|
3333
|
+
...base,
|
|
3334
|
+
id: String(i + 1),
|
|
3335
|
+
// Deterministic pseudo-random spread so visual snapshots are stable across runs.
|
|
3336
|
+
requests: (i * 2_654_435_761) % 100_000,
|
|
3337
|
+
firstDetected: `2026-01-${String((i % 28) + 1).padStart(2, '0')}T${String(i % 24).padStart(2, '0')}:00:00`,
|
|
3338
|
+
};
|
|
3339
|
+
});
|
|
3340
|
+
|
|
3341
|
+
// ---------------------------------------------------------------------------
|
|
3342
|
+
// FullFeatured — all features combined: grouping, virtual scroll, sorting,
|
|
3343
|
+
// selection, resizing, pinning, DnD ordering, visibility, action bar,
|
|
3344
|
+
// context menu
|
|
3345
|
+
// ---------------------------------------------------------------------------
|
|
3346
|
+
|
|
3347
|
+
export const fullFeaturedColumns: TableColumnDef<SecurityHeaderEntry>[] = headerColumns.map(col => {
|
|
3348
|
+
const key = 'accessorKey' in col ? col.accessorKey : undefined;
|
|
3349
|
+
|
|
3350
|
+
if (key === 'objectName') {
|
|
3351
|
+
return {
|
|
3352
|
+
...col,
|
|
3353
|
+
size: 220,
|
|
3354
|
+
meta: {
|
|
3355
|
+
...col.meta,
|
|
3356
|
+
resizeType: 'cut' as const,
|
|
3357
|
+
description: { type: 'tooltip' as const, content: 'Target resource' },
|
|
3358
|
+
},
|
|
3359
|
+
cell: ({ getValue }: { getValue: () => string }) => (
|
|
3360
|
+
<DropdownMenu>
|
|
3361
|
+
<DropdownMenuContextTrigger>
|
|
3362
|
+
<OverflowTooltip>
|
|
3363
|
+
<OverflowTooltipTrigger asChild>
|
|
3364
|
+
<Text size='sm' truncate>
|
|
3365
|
+
{getValue()}
|
|
3366
|
+
</Text>
|
|
3367
|
+
</OverflowTooltipTrigger>
|
|
3368
|
+
<OverflowTooltipContent>{getValue()}</OverflowTooltipContent>
|
|
3369
|
+
</OverflowTooltip>
|
|
3370
|
+
</DropdownMenuContextTrigger>
|
|
3371
|
+
<DropdownMenuContent>
|
|
3372
|
+
<DropdownMenuItem onSelect={() => alert(`Copied: ${getValue()}`)}>
|
|
3373
|
+
<DropdownMenuItemIcon>
|
|
3374
|
+
<Copy />
|
|
3375
|
+
</DropdownMenuItemIcon>
|
|
3376
|
+
<DropdownMenuItemText>Copy value</DropdownMenuItemText>
|
|
3377
|
+
</DropdownMenuItem>
|
|
3378
|
+
|
|
3379
|
+
<DropdownMenuSeparator />
|
|
3380
|
+
|
|
3381
|
+
<DropdownMenuItem onSelect={() => alert(`Show only: ${getValue()}`)}>
|
|
3382
|
+
<DropdownMenuItemIcon>
|
|
3383
|
+
<Filter />
|
|
3384
|
+
</DropdownMenuItemIcon>
|
|
3385
|
+
<DropdownMenuItemContent>
|
|
3386
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
3387
|
+
<DropdownMenuItemDescription>Filter to matching rows</DropdownMenuItemDescription>
|
|
3388
|
+
</DropdownMenuItemContent>
|
|
3389
|
+
</DropdownMenuItem>
|
|
3390
|
+
|
|
3391
|
+
<DropdownMenuItem onSelect={() => alert(`Exclude: ${getValue()}`)}>
|
|
3392
|
+
<DropdownMenuItemIcon>
|
|
3393
|
+
<FilterX />
|
|
3394
|
+
</DropdownMenuItemIcon>
|
|
3395
|
+
<DropdownMenuItemContent>
|
|
3396
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
3397
|
+
<DropdownMenuItemDescription>Filter out matching rows</DropdownMenuItemDescription>
|
|
3398
|
+
</DropdownMenuItemContent>
|
|
3399
|
+
</DropdownMenuItem>
|
|
3400
|
+
</DropdownMenuContent>
|
|
3401
|
+
</DropdownMenu>
|
|
3402
|
+
),
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
if (key === 'ip') {
|
|
3407
|
+
return {
|
|
3408
|
+
...col,
|
|
3409
|
+
meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Origin IP' } },
|
|
3410
|
+
cell: ({ row }: { row: { original: SecurityHeaderEntry } }) => (
|
|
3411
|
+
<DropdownMenu>
|
|
3412
|
+
<DropdownMenuContextTrigger>
|
|
3413
|
+
<Ip>
|
|
3414
|
+
{row.original.ipCountry && <IpCountry code={row.original.ipCountry} />}
|
|
3415
|
+
<IpAddress>{row.original.ip}</IpAddress>
|
|
3416
|
+
{row.original.provider && (
|
|
3417
|
+
<IpProvider>{sourceLabels[row.original.provider as SourceKey]}</IpProvider>
|
|
3418
|
+
)}
|
|
3419
|
+
</Ip>
|
|
3420
|
+
</DropdownMenuContextTrigger>
|
|
3421
|
+
<DropdownMenuContent>
|
|
3422
|
+
<DropdownMenuItem onSelect={() => alert(`Copied: ${row.original.ip}`)}>
|
|
3423
|
+
<DropdownMenuItemIcon>
|
|
3424
|
+
<Copy />
|
|
3425
|
+
</DropdownMenuItemIcon>
|
|
3426
|
+
<DropdownMenuItemText>Copy IP</DropdownMenuItemText>
|
|
3427
|
+
</DropdownMenuItem>
|
|
3428
|
+
|
|
3429
|
+
<DropdownMenuSeparator />
|
|
3430
|
+
|
|
3431
|
+
<DropdownMenuItem onSelect={() => alert(`Show only: ${row.original.ip}`)}>
|
|
3432
|
+
<DropdownMenuItemIcon>
|
|
3433
|
+
<Filter />
|
|
3434
|
+
</DropdownMenuItemIcon>
|
|
3435
|
+
<DropdownMenuItemContent>
|
|
3436
|
+
<DropdownMenuItemText>Show only</DropdownMenuItemText>
|
|
3437
|
+
<DropdownMenuItemDescription>Filter to this IP</DropdownMenuItemDescription>
|
|
3438
|
+
</DropdownMenuItemContent>
|
|
3439
|
+
</DropdownMenuItem>
|
|
3440
|
+
|
|
3441
|
+
<DropdownMenuItem onSelect={() => alert(`Exclude: ${row.original.ip}`)}>
|
|
3442
|
+
<DropdownMenuItemIcon>
|
|
3443
|
+
<FilterX />
|
|
3444
|
+
</DropdownMenuItemIcon>
|
|
3445
|
+
<DropdownMenuItemContent>
|
|
3446
|
+
<DropdownMenuItemText>Exclude</DropdownMenuItemText>
|
|
3447
|
+
<DropdownMenuItemDescription>Filter out this IP</DropdownMenuItemDescription>
|
|
3448
|
+
</DropdownMenuItemContent>
|
|
3449
|
+
</DropdownMenuItem>
|
|
3450
|
+
</DropdownMenuContent>
|
|
3451
|
+
</DropdownMenu>
|
|
3452
|
+
),
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
if (key === 'requests') {
|
|
3457
|
+
return {
|
|
3458
|
+
...col,
|
|
3459
|
+
meta: { ...col.meta, description: { type: 'text' as const, content: 'Total hits' } },
|
|
3460
|
+
};
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
if (key === 'status') {
|
|
3464
|
+
return {
|
|
3465
|
+
...col,
|
|
3466
|
+
meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Resolution state' } },
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
if (key === 'lastEdited') {
|
|
3471
|
+
return {
|
|
3472
|
+
...col,
|
|
3473
|
+
meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Last modified' } },
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
if (key === 'lastSynced') {
|
|
3478
|
+
return {
|
|
3479
|
+
...col,
|
|
3480
|
+
meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Last sync time' } },
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
return col;
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
// ---------------------------------------------------------------------------
|
|
3488
|
+
// Infinite scroll — hook for stories
|
|
3489
|
+
// ---------------------------------------------------------------------------
|
|
3490
|
+
|
|
3491
|
+
const INFINITE_PAGE_SIZE = 50;
|
|
3492
|
+
const INFINITE_MAX_ITEMS = 500;
|
|
3493
|
+
const BIDIRECTIONAL_TOTAL = 500;
|
|
3494
|
+
const BIDIRECTIONAL_PAGE_SIZE = 50;
|
|
3495
|
+
const BIDIRECTIONAL_INITIAL_ANCHOR_INDEX = 250;
|
|
3496
|
+
|
|
3497
|
+
export const useInfiniteData = () => {
|
|
3498
|
+
const allData = useMemo(() => createLargeSecurityEvents(INFINITE_MAX_ITEMS), []);
|
|
3499
|
+
const [data, setData] = useState(() => allData.slice(0, INFINITE_PAGE_SIZE));
|
|
3500
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
3501
|
+
const hasMore = data.length < allData.length;
|
|
3502
|
+
|
|
3503
|
+
const fetchNextPage = useCallback(() => {
|
|
3504
|
+
if (isFetching || !hasMore) return;
|
|
3505
|
+
setIsFetching(true);
|
|
3506
|
+
|
|
3507
|
+
// Simulate network delay
|
|
3508
|
+
setTimeout(() => {
|
|
3509
|
+
setData(prev => allData.slice(0, prev.length + INFINITE_PAGE_SIZE));
|
|
3510
|
+
setIsFetching(false);
|
|
3511
|
+
}, 800);
|
|
3512
|
+
}, [isFetching, hasMore, allData]);
|
|
3513
|
+
|
|
3514
|
+
return { data, isFetching, hasMore, totalItems: INFINITE_MAX_ITEMS, fetchNextPage };
|
|
3515
|
+
};
|
|
3516
|
+
|
|
3517
|
+
/**
|
|
3518
|
+
* Mock for bidirectional infinite scroll: opens a window around an anchor row
|
|
3519
|
+
* and exposes cursor-style fetchers for both directions.
|
|
3520
|
+
*/
|
|
3521
|
+
export const useBidirectionalData = () => {
|
|
3522
|
+
const allData = useMemo(() => createLargeSecurityEvents(BIDIRECTIONAL_TOTAL), []);
|
|
3523
|
+
|
|
3524
|
+
const initialStart = Math.max(0, BIDIRECTIONAL_INITIAL_ANCHOR_INDEX - BIDIRECTIONAL_PAGE_SIZE);
|
|
3525
|
+
const initialEnd = Math.min(
|
|
3526
|
+
allData.length,
|
|
3527
|
+
BIDIRECTIONAL_INITIAL_ANCHOR_INDEX + BIDIRECTIONAL_PAGE_SIZE,
|
|
3528
|
+
);
|
|
3529
|
+
|
|
3530
|
+
const [range, setRange] = useState({ start: initialStart, end: initialEnd });
|
|
3531
|
+
// Separate flags so each direction drives only its own loader, mirroring a
|
|
3532
|
+
// real bidirectional source where scroll-up and scroll-down fetch in parallel.
|
|
3533
|
+
const [isFetchingPrev, setIsFetchingPrev] = useState(false);
|
|
3534
|
+
const [isFetchingNext, setIsFetchingNext] = useState(false);
|
|
3535
|
+
|
|
3536
|
+
const data = useMemo(() => allData.slice(range.start, range.end), [allData, range]);
|
|
3537
|
+
const anchorId = allData[BIDIRECTIONAL_INITIAL_ANCHOR_INDEX]?.id;
|
|
3538
|
+
const hasPrev = range.start > 0;
|
|
3539
|
+
const hasNext = range.end < allData.length;
|
|
3540
|
+
|
|
3541
|
+
const fetchPrevPage = useCallback(() => {
|
|
3542
|
+
if (isFetchingPrev || range.start <= 0) return;
|
|
3543
|
+
setIsFetchingPrev(true);
|
|
3544
|
+
setTimeout(() => {
|
|
3545
|
+
setRange(prev => ({ ...prev, start: Math.max(0, prev.start - BIDIRECTIONAL_PAGE_SIZE) }));
|
|
3546
|
+
setIsFetchingPrev(false);
|
|
3547
|
+
}, 600);
|
|
3548
|
+
}, [isFetchingPrev, range.start]);
|
|
3549
|
+
|
|
3550
|
+
const fetchNextPage = useCallback(() => {
|
|
3551
|
+
if (isFetchingNext || range.end >= allData.length) return;
|
|
3552
|
+
setIsFetchingNext(true);
|
|
3553
|
+
setTimeout(() => {
|
|
3554
|
+
setRange(prev => ({
|
|
3555
|
+
...prev,
|
|
3556
|
+
end: Math.min(allData.length, prev.end + BIDIRECTIONAL_PAGE_SIZE),
|
|
3557
|
+
}));
|
|
3558
|
+
setIsFetchingNext(false);
|
|
3559
|
+
}, 600);
|
|
3560
|
+
}, [isFetchingNext, range.end, allData.length]);
|
|
3561
|
+
|
|
3562
|
+
return {
|
|
3563
|
+
data,
|
|
3564
|
+
anchorId,
|
|
3565
|
+
isFetchingPrev,
|
|
3566
|
+
isFetchingNext,
|
|
3567
|
+
hasPrev,
|
|
3568
|
+
hasNext,
|
|
3569
|
+
fetchPrevPage,
|
|
3570
|
+
fetchNextPage,
|
|
3571
|
+
};
|
|
3572
|
+
};
|
|
3573
|
+
```
|
|
3574
|
+
|
|
3575
|
+
### ../utils/abbreviateNumber.ts
|
|
3576
|
+
|
|
3577
|
+
```tsx
|
|
3578
|
+
/**
|
|
3579
|
+
* Suffix tiers: [minAbsValue, divisor, suffix].
|
|
3580
|
+
* Sorted descending so the first match wins.
|
|
3581
|
+
*
|
|
3582
|
+
* Boundaries use "rounding-aware" thresholds:
|
|
3583
|
+
* 999,500 rounds to 1M (not 999.5k), 999,499 stays 999k.
|
|
3584
|
+
*/
|
|
3585
|
+
const tiers: [threshold: number, divisor: number, suffix: string][] = [
|
|
3586
|
+
[999_500_000_000, 1e12, 'T'],
|
|
3587
|
+
[999_500_000, 1e9, 'B'],
|
|
3588
|
+
[999_500, 1e6, 'M'],
|
|
3589
|
+
[1_000, 1e3, 'k'],
|
|
3590
|
+
];
|
|
3591
|
+
|
|
3592
|
+
const fullFormatter = new Intl.NumberFormat('en-US');
|
|
3593
|
+
|
|
3594
|
+
/**
|
|
3595
|
+
* Abbreviate a number for table cells: `12042` → `"12k"`, `59614283` → `"59.6M"`.
|
|
3596
|
+
*
|
|
3597
|
+
* Rules (from the Number display & abbreviation guide):
|
|
3598
|
+
* - Suffixes: k (10³), M (10⁶), B (10⁹), T (10¹²) — k lowercase, rest uppercase, no space
|
|
3599
|
+
* - Boundary: moves to next suffix when rounding would reach 1000×
|
|
3600
|
+
* - Precision: ≥100 → 0 decimals; 10–99 → 1 decimal (drop .0); 1–9.9 → 1 decimal
|
|
3601
|
+
* - Negative numbers supported
|
|
3602
|
+
*/
|
|
3603
|
+
export const abbreviateNumber = (value: number): string => {
|
|
3604
|
+
if (value === 0) return '0';
|
|
3605
|
+
|
|
3606
|
+
const sign = value < 0 ? '-' : '';
|
|
3607
|
+
const abs = Math.abs(value);
|
|
3608
|
+
|
|
3609
|
+
if (abs < 1_000) return String(value);
|
|
3610
|
+
|
|
3611
|
+
const tier = tiers.find(([threshold]) => abs >= threshold);
|
|
3612
|
+
|
|
3613
|
+
if (!tier) return String(value);
|
|
3614
|
+
|
|
3615
|
+
const [, divisor, suffix] = tier;
|
|
3616
|
+
const scaled = abs / divisor;
|
|
3617
|
+
|
|
3618
|
+
// ≥100 → 0 decimals; <100 → 1 decimal (drop trailing .0)
|
|
3619
|
+
const precision = scaled >= 100 ? 0 : 1;
|
|
3620
|
+
const rounded = Math.round(scaled * 10 ** precision) / 10 ** precision;
|
|
3621
|
+
const formatted = rounded % 1 === 0 ? String(rounded) : rounded.toFixed(1);
|
|
3622
|
+
|
|
3623
|
+
return `${sign}${formatted}${suffix}`;
|
|
3624
|
+
};
|
|
3625
|
+
|
|
3626
|
+
/**
|
|
3627
|
+
* Format a number with thousand separators for tooltips: `59614283` → `"59,614,283"`.
|
|
3628
|
+
*/
|
|
3629
|
+
export const formatFullNumber = (value: number): string => fullFormatter.format(value);
|
|
3630
|
+
|
|
3631
|
+
/**
|
|
3632
|
+
* Format a percentage with boundary safety:
|
|
3633
|
+
* - Never show 100% when real value < 100 → ">99%" (or ">99.9%" etc.)
|
|
3634
|
+
* - Never show 0% when real value > 0 → "<1%" (or "<0.1%" etc.)
|
|
3635
|
+
* - `decimals` controls fractional precision (default 0)
|
|
3636
|
+
*/
|
|
3637
|
+
export const formatPercent = (value: number, decimals = 0): string => {
|
|
3638
|
+
if (value === 0) return '0%';
|
|
3639
|
+
if (value === 100) return '100%';
|
|
3640
|
+
|
|
3641
|
+
const factor = 10 ** decimals;
|
|
3642
|
+
const rounded = Math.round(value * factor) / factor;
|
|
3643
|
+
|
|
3644
|
+
// Boundary safety: value > 0 but rounds to 0
|
|
3645
|
+
if (value > 0 && rounded === 0) {
|
|
3646
|
+
const threshold = 1 / factor;
|
|
3647
|
+
return `<${threshold}%`;
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// Boundary safety: value < 100 but rounds to 100
|
|
3651
|
+
if (value < 100 && rounded === 100) {
|
|
3652
|
+
const max = 100 - 1 / factor;
|
|
3653
|
+
return `>${decimals > 0 ? max.toFixed(decimals) : String(max)}%`;
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
return `${decimals > 0 ? rounded.toFixed(decimals) : String(rounded)}%`;
|
|
3657
|
+
};
|
|
3658
|
+
|
|
3659
|
+
/**
|
|
3660
|
+
* Byte-formatting tiers: [minValue, divisor, suffix].
|
|
3661
|
+
* Uses decimal (SI) units: KB = 1000, MB = 1e6, etc.
|
|
3662
|
+
*/
|
|
3663
|
+
const byteTiers: [threshold: number, divisor: number, suffix: string][] = [
|
|
3664
|
+
[999_500_000_000, 1e12, 'TB'],
|
|
3665
|
+
[999_500_000, 1e9, 'GB'],
|
|
3666
|
+
[999_500, 1e6, 'MB'],
|
|
3667
|
+
[1_000, 1e3, 'KB'],
|
|
3668
|
+
];
|
|
3669
|
+
|
|
3670
|
+
/**
|
|
3671
|
+
* Format bytes using decimal (SI) units: 512 B, 3.4 KB, 12.7 MB, 2.3 GB, 1.1 TB.
|
|
3672
|
+
* Same decimal precision rules as abbreviateNumber.
|
|
3673
|
+
* Returns `{ display, full }` where `full` = "2,345,678,901 bytes".
|
|
3674
|
+
*/
|
|
3675
|
+
export const formatBytes = (value: number): { display: string; full: string } => {
|
|
3676
|
+
const full = `${fullFormatter.format(value)} bytes`;
|
|
3677
|
+
|
|
3678
|
+
if (value < 1_000) {
|
|
3679
|
+
return { display: `${value} B`, full };
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
const tier = byteTiers.find(([threshold]) => value >= threshold);
|
|
3683
|
+
|
|
3684
|
+
if (!tier) {
|
|
3685
|
+
return { display: `${value} B`, full };
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
const [, divisor, suffix] = tier;
|
|
3689
|
+
const scaled = value / divisor;
|
|
3690
|
+
|
|
3691
|
+
const precision = scaled >= 100 ? 0 : 1;
|
|
3692
|
+
const rounded = Math.round(scaled * 10 ** precision) / 10 ** precision;
|
|
3693
|
+
const formatted = rounded % 1 === 0 ? String(rounded) : rounded.toFixed(1);
|
|
3694
|
+
|
|
3695
|
+
return { display: `${formatted} ${suffix}`, full };
|
|
3696
|
+
};
|
|
3697
|
+
```
|