fix storybook lint/built
This commit is contained in:
parent
3b2ece2fcf
commit
8a74f79378
18 changed files with 157 additions and 110 deletions
|
|
@ -3,10 +3,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { HttpResponse } from 'msw';
|
||||
import type { DefaultBodyType, HttpResponseResolver, JsonBodyType, PathParams } from 'msw';
|
||||
import seedrandom from 'seedrandom';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
|
||||
const rng = seedrandom(seed);
|
||||
|
|
@ -30,7 +30,9 @@ export function getChartResolver(fields: string[], option?: { accumulate?: boole
|
|||
action(`GET ${request.url}`)();
|
||||
const limitParam = new URL(request.url).searchParams.get('limit');
|
||||
const limit = limitParam ? parseInt(limitParam) : 30;
|
||||
const res = {};
|
||||
// What the *fuck* is the type of this object???
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res: any = {};
|
||||
for (const field of fields) {
|
||||
const layers = field.split('.');
|
||||
let current = res;
|
||||
|
|
|
|||
|
|
@ -220,6 +220,11 @@ export function federationInstance(): entities.FederationInstance {
|
|||
themeColor: '',
|
||||
infoUpdatedAt: '',
|
||||
latestRequestReceivedAt: '',
|
||||
isMediaSilenced: false,
|
||||
rejectReports: false,
|
||||
rejectQuotes: false,
|
||||
isBubbled: false,
|
||||
mandatoryCW: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +245,13 @@ export function note(id = 'somenoteid'): entities.Note {
|
|||
reactionCount: 0,
|
||||
renoteCount: 0,
|
||||
repliesCount: 0,
|
||||
threadId: '',
|
||||
userHost: null,
|
||||
isMutingThread: false,
|
||||
isMutingNote: false,
|
||||
isFavorited: false,
|
||||
isRenoted: false,
|
||||
bypassSilence: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -254,10 +266,27 @@ export function userLite(id = 'someuserid', username = 'miskist', host: entities
|
|||
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||
avatarDecorations: [],
|
||||
emojis: {},
|
||||
createdAt: '',
|
||||
updatedAt: null,
|
||||
lastFetchedAt: null,
|
||||
approved: false,
|
||||
description: null,
|
||||
isAdmin: false,
|
||||
isModerator: false,
|
||||
isSystem: false,
|
||||
noindex: false,
|
||||
enableRss: false,
|
||||
mandatoryCW: null,
|
||||
isSilenced: false,
|
||||
bypassSilence: false,
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
notesCount: 0,
|
||||
attributionDomains: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
|
||||
function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
|
||||
return {
|
||||
...userLite(id, username, host, name),
|
||||
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
|
||||
|
|
@ -312,9 +341,17 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host: enti
|
|||
alsoKnownAs: null,
|
||||
notify: 'none',
|
||||
memo: null,
|
||||
backgroundUrl: null,
|
||||
backgroundId: null,
|
||||
backgroundBlurhash: null,
|
||||
listenbrainz: null,
|
||||
canChat: true,
|
||||
chatScope: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export default userDetailed;
|
||||
|
||||
export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
|
||||
const date = new Date();
|
||||
const createdAt = new Date();
|
||||
|
|
@ -382,9 +419,10 @@ export function role(params: {
|
|||
condFormula: {
|
||||
id: '',
|
||||
type: 'or',
|
||||
values: []
|
||||
values: [],
|
||||
},
|
||||
policies: {},
|
||||
preserveAssignmentOnMoveAccount: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface ImportDeclaration extends estree.ImportDeclaration {
|
|||
|
||||
const generator = {
|
||||
...GENERATOR,
|
||||
ImportDeclaration(node: ImportDeclaration, state: State) {
|
||||
ImportDeclaration(node: ImportDeclaration, state: State): void {
|
||||
state.write('import ');
|
||||
if (node.kind === 'type') state.write('type ');
|
||||
const { specifiers } = node;
|
||||
|
|
@ -63,7 +63,7 @@ const generator = {
|
|||
|
||||
state.write(';');
|
||||
},
|
||||
SatisfiesExpression(node: SatisfiesExpression, state: State) {
|
||||
SatisfiesExpression(node: SatisfiesExpression, state: State): void {
|
||||
switch (node.expression.type) {
|
||||
case 'ArrowFunctionExpression': {
|
||||
state.write('(');
|
||||
|
|
@ -72,7 +72,7 @@ const generator = {
|
|||
break;
|
||||
}
|
||||
default: {
|
||||
// @ts-expect-error ???
|
||||
// @ts-expect-error Produces "Expression produces a union type that is too complex to represent" for some reason
|
||||
this[node.expression.type](node.expression, state);
|
||||
break;
|
||||
}
|
||||
|
|
@ -94,7 +94,6 @@ type SplitCamel<
|
|||
: SplitCamel<XR, `${YC}${XH}`, YN>
|
||||
: YN;
|
||||
|
||||
// @ts-expect-error ???
|
||||
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
|
||||
? [XH, ...SplitKebab<XR>]
|
||||
: [T];
|
||||
|
|
@ -110,7 +109,6 @@ type ToKebab<T extends readonly string[]> = T extends readonly [
|
|||
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
|
||||
: '';
|
||||
|
||||
// @ts-expect-error ???
|
||||
type ToPascal<T extends readonly string[]> = T extends readonly [
|
||||
infer XH extends string,
|
||||
...infer XR extends readonly string[]
|
||||
|
|
@ -126,6 +124,7 @@ function h<T extends estree.Node>(
|
|||
return Object.assign(props || {}, { type }) as T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace h.JSX {
|
||||
type Element = estree.Node;
|
||||
type IntrinsicElements = {
|
||||
|
|
|
|||
3
packages/frontend/.storybook/locale.d.ts
vendored
Normal file
3
packages/frontend/.storybook/locale.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import locales from '../../../locales/index.js';
|
||||
|
||||
export default locales['ja-JP'] as const;
|
||||
|
|
@ -7,7 +7,7 @@ import { createRequire } from 'node:module';
|
|||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||
import { type Plugin, mergeConfig } from 'vite';
|
||||
import { mergeConfig } from 'vite';
|
||||
import turbosnap from 'vite-plugin-turbosnap';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
@ -29,19 +29,20 @@ const config = {
|
|||
options: {},
|
||||
},
|
||||
docs: {
|
||||
// @ts-expect-error This seems to be wrong, but I can't find what the alternative might be.
|
||||
autodocs: 'tag',
|
||||
},
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const replacePluginForIsChromatic = config.plugins?.findIndex((plugin: Plugin) => plugin && plugin.name === 'replace') ?? -1;
|
||||
const replacePluginForIsChromatic = config.plugins?.findIndex(plugin => plugin && 'name' in plugin && plugin.name === 'replace') ?? -1;
|
||||
if (~replacePluginForIsChromatic) {
|
||||
config.plugins?.splice(replacePluginForIsChromatic, 1);
|
||||
}
|
||||
|
||||
//pluginsからcreateSearchIndexを削除、複数あるかもしれないので全て削除
|
||||
config.plugins = config.plugins?.filter((plugin: Plugin) => plugin && plugin.name !== 'createSearchIndex') ?? [];
|
||||
config.plugins = config.plugins?.filter(plugin => plugin && 'name' in plugin && plugin.name !== 'createSearchIndex') ?? [];
|
||||
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { type SharedOptions, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import type { SharedOptions } from 'msw';
|
||||
|
||||
export const onUnhandledRequest = ((req, print) => {
|
||||
const url = new URL(req.url);
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { writeFile } from 'node:fs/promises';
|
|||
import locales from '../../../locales/index.js';
|
||||
|
||||
await writeFile(
|
||||
new URL('locale.ts', import.meta.url),
|
||||
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
|
||||
new URL('locale.js', import.meta.url),
|
||||
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)};`,
|
||||
'utf8',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@ const keys = [
|
|||
|
||||
await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
|
||||
writeFile(
|
||||
new URL('./themes.ts', import.meta.url),
|
||||
new URL('./themes.js', import.meta.url),
|
||||
`export default ${JSON.stringify(
|
||||
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
|
||||
undefined,
|
||||
2,
|
||||
)} as const;`,
|
||||
'utf8'
|
||||
)};`,
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@
|
|||
|
||||
import { FORCE_RE_RENDER, FORCE_REMOUNT } from '@storybook/core-events';
|
||||
import { addons } from '@storybook/preview-api';
|
||||
import { type Preview, setup } from '@storybook/vue3';
|
||||
import { setup } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { userDetailed } from './fakes.js';
|
||||
import userDetailed from './fakes.js';
|
||||
import locale from './locale.js';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks.js';
|
||||
import themes from './themes.js';
|
||||
import type { Preview } from '@storybook/vue3';
|
||||
import type * as MisskeyOS from '../src/os.js';
|
||||
import '../src/style.scss';
|
||||
|
||||
const appInitialized = Symbol();
|
||||
|
|
@ -19,13 +21,13 @@ const appInitialized = Symbol();
|
|||
let lastStory: string | null = null;
|
||||
let moduleInitialized = false;
|
||||
let unobserve = () => {};
|
||||
let misskeyOS = null;
|
||||
let misskeyOS: typeof MisskeyOS | null = null;
|
||||
|
||||
function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
|
||||
unobserve();
|
||||
const theme = themes[window.document.documentElement.dataset.misskeyTheme];
|
||||
const theme = themes[window.document.documentElement.dataset.misskeyTheme as string];
|
||||
if (theme) {
|
||||
applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]);
|
||||
applyTheme(themes[window.document.documentElement.dataset.misskeyTheme as string]);
|
||||
} else {
|
||||
applyTheme(themes['l-light']);
|
||||
}
|
||||
|
|
@ -33,9 +35,9 @@ function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
|
|||
for (const entry of entries) {
|
||||
if (entry.attributeName === 'data-misskey-theme') {
|
||||
const target = entry.target as HTMLElement;
|
||||
const theme = themes[target.dataset.misskeyTheme];
|
||||
const theme = themes[target.dataset.misskeyTheme as string];
|
||||
if (theme) {
|
||||
applyTheme(themes[target.dataset.misskeyTheme]);
|
||||
applyTheme(themes[target.dataset.misskeyTheme as string]);
|
||||
} else {
|
||||
target.removeAttribute('style');
|
||||
}
|
||||
|
|
@ -97,7 +99,7 @@ const preview = {
|
|||
} else {
|
||||
lastStory = context.id;
|
||||
const channel = addons.getChannel();
|
||||
const resetIndexedDBPromise = globalThis.indexedDB?.databases
|
||||
const resetIndexedDBPromise = (globalThis.indexedDB as IDBFactory | undefined)?.databases
|
||||
? indexedDB.databases().then((r) => {
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
indexedDB.deleteDatabase(r[i].name!);
|
||||
|
|
@ -105,7 +107,6 @@ const preview = {
|
|||
}).catch(() => {})
|
||||
: Promise.resolve();
|
||||
const resetDefaultStorePromise = import('../src/store').then(({ store }) => {
|
||||
// @ts-expect-error ???
|
||||
store.init();
|
||||
}).catch(() => {});
|
||||
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
|
||||
|
|
@ -122,12 +123,12 @@ const preview = {
|
|||
}
|
||||
return story;
|
||||
},
|
||||
(Story, context) => {
|
||||
(_, context) => {
|
||||
return {
|
||||
setup() {
|
||||
return {
|
||||
context,
|
||||
popups: misskeyOS.popups,
|
||||
popups: misskeyOS?.popups,
|
||||
};
|
||||
},
|
||||
template:
|
||||
|
|
|
|||
3
packages/frontend/.storybook/themes.d.ts
vendored
Normal file
3
packages/frontend/.storybook/themes.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { Theme } from '../src/theme.js';
|
||||
|
||||
export default {} as Record<string, Theme>;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../tsconfig.vue.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h"
|
||||
},
|
||||
"files": [
|
||||
"./changes.ts",
|
||||
"./generate.tsx",
|
||||
"./preload-locale.ts",
|
||||
"./preload-theme.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"files": [],
|
||||
// WebStorm only reads one tsconfig per directory, so this tricks it into loading both.
|
||||
"references": [
|
||||
{ "path": "./tsconfig.gen.json" },
|
||||
{ "path": "./tsconfig.vue.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../tsconfig.vue.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"./changes.ts",
|
||||
"./generate.tsx",
|
||||
"./preload-locale.ts",
|
||||
"./preload-theme.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -15,9 +15,14 @@ export default [
|
|||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['{src,test,js,@types}/**/*.{ts,vue}'],
|
||||
files: ['{src,test,js,@types}/**/*.{ts,vue}', '.storybook/**/*.ts', '.storybook/**/*.tsx', '.storybook/**/*.js', '.storybook/**/*.jsx'],
|
||||
ignores: [
|
||||
'*.*',
|
||||
'.storybook/changes.ts',
|
||||
'.storybook/main.ts',
|
||||
'.storybook/generate.tsx',
|
||||
'.storybook/preload-locale.ts',
|
||||
'.storybook/preload-theme.ts',
|
||||
],
|
||||
plugins: { sharkey: { rules: { locale: localeRule } } },
|
||||
languageOptions: {
|
||||
|
|
@ -46,7 +51,7 @@ export default [
|
|||
parserOptions: {
|
||||
extraFileExtensions: ['.vue'],
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.vue.json'],
|
||||
project: ['tsconfig.vue.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
|
|
@ -173,37 +178,31 @@ export default [
|
|||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['.storybook/**/*.ts', '.storybook/**/*.tsx', '.storybook/**/*.js', '.storybook/**/*.jsx'],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
".storybook/changes.ts",
|
||||
".storybook/generate.tsx",
|
||||
".storybook/preload-locale.ts",
|
||||
".storybook/preload-theme.ts"
|
||||
'.storybook/changes.ts',
|
||||
'.storybook/main.ts',
|
||||
'.storybook/generate.tsx',
|
||||
'.storybook/preload-locale.ts',
|
||||
'.storybook/preload-theme.ts',
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['.storybook/tsconfig.gen.json'],
|
||||
project: ['tsconfig.storybook.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['.storybook/**/*.ts', '.storybook/**/*.tsx', '.storybook/**/*.js', '.storybook/**/*.jsx'],
|
||||
ignores: [
|
||||
".storybook/changes.ts",
|
||||
".storybook/generate.tsx",
|
||||
".storybook/preload-locale.ts",
|
||||
".storybook/preload-theme.ts"
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['.storybook/tsconfig.vue.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -233,15 +232,15 @@ export default [
|
|||
},
|
||||
{
|
||||
ignores: [
|
||||
'**/lib/',
|
||||
'**/temp/',
|
||||
'**/built/',
|
||||
'**/coverage/',
|
||||
'**/node_modules/',
|
||||
'**/libopenmpt/',
|
||||
'**/storybook-static/',
|
||||
'**/lib',
|
||||
'**/temp',
|
||||
'**/built',
|
||||
'**/coverage',
|
||||
'**/node_modules',
|
||||
'**/libopenmpt',
|
||||
'**/storybook-static',
|
||||
'vue-shims.d.ts',
|
||||
'assets/'
|
||||
'assets',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
// WebStorm only reads one tsconfig per directory, so this tricks it into loading both.
|
||||
"references": [
|
||||
{ "path": "./tsconfig.scripts.json" },
|
||||
{ "path": "./tsconfig.vue.json" }
|
||||
{ "path": "./tsconfig.vue.json" },
|
||||
{ "path": "./tsconfig.vue.storybook.json" },
|
||||
{ "path": "./tsconfig.storybook.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
15
packages/frontend/tsconfig.storybook.json
Normal file
15
packages/frontend/tsconfig.storybook.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../shared/tsconfig.node.jsonc",
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h"
|
||||
},
|
||||
"files": [
|
||||
".storybook/changes.ts",
|
||||
".storybook/main.ts",
|
||||
".storybook/generate.tsx",
|
||||
".storybook/preload-locale.ts",
|
||||
".storybook/preload-theme.ts"
|
||||
]
|
||||
}
|
||||
31
packages/frontend/tsconfig.vue.storybook.json
Normal file
31
packages/frontend/tsconfig.vue.storybook.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.vue.json",
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"./lib/**/*.ts",
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.vue",
|
||||
"./test/**/*.ts",
|
||||
"./test/**/*.vue",
|
||||
"./@types/**/*.ts",
|
||||
"./vue-shims.d.ts",
|
||||
".storybook/**/*.js",
|
||||
".storybook/**/*.jsx",
|
||||
".storybook/**/*.ts",
|
||||
".storybook/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"eslint.config.js",
|
||||
"vite.*",
|
||||
".storybook/changes.ts",
|
||||
".storybook/main.ts",
|
||||
".storybook/generate.tsx",
|
||||
".storybook/preload-locale.ts",
|
||||
".storybook/preload-theme.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue