mirror of https://github.com/nocodb/nocodb
Braks
2 years ago
committed by
GitHub
90 changed files with 680 additions and 503 deletions
@ -0,0 +1,20 @@ |
|||||||
|
export * from './useApi' |
||||||
|
export * from './useGlobal' |
||||||
|
export * from './useUIPermission' |
||||||
|
export * from './useAttachment' |
||||||
|
export * from './useBelongsTo' |
||||||
|
export * from './useColors' |
||||||
|
export * from './useColumn' |
||||||
|
export * from './useGridViewColumnWidth' |
||||||
|
export * from './useHasMany' |
||||||
|
export * from './useManyToMany' |
||||||
|
export * from './useMetas' |
||||||
|
export * from './useProject' |
||||||
|
export * from './useTableCreate' |
||||||
|
export * from './useTabs' |
||||||
|
export * from './useViewColumns' |
||||||
|
export * from './useViewData' |
||||||
|
export * from './useViewFilters' |
||||||
|
export * from './useViews' |
||||||
|
export * from './useViewSorts' |
||||||
|
export * from './useVirtualCell' |
@ -0,0 +1,26 @@ |
|||||||
|
import type { Api } from 'nocodb-sdk' |
||||||
|
import type { Ref } from 'vue' |
||||||
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' |
||||||
|
import type { EventHook, MaybeRef } from '@vueuse/core' |
||||||
|
|
||||||
|
export interface UseApiReturn<D = any, R = any> { |
||||||
|
api: Api<any> |
||||||
|
isLoading: Ref<boolean> |
||||||
|
error: Ref<AxiosError<D, R> | null> |
||||||
|
response: Ref<AxiosResponse<D, R> | null> |
||||||
|
onError: EventHook<AxiosError<D, R>>['on'] |
||||||
|
onResponse: EventHook<AxiosResponse<D, R>>['on'] |
||||||
|
} |
||||||
|
|
||||||
|
/** {@link Api} options */ |
||||||
|
export interface CreateApiOptions { |
||||||
|
baseURL?: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface UseApiProps<D = any> { |
||||||
|
/** additional axios config for requests */ |
||||||
|
axiosConfig?: MaybeRef<AxiosRequestConfig<D>> |
||||||
|
/** {@link Api} options */ |
||||||
|
apiOptions?: CreateApiOptions |
||||||
|
useGlobalInstance?: boolean |
||||||
|
} |
@ -0,0 +1,54 @@ |
|||||||
|
import { notification } from 'ant-design-vue' |
||||||
|
import type { Actions, State } from './types' |
||||||
|
import { useNuxtApp } from '#imports' |
||||||
|
|
||||||
|
export function useGlobalActions(state: State): Actions { |
||||||
|
// todo replace with just `new Api()`? Would solve recursion issues
|
||||||
|
/** we have to use the globally injected api instance, otherwise we run into recursion as `useApi` calls `useGlobal` */ |
||||||
|
const { $api } = useNuxtApp() |
||||||
|
|
||||||
|
/** Sign out by deleting the token from localStorage */ |
||||||
|
const signOut: Actions['signOut'] = () => { |
||||||
|
state.token.value = null |
||||||
|
state.user.value = null |
||||||
|
} |
||||||
|
|
||||||
|
/** Sign in by setting the token in localStorage */ |
||||||
|
const signIn: Actions['signIn'] = async (newToken) => { |
||||||
|
state.token.value = newToken |
||||||
|
|
||||||
|
if (state.jwtPayload.value) { |
||||||
|
state.user.value = { |
||||||
|
id: state.jwtPayload.value.id, |
||||||
|
email: state.jwtPayload.value.email, |
||||||
|
firstname: state.jwtPayload.value.firstname, |
||||||
|
lastname: state.jwtPayload.value.lastname, |
||||||
|
roles: state.jwtPayload.value.roles, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** manually try to refresh token */ |
||||||
|
const refreshToken = async () => { |
||||||
|
$api.instance |
||||||
|
.post('/auth/refresh-token', null, { |
||||||
|
withCredentials: true, |
||||||
|
}) |
||||||
|
.then((response) => { |
||||||
|
if (response.data?.token) { |
||||||
|
signIn(response.data.token) |
||||||
|
} |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
notification.error({ |
||||||
|
// todo: add translation
|
||||||
|
message: err.message || 'You have been signed out.', |
||||||
|
}) |
||||||
|
console.error(err) |
||||||
|
|
||||||
|
signOut() |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return { signIn, signOut, refreshToken } |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import type { Getters, State } from './types' |
||||||
|
import { computed } from '#imports' |
||||||
|
|
||||||
|
export function useGlobalGetters(state: State): Getters { |
||||||
|
/** Verify that a user is signed in by checking if token exists and is not expired */ |
||||||
|
const signedIn: Getters['signedIn'] = computed( |
||||||
|
() => |
||||||
|
!!( |
||||||
|
!!state.token && |
||||||
|
state.token.value !== '' && |
||||||
|
state.jwtPayload.value && |
||||||
|
state.jwtPayload.value.exp && |
||||||
|
state.jwtPayload.value.exp > state.timestamp.value / 1000 |
||||||
|
), |
||||||
|
) |
||||||
|
|
||||||
|
/** global loading state */ |
||||||
|
let loading = $ref(false) |
||||||
|
const isLoading = computed({ |
||||||
|
get: () => state.runningRequests.count.value > 0 || loading, |
||||||
|
set: (_loading) => (loading = _loading), |
||||||
|
}) |
||||||
|
|
||||||
|
return { signedIn, isLoading } |
||||||
|
} |
@ -0,0 +1,71 @@ |
|||||||
|
import { useGlobalState } from './state' |
||||||
|
import { useGlobalActions } from './actions' |
||||||
|
import type { UseGlobalReturn } from './types' |
||||||
|
import { useGlobalGetters } from './getters' |
||||||
|
import { useNuxtApp, watch } from '#imports' |
||||||
|
|
||||||
|
/** |
||||||
|
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`). |
||||||
|
* You can still call `useGlobal` to receive the `$state` object and access the global state. |
||||||
|
* If it's not available yet, a new global state object is created and injected into the nuxt app. |
||||||
|
* |
||||||
|
* Part of the state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab. |
||||||
|
* Check the {@link StoredState StoredState} type for more information. |
||||||
|
* |
||||||
|
* @example |
||||||
|
* ```js
|
||||||
|
* import { useNuxtApp } from '#app' |
||||||
|
* |
||||||
|
* const { $state } = useNuxtApp() |
||||||
|
* |
||||||
|
* const token = $state.token.value |
||||||
|
* const user = $state.user.value |
||||||
|
* ``` |
||||||
|
* |
||||||
|
* @example |
||||||
|
* ```js
|
||||||
|
* import { useGlobal } from '#imports' |
||||||
|
* |
||||||
|
* const globalState = useGlobal() |
||||||
|
* |
||||||
|
* cont token = globalState.token.value |
||||||
|
* const user = globalState.user.value |
||||||
|
* |
||||||
|
* console.log(state.isLoading.value) // isLoading = true if any api request is still running
|
||||||
|
* ``` |
||||||
|
*/ |
||||||
|
export const useGlobal = (): UseGlobalReturn => { |
||||||
|
const { $state, provide } = useNuxtApp() |
||||||
|
|
||||||
|
/** If state already exists, return it */ |
||||||
|
if (typeof $state !== 'undefined') return $state |
||||||
|
|
||||||
|
const state = useGlobalState() |
||||||
|
|
||||||
|
const getters = useGlobalGetters(state) |
||||||
|
|
||||||
|
const actions = useGlobalActions(state) |
||||||
|
|
||||||
|
/** try to refresh token before expiry (5 min before expiry) */ |
||||||
|
watch( |
||||||
|
() => |
||||||
|
!!( |
||||||
|
state.jwtPayload.value && |
||||||
|
state.jwtPayload.value.exp && |
||||||
|
state.jwtPayload.value.exp - 5 * 60 < state.timestamp.value / 1000 |
||||||
|
), |
||||||
|
async (expiring) => { |
||||||
|
if (getters.signedIn.value && state.jwtPayload.value && expiring) { |
||||||
|
await actions.refreshToken() |
||||||
|
} |
||||||
|
}, |
||||||
|
{ immediate: true }, |
||||||
|
) |
||||||
|
|
||||||
|
const globalState = { ...state, ...getters, ...actions } as UseGlobalReturn |
||||||
|
|
||||||
|
/** provide a fresh state instance into nuxt app */ |
||||||
|
provide('state', globalState) |
||||||
|
|
||||||
|
return globalState |
||||||
|
} |
@ -0,0 +1,95 @@ |
|||||||
|
import { usePreferredLanguages, useStorage } from '@vueuse/core' |
||||||
|
import { useJwt } from '@vueuse/integrations/useJwt' |
||||||
|
import type { JwtPayload } from 'jwt-decode' |
||||||
|
import type { State, StoredState } from './types' |
||||||
|
import { computed, ref, toRefs, useCounter, useNuxtApp, useTimestamp } from '#imports' |
||||||
|
import type { User } from '~/lib' |
||||||
|
|
||||||
|
export function useGlobalState(storageKey = 'nocodb-gui-v2'): State { |
||||||
|
/** get the preferred languages of a user, according to browser settings */ |
||||||
|
const preferredLanguages = $(usePreferredLanguages()) |
||||||
|
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */ |
||||||
|
// const prefersDarkMode = $(usePreferredDark())
|
||||||
|
const prefersDarkMode = false |
||||||
|
|
||||||
|
/** reactive timestamp to check token expiry against */ |
||||||
|
const timestamp = useTimestamp({ immediate: true, interval: 100 }) |
||||||
|
|
||||||
|
const { |
||||||
|
vueApp: { i18n }, |
||||||
|
} = useNuxtApp() |
||||||
|
|
||||||
|
/** |
||||||
|
* Set initial language based on browser settings. |
||||||
|
* If the user has not set a preferred language, we fallback to 'en'. |
||||||
|
* If the user has set a preferred language, we try to find a matching locale in the available locales. |
||||||
|
*/ |
||||||
|
const preferredLanguage = preferredLanguages.reduce((locale, language) => { |
||||||
|
/** split language to language and code, e.g. en-GB -> [en, GB] */ |
||||||
|
const [lang, code] = language.split(/[_-]/) |
||||||
|
|
||||||
|
/** find all locales that match the language */ |
||||||
|
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang)) |
||||||
|
|
||||||
|
/** If we can match more than one locale, we check if the code of the language matches as well */ |
||||||
|
if (availableLocales.length > 1) { |
||||||
|
availableLocales = availableLocales.filter((locale) => locale.endsWith(code)) |
||||||
|
} |
||||||
|
|
||||||
|
/** if there are still multiple locales, pick the first one */ |
||||||
|
const availableLocale = availableLocales[0] |
||||||
|
|
||||||
|
/** if we found a matching locale, return it */ |
||||||
|
if (availableLocale) locale = availableLocale |
||||||
|
|
||||||
|
return locale |
||||||
|
}, 'en' /** fallback locale */) |
||||||
|
|
||||||
|
/** State */ |
||||||
|
const initialState: StoredState = { |
||||||
|
token: null, |
||||||
|
user: null, |
||||||
|
lang: preferredLanguage, |
||||||
|
darkMode: prefersDarkMode, |
||||||
|
feedbackForm: { |
||||||
|
url: 'https://docs.google.com/forms/d/e/1FAIpQLSeTlAfZjszgr53lArz3NvUEnJGOT9JtG9NAU5d0oQwunDS2Pw/viewform?embedded=true', |
||||||
|
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(), |
||||||
|
isHidden: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
/** saves a reactive state, any change to these values will write/delete to localStorage */ |
||||||
|
const storage = useStorage<StoredState>(storageKey, initialState) |
||||||
|
|
||||||
|
/** force turn off of dark mode, regardless of previously stored settings */ |
||||||
|
storage.value.darkMode = false |
||||||
|
|
||||||
|
/** current token ref, used by `useJwt` to reactively parse our token payload */ |
||||||
|
const token = computed({ |
||||||
|
get: () => storage.value.token || '', |
||||||
|
set: (val) => (storage.value.token = val), |
||||||
|
}) |
||||||
|
|
||||||
|
/** reactive token payload */ |
||||||
|
const { payload } = useJwt<JwtPayload & User>(token) |
||||||
|
|
||||||
|
/** is sidebar open */ |
||||||
|
const sidebarOpen = ref(false) |
||||||
|
|
||||||
|
/** currently running requests */ |
||||||
|
const runningRequests = useCounter() |
||||||
|
|
||||||
|
/** global error */ |
||||||
|
const error = ref() |
||||||
|
|
||||||
|
return { |
||||||
|
...toRefs(storage.value), |
||||||
|
storage, |
||||||
|
token, |
||||||
|
jwtPayload: payload, |
||||||
|
sidebarOpen, |
||||||
|
timestamp, |
||||||
|
runningRequests, |
||||||
|
error, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,45 @@ |
|||||||
|
import type { ComputedRef, Ref, ToRefs } from 'vue' |
||||||
|
import type { WritableComputedRef } from '@vue/reactivity' |
||||||
|
import type { JwtPayload } from 'jwt-decode' |
||||||
|
import type { User } from '~/lib' |
||||||
|
import type { useCounter } from '#imports' |
||||||
|
|
||||||
|
export interface FeedbackForm { |
||||||
|
url: string |
||||||
|
createdAt: string |
||||||
|
isHidden: boolean |
||||||
|
lastFormPollDate?: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface StoredState { |
||||||
|
token: string | null |
||||||
|
user: User | null |
||||||
|
lang: string |
||||||
|
darkMode: boolean |
||||||
|
feedbackForm: FeedbackForm |
||||||
|
} |
||||||
|
|
||||||
|
export type State = ToRefs<Omit<StoredState, 'token'>> & { |
||||||
|
storage: Ref<StoredState> |
||||||
|
token: WritableComputedRef<StoredState['token']> |
||||||
|
jwtPayload: ComputedRef<(JwtPayload & User) | null> |
||||||
|
sidebarOpen: Ref<boolean> |
||||||
|
timestamp: Ref<number> |
||||||
|
runningRequests: ReturnType<typeof useCounter> |
||||||
|
error: Ref<any> |
||||||
|
} |
||||||
|
|
||||||
|
export interface Getters { |
||||||
|
signedIn: ComputedRef<boolean> |
||||||
|
isLoading: WritableComputedRef<boolean> |
||||||
|
} |
||||||
|
|
||||||
|
export interface Actions { |
||||||
|
signOut: () => void |
||||||
|
signIn: (token: string) => void |
||||||
|
refreshToken: () => void |
||||||
|
} |
||||||
|
|
||||||
|
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'> |
||||||
|
|
||||||
|
export type UseGlobalReturn = Getters & Actions & ReadonlyState |
@ -1,169 +0,0 @@ |
|||||||
import { breakpointsTailwind, usePreferredLanguages, useStorage } from '@vueuse/core' |
|
||||||
import { useJwt } from '@vueuse/integrations/useJwt' |
|
||||||
import type { JwtPayload } from 'jwt-decode' |
|
||||||
import { notification } from 'ant-design-vue' |
|
||||||
import initialFeedBackForm from './initialFeedbackForm' |
|
||||||
import { computed, ref, toRefs, useBreakpoints, useNuxtApp, useTimestamp, watch } from '#imports' |
|
||||||
import type { Actions, Getters, GlobalState, StoredState, User } from '~/lib/types' |
|
||||||
|
|
||||||
const storageKey = 'nocodb-gui-v2' |
|
||||||
|
|
||||||
/** |
|
||||||
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`). |
|
||||||
* Manual initialization is unnecessary and should be avoided. |
|
||||||
* |
|
||||||
* The state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab. |
|
||||||
* |
|
||||||
* @example |
|
||||||
* ```js
|
|
||||||
* import { useNuxtApp } from '#app' |
|
||||||
* |
|
||||||
* const { $state } = useNuxtApp() |
|
||||||
* |
|
||||||
* const token = $state.token.value |
|
||||||
* const user = $state.user.value |
|
||||||
* ``` |
|
||||||
*/ |
|
||||||
export const useGlobalState = (): GlobalState => { |
|
||||||
const { $state } = useNuxtApp() |
|
||||||
|
|
||||||
if ($state) { |
|
||||||
console.warn( |
|
||||||
'[useGlobalState] Global state is injected by state plugin. Manual initialization is unnecessary and should be avoided.', |
|
||||||
) |
|
||||||
return $state |
|
||||||
} |
|
||||||
|
|
||||||
/** get the preferred languages of a user, according to browser settings */ |
|
||||||
const preferredLanguages = $(usePreferredLanguages()) |
|
||||||
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */ |
|
||||||
// const prefersDarkMode = $(usePreferredDark())
|
|
||||||
const prefersDarkMode = false |
|
||||||
|
|
||||||
/** get current breakpoints (for enabling sidebar) */ |
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind) |
|
||||||
|
|
||||||
/** reactive timestamp to check token expiry against */ |
|
||||||
const timestamp = $(useTimestamp({ immediate: true, interval: 100 })) |
|
||||||
|
|
||||||
const { |
|
||||||
$api, |
|
||||||
vueApp: { i18n }, |
|
||||||
} = useNuxtApp() |
|
||||||
|
|
||||||
/** |
|
||||||
* Set initial language based on browser settings. |
|
||||||
* If the user has not set a preferred language, we fallback to 'en'. |
|
||||||
* If the user has set a preferred language, we try to find a matching locale in the available locales. |
|
||||||
*/ |
|
||||||
const preferredLanguage = preferredLanguages.reduce<string>((locale, language) => { |
|
||||||
/** split language to language and code, e.g. en-GB -> [en, GB] */ |
|
||||||
const [lang, code] = language.split(/[_-]/) |
|
||||||
|
|
||||||
/** find all locales that match the language */ |
|
||||||
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang)) |
|
||||||
|
|
||||||
/** If we can match more than one locale, we check if the code of the language matches as well */ |
|
||||||
if (availableLocales.length > 1) { |
|
||||||
availableLocales = availableLocales.filter((locale) => locale.endsWith(code)) |
|
||||||
} |
|
||||||
|
|
||||||
/** if there are still multiple locales, pick the first one */ |
|
||||||
const availableLocale = availableLocales[0] |
|
||||||
|
|
||||||
/** if we found a matching locale, return it */ |
|
||||||
if (availableLocale) locale = availableLocale |
|
||||||
|
|
||||||
return locale |
|
||||||
}, 'en' /** fallback locale */) |
|
||||||
|
|
||||||
/** State */ |
|
||||||
const initialState: StoredState = { |
|
||||||
token: null, |
|
||||||
user: null, |
|
||||||
lang: preferredLanguage, |
|
||||||
darkMode: prefersDarkMode, |
|
||||||
feedbackForm: initialFeedBackForm, |
|
||||||
} |
|
||||||
|
|
||||||
/** saves a reactive state, any change to these values will write/delete to localStorage */ |
|
||||||
const storage = $(useStorage<StoredState>(storageKey, initialState)) |
|
||||||
|
|
||||||
/** force turn off of dark mode, regardless of previously stored settings */ |
|
||||||
storage.darkMode = false |
|
||||||
|
|
||||||
/** current token ref, used by `useJwt` to reactively parse our token payload */ |
|
||||||
let token = $computed({ |
|
||||||
get: () => storage.token || '', |
|
||||||
set: (val) => (storage.token = val), |
|
||||||
}) |
|
||||||
|
|
||||||
/** reactive token payload */ |
|
||||||
const { payload } = $(useJwt<JwtPayload & User>($$(token!))) |
|
||||||
|
|
||||||
/** Getters */ |
|
||||||
/** Verify that a user is signed in by checking if token exists and is not expired */ |
|
||||||
const signedIn: Getters['signedIn'] = computed( |
|
||||||
() => !!(!!token && token !== '' && payload && payload.exp && payload.exp > timestamp / 1000), |
|
||||||
) |
|
||||||
|
|
||||||
/** is sidebar open */ |
|
||||||
const sidebarOpen = ref(signedIn.value && breakpoints.greater('md').value) |
|
||||||
|
|
||||||
/** Actions */ |
|
||||||
/** Sign out by deleting the token from localStorage */ |
|
||||||
const signOut: Actions['signOut'] = () => { |
|
||||||
storage.token = null |
|
||||||
storage.user = null |
|
||||||
} |
|
||||||
|
|
||||||
/** Sign in by setting the token in localStorage */ |
|
||||||
const signIn: Actions['signIn'] = async (newToken) => { |
|
||||||
token = newToken |
|
||||||
|
|
||||||
if (payload) { |
|
||||||
storage.user = { |
|
||||||
id: payload.id, |
|
||||||
email: payload.email, |
|
||||||
firstname: payload.firstname, |
|
||||||
lastname: payload.lastname, |
|
||||||
roles: payload.roles, |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** manually try to refresh token */ |
|
||||||
const refreshToken = async () => { |
|
||||||
$api.instance |
|
||||||
.post('/auth/refresh-token', null, { |
|
||||||
withCredentials: true, |
|
||||||
}) |
|
||||||
.then((response) => { |
|
||||||
if (response.data?.token) { |
|
||||||
signIn(response.data.token) |
|
||||||
} |
|
||||||
}) |
|
||||||
.catch((err) => { |
|
||||||
notification.error({ |
|
||||||
// todo: add translation
|
|
||||||
message: err.message || 'You have been signed out.', |
|
||||||
}) |
|
||||||
console.error(err) |
|
||||||
|
|
||||||
signOut() |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/** try to refresh token before expiry (5 min before expiry) */ |
|
||||||
watch( |
|
||||||
() => !!(payload && payload.exp && payload.exp - 5 * 60 < timestamp / 1000), |
|
||||||
async (expiring) => { |
|
||||||
if (signedIn.value && payload && expiring) { |
|
||||||
await refreshToken() |
|
||||||
} |
|
||||||
}, |
|
||||||
{ immediate: true }, |
|
||||||
) |
|
||||||
|
|
||||||
return { ...toRefs(storage), signedIn, signOut, signIn, sidebarOpen } |
|
||||||
} |
|
@ -1,5 +0,0 @@ |
|||||||
export default { |
|
||||||
url: 'https://docs.google.com/forms/d/e/1FAIpQLSeTlAfZjszgr53lArz3NvUEnJGOT9JtG9NAU5d0oQwunDS2Pw/viewform?embedded=true', |
|
||||||
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(), |
|
||||||
isHidden: false, |
|
||||||
} |
|
@ -1,7 +1,6 @@ |
|||||||
import { defineNuxtPlugin } from '#imports' |
import { defineNuxtPlugin, useApi } from '#imports' |
||||||
import { createApiInstance } from '~/composables/useApi' |
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => { |
export default defineNuxtPlugin((nuxtApp) => { |
||||||
/** injects a global api instance */ |
/** injects a global api instance */ |
||||||
nuxtApp.provide('api', createApiInstance(nuxtApp)) |
nuxtApp.provide('api', useApi().api) |
||||||
}) |
}) |
||||||
|
Loading…
Reference in new issue