mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor: remove pnpm, fix tests
This commit is contained in:
parent
861ee5acd7
commit
e34b0c9aca
65 changed files with 353 additions and 2458 deletions
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.3.0",
|
||||
"description": "Sync server state with clients using Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type AtomMap = types.AtomMap
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
local types = require(script.Parent.types)
|
||||
|
||||
type AtomMap = types.AtomMap
|
||||
|
||||
type NestedAtomMap = {
|
||||
|
|
|
|||
214
packages/charm-sync/src/index.d.ts
vendored
214
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,214 +0,0 @@
|
|||
import { Atom, Selector } from "@rbxts/charm";
|
||||
|
||||
export = CharmSync;
|
||||
export as namespace CharmSync;
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
type Key = string | number | symbol;
|
||||
|
||||
declare namespace CharmSync {
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
|
||||
type SelectorMap = Record<string, Selector<any>>;
|
||||
|
||||
/**
|
||||
* @deprecated Use `SelectorMap` instead.
|
||||
*/
|
||||
type MoleculeMap = SelectorMap;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
/**
|
||||
* Creates a `ClientSyncer` object that receives patches from the server and
|
||||
* applies them to the local state.
|
||||
*
|
||||
* @client
|
||||
* @param options The atoms to synchronize with the server.
|
||||
* @returns A `ClientSyncer` object.
|
||||
*/
|
||||
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
|
||||
|
||||
/**
|
||||
* Creates a `ServerSyncer` object that sends patches to the client and
|
||||
* hydrates the client's state.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
|
||||
options: ServerOptions<Selectors, Serialize>,
|
||||
): ServerSyncer<Selectors, Serialize>;
|
||||
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
*/
|
||||
function isNone(value: unknown): value is None;
|
||||
|
||||
interface NestedAtomMap {
|
||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
||||
}
|
||||
|
||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
||||
? Name
|
||||
: Name extends string
|
||||
? Path extends string
|
||||
? `${Path}/${Name}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
|
||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||
|
||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
||||
: Atoms[Name] extends NestedAtomMap
|
||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Flattens a nested atom map into a single object with slash-separated
|
||||
* keys. Useful for recursively collecting atoms returned by modules.
|
||||
*
|
||||
* @param atoms The nested atom map to flatten.
|
||||
* @return A flattened atom map.
|
||||
*/
|
||||
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
*/
|
||||
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? Serialize extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
||||
: State;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
|
||||
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
|
||||
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
||||
|
||||
interface ClientOptions<Atoms extends AtomMap> {
|
||||
/**
|
||||
* The atoms to synchronize with the server.
|
||||
*/
|
||||
atoms: Atoms;
|
||||
/**
|
||||
* Whether to ignore patches sent before the client has been hydrated.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
ignoreUnhydrated?: boolean;
|
||||
}
|
||||
|
||||
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
|
||||
/**
|
||||
* The atoms to synchronize with the client.
|
||||
*/
|
||||
atoms: Selectors;
|
||||
/**
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple times
|
||||
* per frame, where each individual change is important. Defaults to `false`.
|
||||
*
|
||||
* If `true`, the broadcaster will send a list of payloads to the client
|
||||
* instead of a single payload. The client will apply each payload in order
|
||||
* to reconstruct the state's changes over time.
|
||||
*/
|
||||
preserveHistory?: boolean;
|
||||
/**
|
||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
* to address remote event argument limitations. Defaults to `true`.
|
||||
*
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*/
|
||||
autoSerialize?: Serialize;
|
||||
}
|
||||
|
||||
interface ClientSyncer<Atoms extends AtomMap> {
|
||||
/**
|
||||
* Applies a patch or initializes the state of the atoms with the given
|
||||
* payload from the server.
|
||||
*
|
||||
* @param payloads The patches or hydration payloads to apply.
|
||||
*/
|
||||
sync(...payloads: SyncPayload<Atoms, boolean>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
|
||||
/**
|
||||
* Sets up a subscription to each atom that schedules a patch to be sent to
|
||||
* the client whenever the state changes. When a change occurs, the `callback`
|
||||
* is called with the player and the payloads to send.
|
||||
*
|
||||
* Note that a `payload` object should not be mutated. If you need to modify
|
||||
* a payload, apply the changes to a copy of the object.
|
||||
*
|
||||
* @param callback The function to call when the state changes.
|
||||
* @returns A cleanup function that unsubscribes all listeners.
|
||||
*/
|
||||
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors, Serialize>[]) => void): Cleanup;
|
||||
/**
|
||||
* Hydrates the client's state with the server's state. This should be
|
||||
* called when a player joins the game and requires the server's state.
|
||||
*
|
||||
* @param player The player to hydrate.
|
||||
*/
|
||||
hydrate(player: Player): void;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ local flatten = require(script.flatten)
|
|||
local patch = require(script.patch)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
local validate = require(script.validate)
|
||||
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
|
|
@ -14,5 +15,7 @@ return {
|
|||
client = client,
|
||||
server = server,
|
||||
flatten = flatten,
|
||||
validate = validate,
|
||||
patch = patch,
|
||||
isNone = patch.isNone,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ local Charm = require(script.Parent.Parent.Charm)
|
|||
local interval = require(script.Parent.interval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type AtomMap = types.AtomMap
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
--[=[
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ registry = "https://github.com/UpliftGames/wally-index"
|
|||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.10"
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/charm",
|
||||
"version": "0.10.0",
|
||||
"description": "An atomic state management library for Roblox",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
}
|
||||
}
|
||||
165
packages/charm/src/index.d.ts
vendored
165
packages/charm/src/index.d.ts
vendored
|
|
@ -1,165 +0,0 @@
|
|||
export = Charm;
|
||||
export as namespace Charm;
|
||||
|
||||
type Dispose = () => void;
|
||||
|
||||
declare namespace Charm {
|
||||
/**
|
||||
* A primitive state container that can be read from or written to. When
|
||||
* the state changes, all subscribers are notified.
|
||||
*
|
||||
* @param state The next state or a function that produces the next state.
|
||||
* @returns The current state.
|
||||
*/
|
||||
interface Atom<State> {
|
||||
(): State;
|
||||
(nextState: State | ((prevState: State) => State)): State;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that depends on one or more atoms and produces a value. Can be
|
||||
* used to derive state from atoms.
|
||||
*
|
||||
* @returns The current state.
|
||||
*/
|
||||
type Selector<State> = () => State;
|
||||
|
||||
/**
|
||||
* Infers the type of the state produced by the given function.
|
||||
*/
|
||||
type StateOf<T> = T extends () => infer State ? State : never;
|
||||
|
||||
interface AtomOptions<State> {
|
||||
/**
|
||||
* A function that determines whether the state has changed. By default,
|
||||
* a strict equality check (`===`) is used.
|
||||
*/
|
||||
equals?: (prev: State, next: State) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new atom with the given state.
|
||||
*
|
||||
* @param state The initial state.
|
||||
* @param options Optional configuration.
|
||||
* @returns A new atom.
|
||||
*/
|
||||
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
|
||||
|
||||
// Overload for no arguments
|
||||
function atom<State>(state?: State, options?: AtomOptions<State>): Atom<State | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a read-only atom that derives its state from one or more atoms.
|
||||
* Used to avoid unnecessary recomputations if multiple listeners depend on
|
||||
* the same atoms.
|
||||
*
|
||||
* @param callback The function that produces the state.
|
||||
* @returns A new read-only atom.
|
||||
*/
|
||||
function computed<State>(callback: (prevResult?: State) => State): () => State;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state only after a change
|
||||
* occurs.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param listener The function to call when the state changes.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function subscribe<State>(callback: () => State, listener: (state: State, prevState: State) => void): Dispose;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the given atom or selector. The callback is
|
||||
* called with the current state and the previous state immediately after
|
||||
* subscribing, and again when a change occurs.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param listener The function to call when the state changes.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function listen<State>(
|
||||
callback: () => State,
|
||||
listener: (state: State, prevState: State, dispose: Dispose) => void,
|
||||
): Dispose;
|
||||
|
||||
/**
|
||||
* Runs the given callback immediately and whenever any atom it depends on
|
||||
* changes. Returns a cleanup function that unsubscribes the callback.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
* @returns A function that unsubscribes the callback.
|
||||
*/
|
||||
function effect(callback: (dispose: Dispose) => Dispose | void): Dispose;
|
||||
|
||||
/**
|
||||
* Runs the given function and schedules listeners to be notified only once
|
||||
* after the function has completed. Useful for batching multiple changes.
|
||||
*
|
||||
* @param callback The function to run.
|
||||
*/
|
||||
function batched(callback: () => void): void;
|
||||
|
||||
/**
|
||||
* Returns the result of the function without subscribing to changes.
|
||||
*
|
||||
* @param callback The atom or selector to get the state of.
|
||||
* @param args Arguments to pass to the function.
|
||||
* @returns The current state.
|
||||
*/
|
||||
function untracked<State, Args extends unknown[]>(callback: (...args: Args) => State, ...args: Args): State;
|
||||
|
||||
/**
|
||||
* Creates an instance of `factory` for each item in the atom's state, and
|
||||
* cleans up the instance when the item is removed. Returns a cleanup function
|
||||
* that unsubscribes all instances.
|
||||
*
|
||||
* @param callback The atom or selector to observe.
|
||||
* @param factory The function that tracks the lifecycle of each item.
|
||||
* @returns A function that unsubscribes all instances.
|
||||
*/
|
||||
function observe<Item>(
|
||||
callback: () => readonly Item[],
|
||||
factory: (item: Item, index: number) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
function observe<Key, Item>(
|
||||
callback: () => Map<Key, Item> | ReadonlyMap<Key, Item>,
|
||||
factory: (item: Item, key: Key) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
function observe<Key extends string | number | symbol, Item>(
|
||||
callback: () => { readonly [P in Key]: Item },
|
||||
factory: (item: Item, key: Key) => Dispose | void,
|
||||
): Dispose;
|
||||
|
||||
/**
|
||||
* Maps each entry in the atom's state to a new key-value pair. If the `mapper`
|
||||
* function returns `undefined`, the entry is omitted from the resulting map.
|
||||
* When the atom changes, the `mapper` only runs for the changed entries.
|
||||
*
|
||||
* @param callback The atom or selector to map.
|
||||
* @param mapper The function that maps each entry.
|
||||
* @returns A new atom with the mapped state.
|
||||
*/
|
||||
function mapped<V0, K1, V1>(
|
||||
callback: () => readonly V0[],
|
||||
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
|
||||
function mapped<V0, V1>(
|
||||
callback: () => readonly V0[],
|
||||
mapper: (value: V0, index: number) => V1,
|
||||
): () => readonly V1[];
|
||||
|
||||
function mapped<K0, V0, K1 = K0, V1 = V0>(
|
||||
callback: () => Map<K0, V0> | ReadonlyMap<K0, V0>,
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
|
||||
function mapped<K0 extends string | number | symbol, V0, K1 = K0, V1 = V0>(
|
||||
callback: () => { readonly [P in K0]: V0 },
|
||||
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
|
||||
): () => ReadonlyMap<K1, V1>;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ local effectScope = require("./effectScope")
|
|||
local listen = require("./listen")
|
||||
local mapped = require("./mapped")
|
||||
local observe = require("./observe")
|
||||
local signals = require("./signals")
|
||||
local subscribe = require("./subscribe")
|
||||
local types = require("./types")
|
||||
local untracked = require("./untracked")
|
||||
|
|
@ -25,4 +26,5 @@ return {
|
|||
observe = observe,
|
||||
subscribe = subscribe,
|
||||
untracked = untracked,
|
||||
signals = signals,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ local useState = React.useState
|
|||
@param dependencies An array of values that the subscription depends on.
|
||||
@return The current state.
|
||||
]=]
|
||||
local function useAtom<State>(callback: Charm.Selector<State>, dependencies: { any }?): State
|
||||
local function useAtom<State>(callback: () -> State, dependencies: { any }?): State
|
||||
local state, setState = useState(callback)
|
||||
|
||||
useEffect(function()
|
||||
|
|
@ -5,5 +5,5 @@ registry = "https://github.com/UpliftGames/wally-index"
|
|||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.10"
|
||||
React = "jsdotlua/react@^17"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/react-charm",
|
||||
"version": "0.3.0",
|
||||
"description": "React bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*",
|
||||
"@rbxts/react": "*"
|
||||
}
|
||||
}
|
||||
13
packages/react/src/index.d.ts
vendored
13
packages/react/src/index.d.ts
vendored
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* A hook that subscribes to changes in the given atom or selector. The
|
||||
* component is re-rendered whenever the state changes.
|
||||
*
|
||||
* If the `dependencies` array is provided, the subscription to the atom or
|
||||
* selector is re-created whenever the dependencies change. Otherwise, the
|
||||
* subscription is created once when the component is mounted.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @param dependencies An array of values that the subscription depends on.
|
||||
* @returns The current state.
|
||||
*/
|
||||
export function useAtom<State>(callback: () => State, dependencies?: unknown[]): State;
|
||||
|
|
@ -5,5 +5,5 @@ registry = "https://github.com/UpliftGames/wally-index"
|
|||
realm = "shared"
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.10"
|
||||
Vide = "centau/vide@^0.3"
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@rbxts/vide-charm",
|
||||
"version": "0.3.0",
|
||||
"description": "Vide bindings for Charm",
|
||||
"repository": "littensy/charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*",
|
||||
"@rbxts/vide": "*"
|
||||
}
|
||||
}
|
||||
7
packages/vide/src/index.d.ts
vendored
7
packages/vide/src/index.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Subscribes to the state of an atom and returns a Vide source.
|
||||
*
|
||||
* @param callback The atom or selector to subscribe to.
|
||||
* @returns The reactive source.
|
||||
*/
|
||||
export function useAtom<T>(callback: () => T): () => T;
|
||||
Loading…
Add table
Add a link
Reference in a new issue