mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugins: Add grafana/user/profile/tab plugin extension point (#77863)
* add grafana/user/profile/settings plugin extension point * changes to support plugins having their own settings tabs * WIP * add comment * add unit tests * allow setting open tab based on tab query param * update name of extension point * add some more unit tests * address PR comments * PR comments
This commit is contained in:
@@ -121,6 +121,7 @@ export enum PluginExtensionPoints {
|
|||||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||||
DataSourceConfig = 'grafana/datasources/config',
|
DataSourceConfig = 'grafana/datasources/config',
|
||||||
ExploreToolbarAction = 'grafana/explore/toolbar/action',
|
ExploreToolbarAction = 'grafana/explore/toolbar/action',
|
||||||
|
UserProfileTab = 'grafana/user/profile/tab',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginExtensionPanelContext = {
|
export type PluginExtensionPanelContext = {
|
||||||
|
|||||||
@@ -421,6 +421,8 @@ export const Components = {
|
|||||||
preferencesSaveButton: 'data-testid-shared-prefs-save',
|
preferencesSaveButton: 'data-testid-shared-prefs-save',
|
||||||
orgsTable: 'data-testid-user-orgs-table',
|
orgsTable: 'data-testid-user-orgs-table',
|
||||||
sessionsTable: 'data-testid-user-sessions-table',
|
sessionsTable: 'data-testid-user-sessions-table',
|
||||||
|
extensionPointTabs: 'data-testid-extension-point-tabs',
|
||||||
|
extensionPointTab: (tabId: string) => `data-testid-extension-point-tab-${tabId}`,
|
||||||
},
|
},
|
||||||
FileUpload: {
|
FileUpload: {
|
||||||
inputField: 'data-testid-file-upload-input-field',
|
inputField: 'data-testid-file-upload-input-field',
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { render, screen, waitFor, within } from '@testing-library/react';
|
|||||||
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { OrgRole } from '@grafana/data';
|
import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { setPluginExtensionGetter, GetPluginExtensions } from '@grafana/runtime';
|
||||||
|
import * as useQueryParams from 'app/core/hooks/useQueryParams';
|
||||||
|
|
||||||
import { TestProvider } from '../../../test/helpers/TestProvider';
|
import { TestProvider } from '../../../test/helpers/TestProvider';
|
||||||
import { backendSrv } from '../../core/services/backend_srv';
|
import { backendSrv } from '../../core/services/backend_srv';
|
||||||
@@ -13,6 +15,13 @@ import { getMockTeam } from '../teams/__mocks__/teamMocks';
|
|||||||
import { Props, UserProfileEditPage } from './UserProfileEditPage';
|
import { Props, UserProfileEditPage } from './UserProfileEditPage';
|
||||||
import { initialUserState } from './state/reducers';
|
import { initialUserState } from './state/reducers';
|
||||||
|
|
||||||
|
const mockUseQueryParams = useQueryParams as { useQueryParams: typeof useQueryParams.useQueryParams };
|
||||||
|
|
||||||
|
jest.mock('app/core/hooks/useQueryParams', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
useQueryParams: () => [{}],
|
||||||
|
}));
|
||||||
|
|
||||||
const defaultProps: Props = {
|
const defaultProps: Props = {
|
||||||
...initialUserState,
|
...initialUserState,
|
||||||
user: {
|
user: {
|
||||||
@@ -91,10 +100,69 @@ function getSelectors() {
|
|||||||
within(sessionsTable()).getByRole('row', {
|
within(sessionsTable()).getByRole('row', {
|
||||||
name: /now January 1, 2021 localhost chrome on mac os x 11/i,
|
name: /now January 1, 2021 localhost chrome on mac os x 11/i,
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* using queryByTestId instead of getByTestId because the tabs are not always rendered
|
||||||
|
* and getByTestId throws an TestingLibraryElementError error if the element is not found
|
||||||
|
* whereas queryByTestId returns null if the element is not found. There are some test cases
|
||||||
|
* where we'd explicitly like to assert that the tabs are not rendered.
|
||||||
|
*/
|
||||||
|
extensionPointTabs: () => screen.queryByTestId(selectors.components.UserProfile.extensionPointTabs),
|
||||||
|
/**
|
||||||
|
* here lets use getByTestId because a specific tab should always be rendered within the tabs container
|
||||||
|
*/
|
||||||
|
extensionPointTab: (tabId: string) =>
|
||||||
|
within(screen.getByTestId(selectors.components.UserProfile.extensionPointTabs)).getByTestId(
|
||||||
|
selectors.components.UserProfile.extensionPointTab(tabId)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTestContext(overrides: Partial<Props> = {}) {
|
enum ExtensionPointComponentId {
|
||||||
|
One = '1',
|
||||||
|
Two = '2',
|
||||||
|
Three = '3',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExtensionPointComponentTabs {
|
||||||
|
One = '1',
|
||||||
|
Two = '2',
|
||||||
|
}
|
||||||
|
|
||||||
|
const _createTabName = (tab: ExtensionPointComponentTabs) => `Tab ${tab}`;
|
||||||
|
const _createTabContent = (tabId: ExtensionPointComponentId) => `this is settings for component ${tabId}`;
|
||||||
|
|
||||||
|
const generalTabName = 'General';
|
||||||
|
const tabOneName = _createTabName(ExtensionPointComponentTabs.One);
|
||||||
|
const tabTwoName = _createTabName(ExtensionPointComponentTabs.Two);
|
||||||
|
|
||||||
|
const _createPluginExtensionPointComponent = (
|
||||||
|
id: ExtensionPointComponentId,
|
||||||
|
tab: ExtensionPointComponentTabs
|
||||||
|
): PluginExtensionComponent => ({
|
||||||
|
id,
|
||||||
|
type: PluginExtensionTypes.component,
|
||||||
|
title: _createTabName(tab),
|
||||||
|
description: '', // description isn't used here..
|
||||||
|
component: () => <p>{_createTabContent(id)}</p>,
|
||||||
|
pluginId: 'grafana-plugin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PluginExtensionPointComponent1 = _createPluginExtensionPointComponent(
|
||||||
|
ExtensionPointComponentId.One,
|
||||||
|
ExtensionPointComponentTabs.One
|
||||||
|
);
|
||||||
|
const PluginExtensionPointComponent2 = _createPluginExtensionPointComponent(
|
||||||
|
ExtensionPointComponentId.Two,
|
||||||
|
ExtensionPointComponentTabs.One
|
||||||
|
);
|
||||||
|
const PluginExtensionPointComponent3 = _createPluginExtensionPointComponent(
|
||||||
|
ExtensionPointComponentId.Three,
|
||||||
|
ExtensionPointComponentTabs.Two
|
||||||
|
);
|
||||||
|
|
||||||
|
async function getTestContext(overrides: Partial<Props & { extensions: PluginExtensionComponent[] }> = {}) {
|
||||||
|
const extensions = overrides.extensions || [];
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const putSpy = jest.spyOn(backendSrv, 'put');
|
const putSpy = jest.spyOn(backendSrv, 'put');
|
||||||
const getSpy = jest
|
const getSpy = jest
|
||||||
@@ -102,6 +170,10 @@ async function getTestContext(overrides: Partial<Props> = {}) {
|
|||||||
.mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' });
|
.mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' });
|
||||||
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
|
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
|
||||||
|
|
||||||
|
const getter: GetPluginExtensions<PluginExtensionComponent> = jest.fn().mockReturnValue({ extensions });
|
||||||
|
|
||||||
|
setPluginExtensionGetter(getter);
|
||||||
|
|
||||||
const props = { ...defaultProps, ...overrides };
|
const props = { ...defaultProps, ...overrides };
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TestProvider>
|
<TestProvider>
|
||||||
@@ -254,5 +326,73 @@ describe('UserProfileEditPage', () => {
|
|||||||
expect(props.revokeUserSession).toHaveBeenCalledWith(0);
|
expect(props.revokeUserSession).toHaveBeenCalledWith(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('and a plugin registers a component against the user profile settings extension point', () => {
|
||||||
|
const extensions = [
|
||||||
|
PluginExtensionPointComponent1,
|
||||||
|
PluginExtensionPointComponent2,
|
||||||
|
PluginExtensionPointComponent3,
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should not show tabs when no components are registered', async () => {
|
||||||
|
await getTestContext();
|
||||||
|
const { extensionPointTabs } = getSelectors();
|
||||||
|
expect(extensionPointTabs()).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group registered components into tabs', async () => {
|
||||||
|
await getTestContext({ extensions });
|
||||||
|
const { extensionPointTabs, extensionPointTab } = getSelectors();
|
||||||
|
|
||||||
|
const _assertTab = (tabId: string, isDefault = false) => {
|
||||||
|
const tab = extensionPointTab(tabId);
|
||||||
|
expect(tab).toBeInTheDocument();
|
||||||
|
expect(tab).toHaveAttribute('aria-selected', isDefault.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(extensionPointTabs()).toBeInTheDocument();
|
||||||
|
_assertTab(generalTabName.toLowerCase(), true);
|
||||||
|
_assertTab(tabOneName.toLowerCase());
|
||||||
|
_assertTab(tabTwoName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change the active tab when a tab is clicked and update the "tab" query param', async () => {
|
||||||
|
const mockUpdateQueryParams = jest.fn();
|
||||||
|
mockUseQueryParams.useQueryParams = () => [{}, mockUpdateQueryParams];
|
||||||
|
|
||||||
|
await getTestContext({ extensions });
|
||||||
|
const { extensionPointTab } = getSelectors();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab one has two extension components registered against it, they'll both be registered in the same tab
|
||||||
|
* Tab two only has one extension component registered against it.
|
||||||
|
*/
|
||||||
|
const tabOneContent1 = _createTabContent(ExtensionPointComponentId.One);
|
||||||
|
const tabOneContent2 = _createTabContent(ExtensionPointComponentId.Two);
|
||||||
|
const tabTwoContent = _createTabContent(ExtensionPointComponentId.Three);
|
||||||
|
|
||||||
|
// "General" should be the default content
|
||||||
|
expect(screen.queryByText(tabOneContent1)).toBeNull();
|
||||||
|
expect(screen.queryByText(tabOneContent2)).toBeNull();
|
||||||
|
expect(screen.queryByText(tabTwoContent)).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.click(extensionPointTab(tabOneName.toLowerCase()));
|
||||||
|
|
||||||
|
expect(mockUpdateQueryParams).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateQueryParams).toHaveBeenCalledWith({ tab: tabOneName.toLowerCase() });
|
||||||
|
expect(screen.queryByText(tabOneContent1)).not.toBeNull();
|
||||||
|
expect(screen.queryByText(tabOneContent2)).not.toBeNull();
|
||||||
|
expect(screen.queryByText(tabTwoContent)).toBeNull();
|
||||||
|
|
||||||
|
mockUpdateQueryParams.mockClear();
|
||||||
|
await userEvent.click(extensionPointTab(tabTwoName.toLowerCase()));
|
||||||
|
|
||||||
|
expect(mockUpdateQueryParams).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateQueryParams).toHaveBeenCalledWith({ tab: tabTwoName.toLowerCase() });
|
||||||
|
expect(screen.queryByText(tabOneContent1)).toBeNull();
|
||||||
|
expect(screen.queryByText(tabOneContent2)).toBeNull();
|
||||||
|
expect(screen.queryByText(tabTwoContent)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { useMount } from 'react-use';
|
import { useMount } from 'react-use';
|
||||||
|
|
||||||
import { VerticalGroup } from '@grafana/ui';
|
import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { getPluginComponentExtensions } from '@grafana/runtime';
|
||||||
|
import { Tab, TabsBar, TabContent, VerticalGroup } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
|
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||||
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import UserOrganizations from './UserOrganizations';
|
import UserOrganizations from './UserOrganizations';
|
||||||
@@ -13,6 +18,14 @@ import UserSessions from './UserSessions';
|
|||||||
import { UserTeams } from './UserTeams';
|
import { UserTeams } from './UserTeams';
|
||||||
import { changeUserOrg, initUserProfilePage, revokeUserSession, updateUserProfile } from './state/actions';
|
import { changeUserOrg, initUserProfilePage, revokeUserSession, updateUserProfile } from './state/actions';
|
||||||
|
|
||||||
|
const TAB_QUERY_PARAM = 'tab';
|
||||||
|
const GENERAL_SETTINGS_TAB = 'general';
|
||||||
|
|
||||||
|
type TabInfo = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface OwnProps {}
|
export interface OwnProps {}
|
||||||
|
|
||||||
function mapStateToProps(state: StoreState) {
|
function mapStateToProps(state: StoreState) {
|
||||||
@@ -55,19 +68,103 @@ export function UserProfileEditPage({
|
|||||||
changeUserOrg,
|
changeUserOrg,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [queryParams, updateQueryParams] = useQueryParams();
|
||||||
|
const tabQueryParam = queryParams[TAB_QUERY_PARAM];
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(
|
||||||
|
typeof tabQueryParam === 'string' ? tabQueryParam : GENERAL_SETTINGS_TAB
|
||||||
|
);
|
||||||
|
|
||||||
useMount(() => initUserProfilePage());
|
useMount(() => initUserProfilePage());
|
||||||
|
|
||||||
|
const extensionComponents = useMemo(() => {
|
||||||
|
const { extensions } = getPluginComponentExtensions({
|
||||||
|
extensionPointId: PluginExtensionPoints.UserProfileTab,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return extensions;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groupedExtensionComponents = extensionComponents.reduce<Record<string, PluginExtensionComponent[]>>(
|
||||||
|
(acc, extension) => {
|
||||||
|
const { title } = extension;
|
||||||
|
if (acc[title]) {
|
||||||
|
acc[title].push(extension);
|
||||||
|
} else {
|
||||||
|
acc[title] = [extension];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase();
|
||||||
|
|
||||||
|
const showTabs = extensionComponents.length > 0;
|
||||||
|
const tabs: TabInfo[] = [
|
||||||
|
{
|
||||||
|
id: GENERAL_SETTINGS_TAB,
|
||||||
|
title: t('user-profile.tabs.general', 'General'),
|
||||||
|
},
|
||||||
|
...Object.keys(groupedExtensionComponents).map((title) => ({
|
||||||
|
id: convertExtensionComponentTitleToTabId(title),
|
||||||
|
title,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const UserProfile = () => (
|
||||||
|
<VerticalGroup spacing="md">
|
||||||
|
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
|
||||||
|
<SharedPreferences resourceUri="user" preferenceType="user" />
|
||||||
|
<UserTeams isLoading={teamsAreLoading} teams={teams} />
|
||||||
|
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
|
||||||
|
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
|
||||||
|
</VerticalGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UserProfileWithTabs = () => (
|
||||||
|
<div data-testid={selectors.components.UserProfile.extensionPointTabs}>
|
||||||
|
<VerticalGroup spacing="md">
|
||||||
|
<TabsBar>
|
||||||
|
{tabs.map(({ id, title }) => {
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={id}
|
||||||
|
label={title}
|
||||||
|
active={activeTab === id}
|
||||||
|
onChangeTab={() => {
|
||||||
|
setActiveTab(id);
|
||||||
|
updateQueryParams({ [TAB_QUERY_PARAM]: id });
|
||||||
|
}}
|
||||||
|
data-testid={selectors.components.UserProfile.extensionPointTab(id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsBar>
|
||||||
|
<TabContent>
|
||||||
|
{activeTab === GENERAL_SETTINGS_TAB && <UserProfile />}
|
||||||
|
{Object.entries(groupedExtensionComponents).map(([title, pluginExtensionComponents]) => {
|
||||||
|
const tabId = convertExtensionComponentTitleToTabId(title);
|
||||||
|
|
||||||
|
if (activeTab === tabId) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={tabId}>
|
||||||
|
{pluginExtensionComponents.map(({ component: Component }, index) => (
|
||||||
|
<Component key={`${tabId}-${index}`} />
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</TabContent>
|
||||||
|
</VerticalGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="profile/settings">
|
<Page navId="profile/settings">
|
||||||
<Page.Contents isLoading={!user}>
|
<Page.Contents isLoading={!user}>{showTabs ? <UserProfileWithTabs /> : <UserProfile />}</Page.Contents>
|
||||||
<VerticalGroup spacing="md">
|
|
||||||
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
|
|
||||||
<SharedPreferences resourceUri="user" preferenceType="user" />
|
|
||||||
<UserTeams isLoading={teamsAreLoading} teams={teams} />
|
|
||||||
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
|
|
||||||
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
|
|
||||||
</VerticalGroup>
|
|
||||||
</Page.Contents>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1298,6 +1298,9 @@
|
|||||||
"name-error": "Name ist erforderlich",
|
"name-error": "Name ist erforderlich",
|
||||||
"name-label": "Name",
|
"name-label": "Name",
|
||||||
"username-label": "Benutzername"
|
"username-label": "Benutzername"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
@@ -1298,6 +1298,9 @@
|
|||||||
"name-error": "Name is required",
|
"name-error": "Name is required",
|
||||||
"name-label": "Name",
|
"name-label": "Name",
|
||||||
"username-label": "Username"
|
"username-label": "Username"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": "General"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
@@ -1304,6 +1304,9 @@
|
|||||||
"name-error": "El nombre es obligatorio",
|
"name-error": "El nombre es obligatorio",
|
||||||
"name-label": "Nombre",
|
"name-label": "Nombre",
|
||||||
"username-label": "Nombre de usuario"
|
"username-label": "Nombre de usuario"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
@@ -1304,6 +1304,9 @@
|
|||||||
"name-error": "Un nom est obligatoire",
|
"name-error": "Un nom est obligatoire",
|
||||||
"name-label": "Nom",
|
"name-label": "Nom",
|
||||||
"username-label": "Nom d’utilisateur"
|
"username-label": "Nom d’utilisateur"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
@@ -1298,6 +1298,9 @@
|
|||||||
"name-error": "Ńämę įş řęqūįřęđ",
|
"name-error": "Ńämę įş řęqūįřęđ",
|
||||||
"name-label": "Ńämę",
|
"name-label": "Ńämę",
|
||||||
"username-label": "Ůşęřʼnämę"
|
"username-label": "Ůşęřʼnämę"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": "Ğęʼnęřäľ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
@@ -1292,6 +1292,9 @@
|
|||||||
"name-error": "姓名是必填项",
|
"name-error": "姓名是必填项",
|
||||||
"name-label": "姓名",
|
"name-label": "姓名",
|
||||||
"username-label": "用户名"
|
"username-label": "用户名"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"general": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user-session": {
|
"user-session": {
|
||||||
|
|||||||
Reference in New Issue
Block a user