Show account tx history
This commit is contained in:
parent
73922609e4
commit
e5f69673d7
|
@ -1269,9 +1269,8 @@
|
|||
"integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw=="
|
||||
},
|
||||
"@solana/web3.js": {
|
||||
"version": "0.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.43.0.tgz",
|
||||
"integrity": "sha512-Zf+UfoAuXdn8WwvYSVb8k4hQ484L2ZK6eXlW5EN048273Nks0+Sf44QrUpq2AhYVm6KAT7siyB8Nhgo0jQAG6A==",
|
||||
"version": "github:jstarry/solana-web3.js#c7d8fe33877a9eae354b456e019ad5044c25eeb7",
|
||||
"from": "github:jstarry/solana-web3.js#address-history",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"bn.js": "^5.0.0",
|
||||
|
@ -1296,9 +1295,9 @@
|
|||
"integrity": "sha512-IUTD/REb78Z2eodka1QZyyEk66pciRcP6Sroka0aI3tG/iwIdYLrBD62RsubR7vqdt3WyX8p4jxeatzmRSphtA=="
|
||||
},
|
||||
"buffer": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz",
|
||||
"integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==",
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
|
||||
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
|
||||
"requires": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4"
|
||||
|
@ -1627,9 +1626,9 @@
|
|||
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
|
||||
},
|
||||
"@types/express-serve-static-core": {
|
||||
"version": "4.17.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.3.tgz",
|
||||
"integrity": "sha512-sHEsvEzjqN+zLbqP+8OXTipc10yH1QLR+hnr5uw29gi9AhCAAAdri8ClNV7iMdrJrIzXIQtlkPvq8tJGhj3QJQ==",
|
||||
"version": "4.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz",
|
||||
"integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==",
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"@types/range-parser": "*"
|
||||
|
@ -1686,9 +1685,9 @@
|
|||
"integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA=="
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.149",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
|
||||
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ=="
|
||||
"version": "4.14.150",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.150.tgz",
|
||||
"integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w=="
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
|
@ -7205,9 +7204,9 @@
|
|||
}
|
||||
},
|
||||
"jayson": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jayson/-/jayson-3.2.0.tgz",
|
||||
"integrity": "sha512-DZQnwA57GcStw4soSYB2VntWXFfoWvmSarlaWePDYOWhjxT72PBM4atEBomaTaS1uqk3jFC9UO9AyWjlujo3xw==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jayson/-/jayson-3.2.1.tgz",
|
||||
"integrity": "sha512-8YfxxjQdcSVMr0/+7B1+aGUFAI+yQYfTWBOI+rq+PBu35HL7nJ3wayC2fTXeUJZqad/I4UoDwMtBTNVWJ9rzlg==",
|
||||
"requires": {
|
||||
"@types/connect": "^3.4.32",
|
||||
"@types/express-serve-static-core": "^4.16.9",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@solana/web3.js": "^0.43.0",
|
||||
"@solana/web3.js": "github:jstarry/solana-web3.js#address-history",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
|
|
|
@ -8,13 +8,19 @@ import {
|
|||
useTransactions,
|
||||
ActionType
|
||||
} from "./providers/transactions";
|
||||
import { AccountsProvider } from "./providers/accounts";
|
||||
import {
|
||||
AccountsProvider,
|
||||
useSelectedAccount,
|
||||
useAccountsDispatch,
|
||||
ActionType as AccountActionType
|
||||
} from "./providers/accounts";
|
||||
import { BlocksProvider } from "./providers/blocks";
|
||||
import ClusterStatusButton from "./components/ClusterStatusButton";
|
||||
import AccountsCard from "./components/AccountsCard";
|
||||
import TransactionsCard from "./components/TransactionsCard";
|
||||
import ClusterModal from "./components/ClusterModal";
|
||||
import TransactionModal from "./components/TransactionModal";
|
||||
import AccountModal from "./components/AccountModal";
|
||||
import Logo from "./img/logos-solana/light-explorer-logo.svg";
|
||||
import { useCurrentTab, Tab } from "./providers/tab";
|
||||
|
||||
|
@ -31,6 +37,7 @@ function App() {
|
|||
onClose={() => setShowClusterModal(false)}
|
||||
/>
|
||||
<TransactionModal />
|
||||
<AccountModal />
|
||||
<div className="main-content">
|
||||
<nav className="navbar navbar-expand-xl navbar-light">
|
||||
<div className="container">
|
||||
|
@ -120,15 +127,18 @@ type OverlayProps = {
|
|||
|
||||
function Overlay({ show, onClick }: OverlayProps) {
|
||||
const { selected } = useTransactions();
|
||||
const dispatch = useTransactionsDispatch();
|
||||
const selectedAccount = useSelectedAccount();
|
||||
const txDispatch = useTransactionsDispatch();
|
||||
const accountDispatch = useAccountsDispatch();
|
||||
|
||||
if (show || !!selected)
|
||||
if (show || !!selected || !!selectedAccount)
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop fade show"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
dispatch({ type: ActionType.Deselect });
|
||||
txDispatch({ type: ActionType.Deselect });
|
||||
accountDispatch({ type: AccountActionType.Select });
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
import React from "react";
|
||||
import {
|
||||
useSelectedAccount,
|
||||
useAccountsDispatch,
|
||||
ActionType,
|
||||
Account,
|
||||
Status
|
||||
} from "../providers/accounts";
|
||||
import { TransactionError } from "@solana/web3.js";
|
||||
|
||||
function AccountModal() {
|
||||
const selected = useSelectedAccount();
|
||||
const dispatch = useAccountsDispatch();
|
||||
const onClose = () => dispatch({ type: ActionType.Select });
|
||||
const show = !!selected;
|
||||
|
||||
const renderContent = () => {
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-card card">
|
||||
<div className="card-header">
|
||||
<h4 className="card-header-title">Account Transaction History</h4>
|
||||
<button type="button" className="close" onClick={onClose}>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<AccountDetails account={selected} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`modal fade${show ? " show" : ""}`} onClick={onClose}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountDetails({ account }: { account: Account }) {
|
||||
const renderError = (content: React.ReactNode) => {
|
||||
return <span className="text-info">{content}</span>;
|
||||
};
|
||||
|
||||
if (account.status === Status.FetchingHistory) {
|
||||
return renderError(
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||
Loading
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.history === undefined)
|
||||
return renderError("Failed to fetch account transaction history");
|
||||
|
||||
if (account.history.size === 0) return renderError("No transactions found");
|
||||
|
||||
const detailsList: React.ReactNode[] = [];
|
||||
account.history.forEach((slotTransactions, slot) => {
|
||||
detailsList.push(
|
||||
<SlotTransactionDetails
|
||||
slot={slot}
|
||||
statuses={Array.from(slotTransactions.entries())}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailsList.map((details, i) => {
|
||||
return (
|
||||
<React.Fragment key={++i}>
|
||||
{i > 1 ? <hr className="mt-0 mx-n4 mb-4"></hr> : null}
|
||||
{details}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotTransactionDetails({
|
||||
slot,
|
||||
statuses
|
||||
}: {
|
||||
slot: number;
|
||||
statuses: [string, TransactionError | null][];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="slot-pill">Slot #{slot}</h4>
|
||||
<div className="list-group list-group-flush">
|
||||
{statuses.map(([signature, err]) => {
|
||||
return (
|
||||
<ListGroupItem
|
||||
key={signature}
|
||||
signature={signature}
|
||||
failed={err !== null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ListGroupItem({
|
||||
signature,
|
||||
failed
|
||||
}: {
|
||||
signature: string;
|
||||
failed: boolean;
|
||||
}) {
|
||||
let badgeText, badgeColor;
|
||||
if (failed) {
|
||||
badgeText = "Error";
|
||||
badgeColor = "danger";
|
||||
} else {
|
||||
badgeText = "Success";
|
||||
badgeColor = "primary";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-group-item slot-item">
|
||||
<div className="row align-items-center justify-content-between flex-nowrap">
|
||||
<div className="col-auto">
|
||||
<span className={`badge badge-soft-${badgeColor} badge-pill`}>
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col min-width-0">
|
||||
<h5 className="mb-0 text-truncate">
|
||||
<code>{signature}</code>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountModal;
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import {
|
||||
useAccounts,
|
||||
useAccountsDispatch,
|
||||
Dispatch,
|
||||
fetchAccountInfo,
|
||||
ActionType,
|
||||
Account,
|
||||
|
@ -55,6 +56,7 @@ function AccountsCard() {
|
|||
<th className="text-muted">Balance (SOL)</th>
|
||||
<th className="text-muted">Data (bytes)</th>
|
||||
<th className="text-muted">Owner</th>
|
||||
<th className="text-muted">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
|
@ -86,8 +88,9 @@ function AccountsCard() {
|
|||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{accounts.map(account => renderAccountRow(account))}
|
||||
{accounts.map(account => renderAccountRow(account, dispatch, url))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -107,7 +110,11 @@ const renderHeader = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderAccountRow = (account: Account) => {
|
||||
const renderAccountRow = (
|
||||
account: Account,
|
||||
dispatch: Dispatch,
|
||||
url: string
|
||||
) => {
|
||||
let statusText;
|
||||
let statusClass;
|
||||
switch (account.status) {
|
||||
|
@ -116,10 +123,12 @@ const renderAccountRow = (account: Account) => {
|
|||
statusText = "Not Found";
|
||||
break;
|
||||
case Status.CheckFailed:
|
||||
case Status.HistoryFailed:
|
||||
statusClass = "danger";
|
||||
statusText = "Error";
|
||||
break;
|
||||
case Status.Checking:
|
||||
case Status.FetchingHistory:
|
||||
statusClass = "info";
|
||||
statusText = "Fetching";
|
||||
break;
|
||||
|
@ -148,6 +157,42 @@ const renderAccountRow = (account: Account) => {
|
|||
balance = `◎${(1.0 * account.lamports) / LAMPORTS_PER_SOL}`;
|
||||
}
|
||||
|
||||
const renderDetails = () => {
|
||||
let onClick, icon;
|
||||
switch (account.status) {
|
||||
case Status.Success:
|
||||
icon = "more-horizontal";
|
||||
onClick = () =>
|
||||
dispatch({
|
||||
type: ActionType.Select,
|
||||
address: account.pubkey.toBase58()
|
||||
});
|
||||
break;
|
||||
|
||||
case Status.CheckFailed:
|
||||
case Status.HistoryFailed: {
|
||||
icon = "refresh-cw";
|
||||
onClick = () => {
|
||||
fetchAccountInfo(dispatch, account.pubkey.toBase58(), url);
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-rounded-circle btn-white btn-sm"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`fe fe-${icon}`}></span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const base58AccountPubkey = account.pubkey.toBase58();
|
||||
return (
|
||||
<tr key={account.id}>
|
||||
|
@ -165,6 +210,7 @@ const renderAccountRow = (account: Account) => {
|
|||
<td>{balance}</td>
|
||||
<td>{data}</td>
|
||||
<td>{owner === "-" ? owner : <code>{owner}</code>}</td>
|
||||
<td>{renderDetails()}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,15 +1,28 @@
|
|||
import React from "react";
|
||||
import { PublicKey, Connection } from "@solana/web3.js";
|
||||
import {
|
||||
PublicKey,
|
||||
Connection,
|
||||
TransactionSignature,
|
||||
TransactionError,
|
||||
SignatureStatus
|
||||
} from "@solana/web3.js";
|
||||
import { findGetParameter, findPathSegment } from "../utils/url";
|
||||
import { useCluster, ClusterStatus } from "./cluster";
|
||||
|
||||
export enum Status {
|
||||
Checking,
|
||||
CheckFailed,
|
||||
FetchingHistory,
|
||||
HistoryFailed,
|
||||
NotFound,
|
||||
Success
|
||||
}
|
||||
|
||||
export type History = Map<
|
||||
number,
|
||||
Map<TransactionSignature, TransactionError | null>
|
||||
>;
|
||||
|
||||
enum Source {
|
||||
Url,
|
||||
Input
|
||||
|
@ -23,30 +36,36 @@ export interface Details {
|
|||
|
||||
export interface Account {
|
||||
id: number;
|
||||
status: Status;
|
||||
source: Source;
|
||||
pubkey: PublicKey;
|
||||
source: Source;
|
||||
status: Status;
|
||||
lamports?: number;
|
||||
details?: Details;
|
||||
history?: History;
|
||||
}
|
||||
|
||||
type Accounts = { [address: string]: Account };
|
||||
interface State {
|
||||
idCounter: number;
|
||||
accounts: Accounts;
|
||||
selected?: string;
|
||||
}
|
||||
|
||||
export enum ActionType {
|
||||
Update,
|
||||
Input
|
||||
Input,
|
||||
Select
|
||||
}
|
||||
|
||||
interface Update {
|
||||
type: ActionType.Update;
|
||||
address: string;
|
||||
status: Status;
|
||||
lamports?: number;
|
||||
details?: Details;
|
||||
data: {
|
||||
status: Status;
|
||||
lamports?: number;
|
||||
details?: Details;
|
||||
history?: History;
|
||||
};
|
||||
}
|
||||
|
||||
interface Input {
|
||||
|
@ -54,7 +73,12 @@ interface Input {
|
|||
pubkey: PublicKey;
|
||||
}
|
||||
|
||||
type Action = Update | Input;
|
||||
interface Select {
|
||||
type: ActionType.Select;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
type Action = Update | Input | Select;
|
||||
export type Dispatch = (action: Action) => void;
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
|
@ -74,14 +98,17 @@ function reducer(state: State, action: Action): State {
|
|||
};
|
||||
return { ...state, accounts, idCounter };
|
||||
}
|
||||
|
||||
case ActionType.Select: {
|
||||
return { ...state, selected: action.address };
|
||||
}
|
||||
|
||||
case ActionType.Update: {
|
||||
let account = state.accounts[action.address];
|
||||
if (account) {
|
||||
account = {
|
||||
...account,
|
||||
status: action.status,
|
||||
details: action.details,
|
||||
lamports: action.lamports
|
||||
...action.data
|
||||
};
|
||||
const accounts = {
|
||||
...state.accounts,
|
||||
|
@ -167,8 +194,10 @@ export async function fetchAccountInfo(
|
|||
) {
|
||||
dispatch({
|
||||
type: ActionType.Update,
|
||||
status: Status.Checking,
|
||||
address
|
||||
address,
|
||||
data: {
|
||||
status: Status.Checking
|
||||
}
|
||||
});
|
||||
|
||||
let status;
|
||||
|
@ -188,13 +217,60 @@ export async function fetchAccountInfo(
|
|||
executable: result.executable,
|
||||
owner: result.owner
|
||||
};
|
||||
status = Status.Success;
|
||||
status = Status.FetchingHistory;
|
||||
fetchAccountHistory(dispatch, address, url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch account info", error);
|
||||
status = Status.CheckFailed;
|
||||
}
|
||||
dispatch({ type: ActionType.Update, status, lamports, details, address });
|
||||
const data = { status, lamports, details };
|
||||
dispatch({ type: ActionType.Update, data, address });
|
||||
}
|
||||
|
||||
async function fetchAccountHistory(
|
||||
dispatch: Dispatch,
|
||||
address: string,
|
||||
url: string
|
||||
) {
|
||||
let history;
|
||||
let status;
|
||||
try {
|
||||
const connection = new Connection(url);
|
||||
const currentSlot = await connection.getSlot();
|
||||
const signatures = await connection.getConfirmedSignaturesForAddress(
|
||||
new PublicKey(address),
|
||||
Math.max(0, currentSlot - 10000 + 1),
|
||||
currentSlot
|
||||
);
|
||||
|
||||
let statuses: (SignatureStatus | null)[] = [];
|
||||
if (signatures.length > 0) {
|
||||
statuses = (
|
||||
await connection.getSignatureStatuses(signatures, {
|
||||
searchTransactionHistory: true
|
||||
})
|
||||
).value;
|
||||
}
|
||||
|
||||
history = new Map();
|
||||
for (let i = 0; i < statuses.length; i++) {
|
||||
const status = statuses[i];
|
||||
if (!status) continue;
|
||||
let slotSignatures = history.get(status.slot);
|
||||
if (!slotSignatures) {
|
||||
slotSignatures = new Map();
|
||||
history.set(status.slot, slotSignatures);
|
||||
}
|
||||
slotSignatures.set(signatures[i], status.err);
|
||||
}
|
||||
status = Status.Success;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch account history", error);
|
||||
status = Status.HistoryFailed;
|
||||
}
|
||||
const data = { status, history };
|
||||
dispatch({ type: ActionType.Update, data, address });
|
||||
}
|
||||
|
||||
export function useAccounts() {
|
||||
|
@ -210,6 +286,18 @@ export function useAccounts() {
|
|||
};
|
||||
}
|
||||
|
||||
export function useSelectedAccount() {
|
||||
const context = React.useContext(StateContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useSelectedAccount must be used within a AccountsProvider`
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.selected) return undefined;
|
||||
return context.accounts[context.selected];
|
||||
}
|
||||
|
||||
export function useAccountsDispatch() {
|
||||
const context = React.useContext(DispatchContext);
|
||||
if (!context) {
|
||||
|
|
|
@ -65,6 +65,33 @@ h4.ix-pill {
|
|||
background-color: theme-color-level(info, $badge-soft-bg-level);
|
||||
}
|
||||
|
||||
.list-group-flush .list-group-item.ix-item:first-child {
|
||||
border-top-width: 1px;
|
||||
h4.slot-pill {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
border-radius: $border-radius;
|
||||
margin-left: -5px;
|
||||
background-color: theme-color-level(primary, $badge-soft-bg-level);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.list-group-item:last-child {
|
||||
&.ix-item, &.slot-item {
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group:last-child .list-group-item:last-child {
|
||||
&.ix-item, &.slot-item {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item:first-child {
|
||||
&.ix-item, &.slot-item {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
Loading…
Reference in New Issue