This is an automated email from the ASF dual-hosted git repository.
LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 42ef62292 feat: resolve and link resource cross-references (#3459)
42ef62292 is described below
commit 42ef622923ef825bc84fee379686ae5ea65b681c
Author: Yuhan <[email protected]>
AuthorDate: Mon Aug 3 09:24:45 2026 +0800
feat: resolve and link resource cross-references (#3459)
---
e2e/tests/regression/form.cross-references.spec.ts | 185 +++++++++++++++++++
src/components/form-slice/FormPartConsumer.tsx | 4 +-
src/components/form-slice/FormPartRoute/index.tsx | 13 +-
src/components/form/ResourceRef.test.ts | 64 +++++++
src/components/form/ResourceRef.tsx | 197 +++++++++++++++++++++
src/locales/de/common.json | 5 +
src/locales/en/common.json | 5 +
src/locales/es/common.json | 5 +
src/locales/tr/common.json | 5 +
src/locales/zh/common.json | 5 +
10 files changed, 484 insertions(+), 4 deletions(-)
diff --git a/e2e/tests/regression/form.cross-references.spec.ts
b/e2e/tests/regression/form.cross-references.spec.ts
new file mode 100644
index 000000000..f97311dcc
--- /dev/null
+++ b/e2e/tests/regression/form.cross-references.spec.ts
@@ -0,0 +1,185 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import {
+ deleteAllConsumerGroups,
+ putConsumerGroupReq,
+} from '@/apis/consumer_groups';
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// #3453 item 4: `upstream_id` and friends rendered as a plain text input
+// holding a bare id — no name, nothing to click — so answering "which
+// upstream is this route actually using?" meant copying the id, navigating
+// to Upstreams, and searching.
+
+const UPSTREAM_ID = 'xref-upstream';
+// The `&` is load-bearing: it is the one character i18next's interpolation
+// escaping mangles into `&` if `viewNamed` ever loses its
+// `escapeValue: false`. A fixture name with no such character can't catch
+// that regression.
+const UPSTREAM_NAME = 'xref R&D backend';
+const ROUTE_ID = 'xref-route';
+const DANGLING_ROUTE_ID = 'xref-dangling-route';
+const MISSING_UPSTREAM_ID = 'xref-no-such-upstream';
+
+const clean = () =>
+ safeClean(async () => {
+ await deleteAllRoutes(e2eReq);
+ await deleteAllUpstreams(e2eReq);
+ await deleteAllConsumers(e2eReq);
+ await deleteAllConsumerGroups(e2eReq);
+ });
+
+test.beforeAll(async () => {
+ await clean();
+ await putUpstreamReq(e2eReq, {
+ id: UPSTREAM_ID,
+ name: UPSTREAM_NAME,
+ type: 'roundrobin',
+ nodes: { 'xref.local:80': 1 },
+ } as APISIXType['Upstream']);
+ await putRouteReq(e2eReq, {
+ id: ROUTE_ID,
+ name: 'xref route',
+ uri: '/xref',
+ upstream_id: UPSTREAM_ID,
+ } as APISIXType['Route']);
+ // A dangling reference cannot be written directly: the Admin API rejects a
+ // route whose upstream_id does not resolve. It can still be reached — and
+ // therefore must be handled by the UI — by force-deleting an upstream that
+ // is still referenced, which leaves the route pointing at nothing.
+ await putUpstreamReq(e2eReq, {
+ id: MISSING_UPSTREAM_ID,
+ name: 'xref soon deleted upstream',
+ type: 'roundrobin',
+ nodes: { 'xref-gone.local:80': 1 },
+ } as APISIXType['Upstream']);
+ await putRouteReq(e2eReq, {
+ id: DANGLING_ROUTE_ID,
+ name: 'xref dangling route',
+ uri: '/xref-dangling',
+ upstream_id: MISSING_UPSTREAM_ID,
+ } as APISIXType['Route']);
+ // `force` goes in the URL, not in axios `params`: the Playwright request
+ // adapter in `@e2e/utils/req` builds its URL from `config.url` alone and
+ // never serialises `params`.
+ await e2eReq.delete(`/upstreams/${MISSING_UPSTREAM_ID}?force=true`);
+});
+
+test.afterAll(clean);
+
+test('a resolved reference is a real link to the referenced resource', async ({
+ page,
+}) => {
+ await uiGoto(page, '/routes/detail/$id', { id: ROUTE_ID });
+
+ // getByRole('link') is load-bearing: it fails if the control regresses to
+ // a button, which would be dead inside a disabled fieldset.
+ const link = page.getByRole('link', { name: UPSTREAM_NAME });
+ await expect(link).toBeVisible();
+ // The accessible name has to say what the link does, not just name the
+ // resource: "link, xref upstream backend" tells a screen reader user
+ // nothing about where it goes.
+ await expect(link).toHaveAccessibleName(`View Upstream: ${UPSTREAM_NAME}`);
+
+ await link.click();
+ await expect(page).toHaveURL(new RegExp(`/upstreams/detail/${UPSTREAM_ID}`));
+});
+
+test('a dangling reference warns and is not clickable', async ({ page }) => {
+ await uiGoto(page, '/routes/detail/$id', { id: DANGLING_ROUTE_ID });
+
+ // Deliberately tight. Inheriting the global retry policy made a 404 take
+ // ~8s of backoff to settle; the field's own retry predicate brings that
+ // to ~1.1s, so 3s is roughly 3x margin and a return of the backoff fails
+ // here rather than passing slowly and unnoticed.
+ await expect(
+ page.getByRole('img', { name: 'No Upstream with this ID' })
+ ).toBeVisible({ timeout: 3_000 });
+
+ // The point of the warning state: a reference that resolves to nothing
+ // must not send the user to a page that only says so.
+ await expect(
+ page.locator(`a[href*="/upstreams/detail/${MISSING_UPSTREAM_ID}"]`)
+ ).toHaveCount(0);
+});
+
+test('the resolved state follows what is typed into the field', async ({
+ page,
+}) => {
+ await uiGoto(page, '/routes/detail/$id', { id: ROUTE_ID });
+ await expect(page.getByRole('link', { name: UPSTREAM_NAME })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Edit', exact: true }).click();
+
+ // The field carries no label of its own — "Upstream ID" is the legend of
+ // the fieldset around it, which names the group, not the input.
+ const field = page
+ .getByRole('group', { name: 'Upstream ID', exact: true })
+ .locator('input[name="upstream_id"]');
+ await field.fill(MISSING_UPSTREAM_ID);
+
+ // Proves the field is live, not resolved once at mount. 3s again: this
+ // is the 300ms debounce plus one request (~0.4s measured), and it is the
+ // path a retried 404 punished once per typing pause.
+ await expect(
+ page.getByRole('img', { name: 'No Upstream with this ID' })
+ ).toBeVisible({ timeout: 3_000 });
+ await expect(page.getByRole('link', { name: UPSTREAM_NAME })).toHaveCount(0);
+});
+
+const GROUP_ID = 'xref-group';
+const GROUP_DESC = 'xref consumer group';
+const CONSUMER_NAME = 'xref_consumer';
+
+test('a consumer group reference resolves on the consumer page', async ({
+ page,
+}) => {
+ // A second field on a different resource, so the wiring is not proven
+ // for one call site only.
+ await putConsumerGroupReq(e2eReq, {
+ id: GROUP_ID,
+ desc: GROUP_DESC,
+ plugins: {},
+ });
+ await putConsumerReq(e2eReq, {
+ username: CONSUMER_NAME,
+ group_id: GROUP_ID,
+ });
+
+ await uiGoto(page, '/consumers/detail/$username', {
+ username: CONSUMER_NAME,
+ });
+
+ // Consumer groups have no `name` field at all
+ // (`ConsumerGroup = PluginConfig.omit({ name: true })`), so the link's
+ // accessible name falls back to the `form.ref.view` string.
+ const link = page.getByRole('link', { name: 'View Consumer Group' });
+ await expect(link).toBeVisible();
+ await link.click();
+ await expect(page).toHaveURL(
+ new RegExp(`/consumer_groups/detail/${GROUP_ID}`)
+ );
+});
diff --git a/src/components/form-slice/FormPartConsumer.tsx
b/src/components/form-slice/FormPartConsumer.tsx
index e3fbd9f58..b3ec5fcfa 100644
--- a/src/components/form-slice/FormPartConsumer.tsx
+++ b/src/components/form-slice/FormPartConsumer.tsx
@@ -17,6 +17,7 @@
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
+import { FormItemResourceRef } from '@/components/form/ResourceRef';
import { FormItemTextInput } from '@/components/form/TextInput';
import type { APISIXType } from '@/types/schema/apisix';
@@ -56,10 +57,11 @@ export const FormPartConsumer = (props:
FormPartConsumerProps) => {
/>
}
/>
- <FormItemTextInput
+ <FormItemResourceRef
control={control}
name="group_id"
label={t('form.consumers.groupId')}
+ resource="consumerGroups"
/>
<FormSectionPluginsOnly />
</>
diff --git a/src/components/form-slice/FormPartRoute/index.tsx
b/src/components/form-slice/FormPartRoute/index.tsx
index 9d3638954..0e96f5ec2 100644
--- a/src/components/form-slice/FormPartRoute/index.tsx
+++ b/src/components/form-slice/FormPartRoute/index.tsx
@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
import { FormItemEditor } from '@/components/form/Editor';
import { FormItemNumberInput } from '@/components/form/NumberInput';
+import { FormItemResourceRef } from '@/components/form/ResourceRef';
import { FormItemSwitch } from '@/components/form/Switch';
import { FormItemTagsInput } from '@/components/form/TagInput';
import { FormItemTextarea } from '@/components/form/Textarea';
@@ -115,7 +116,11 @@ export const FormSectionUpstream = () => {
return (
<FormSection legend={t('form.upstreams.title')}>
<FormSection legend={t('form.upstreams.upstreamId')}>
- <FormItemTextInput control={control} name="upstream_id" />
+ <FormItemResourceRef
+ control={control}
+ name="upstream_id"
+ resource="upstreams"
+ />
</FormSection>
<Divider my="xs" label={t('or')} />
<NamePrefixProvider value="upstream">
@@ -137,10 +142,11 @@ export const FormSectionPlugins = (props:
FormSectionPluginsProps) => {
<FormSection legend={t('form.plugins.label')}>
{showConfigId && (
<>
- <FormItemTextInput
+ <FormItemResourceRef
control={control}
name="plugin_config_id"
label={t('form.plugins.configId')}
+ resource="pluginConfigs"
/>
<Divider my="xs" label={t('or')} />
</>
@@ -159,10 +165,11 @@ export const FormSectionService = () => {
legend={t('form.routes.service')}
disabled={readOnlyFields.includes('service_id')}
>
- <FormItemTextInput
+ <FormItemResourceRef
control={control}
name="service_id"
label={t('form.upstreams.serviceId')}
+ resource="services"
/>
</FormSection>
);
diff --git a/src/components/form/ResourceRef.test.ts
b/src/components/form/ResourceRef.test.ts
new file mode 100644
index 000000000..f127b29a4
--- /dev/null
+++ b/src/components/form/ResourceRef.test.ts
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { REFS, type ResourceRefTarget } from './ResourceRef';
+
+// The `satisfies` on REFS checks the route strings against the generated
+// route tree and nothing else: the factory slot is erased to
+// `(id: string) => unknown`, which every one of the four factories
+// satisfies for every key, so pairing `upstreams` with the *service*
+// query compiles clean and ships a link that resolves the wrong resource.
+// This is the check the type system cannot make.
+//
+// The discriminator is the query key: `genDetailQueryOptions(key, ...)`
+// (src/apis/hooks.ts) puts its `key` at position 0 of every queryKey it
+// builds, so the factory identifies itself without being compared by
+// reference.
+const EXPECTED: Record<
+ ResourceRefTarget,
+ { detailQueryKey: string; to: string }
+> = {
+ upstreams: { detailQueryKey: 'upstream', to: '/upstreams/detail/$id' },
+ services: { detailQueryKey: 'service', to: '/services/detail/$id' },
+ pluginConfigs: {
+ detailQueryKey: 'plugin_config',
+ to: '/plugin_configs/detail/$id',
+ },
+ consumerGroups: {
+ detailQueryKey: 'consumer_group',
+ to: '/consumer_groups/detail/$id',
+ },
+};
+
+describe('REFS', () => {
+ it('covers exactly the four referenceable resources', () => {
+ expect(Object.keys(REFS).sort()).toEqual(Object.keys(EXPECTED).sort());
+ });
+
+ it.each(Object.keys(EXPECTED) as ResourceRefTarget[])(
+ '%s pairs its own detail query with its own detail route',
+ (resource) => {
+ const { detailQueryKey, to } = EXPECTED[resource];
+ expect(REFS[resource].getQueryOptions('an-id').queryKey).toEqual([
+ detailQueryKey,
+ 'an-id',
+ ]);
+ expect(REFS[resource].to).toBe(to);
+ }
+ );
+});
diff --git a/src/components/form/ResourceRef.tsx
b/src/components/form/ResourceRef.tsx
new file mode 100644
index 000000000..f630fc1bd
--- /dev/null
+++ b/src/components/form/ResourceRef.tsx
@@ -0,0 +1,197 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Anchor, type AnchorProps, Tooltip } from '@mantine/core';
+import { useDebouncedValue } from '@mantine/hooks';
+import { useQuery } from '@tanstack/react-query';
+import { createLink } from '@tanstack/react-router';
+import { HttpStatusCode, isAxiosError } from 'axios';
+import { forwardRef } from 'react';
+import { type FieldValues, useWatch } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+import {
+ getConsumerGroupQueryOptions,
+ getPluginConfigQueryOptions,
+ getServiceQueryOptions,
+ getUpstreamQueryOptions,
+} from '@/apis/hooks';
+import {
+ FormItemTextInput,
+ type FormItemTextInputProps,
+} from '@/components/form/TextInput';
+import type { FileRoutesByTo } from '@/routeTree.gen';
+import IconWarning from '~icons/tabler/alert-triangle';
+// An in-app router navigation, not a new window: `external-link` would say
+// the opposite of what this control does.
+import IconGoTo from '~icons/tabler/arrow-right';
+
+/**
+ * A 404 means the referenced resource does not exist; anything else means
+ * we could not ask. #3458 introduces a shared `isNotFoundError` in
+ * `@/utils/error` and makes "a read 404 is neither retried nor toasted"
+ * the global rule. Once it has merged, this predicate collapses into that
+ * import and the local `retry` below can go away entirely; the red
+ * `Key not found` toast a dangling reference still pops today goes with it.
+ */
+const isNotFound = (error: unknown) =>
+ isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound;
+
+/**
+ * A real `<a>`, not the repo's `RouteLinkBtn`, which is a Mantine `Button`.
+ * `FormSection` renders a `<fieldset>` and passes `disabled` in the nested
+ * service → route view; a disabled fieldset kills every descendant form
+ * control but leaves anchors alone.
+ */
+const MantineAnchorLink = forwardRef<HTMLAnchorElement, AnchorProps>(
+ (props, ref) => <Anchor ref={ref} {...props} />
+);
+MantineAnchorLink.displayName = 'ResourceRefLink';
+const ResourceRefLink = createLink(MantineAnchorLink);
+
+/** The four referenced resources all carry an optional `name`. */
+type RefDetail = { value?: { name?: string } };
+
+/**
+ * The referenceable resources. The `satisfies` checks the route strings
+ * against the generated route tree; it does NOT check that each entry's
+ * factory belongs to its key, because every factory satisfies the erased
+ * `(id: string) => unknown` slot. `ResourceRef.test.ts` covers that pairing.
+ *
+ * The cast keeps the one unavoidable widening here instead of at every
+ * call site: TanStack's `queryOptions()` types `queryFn` as optional and
+ * as taking a query context, which no shared signature can express.
+ */
+export const REFS = {
+ upstreams: {
+ to: '/upstreams/detail/$id',
+ getQueryOptions: getUpstreamQueryOptions,
+ },
+ services: {
+ to: '/services/detail/$id',
+ getQueryOptions: getServiceQueryOptions,
+ },
+ pluginConfigs: {
+ to: '/plugin_configs/detail/$id',
+ getQueryOptions: getPluginConfigQueryOptions,
+ },
+ consumerGroups: {
+ to: '/consumer_groups/detail/$id',
+ getQueryOptions: getConsumerGroupQueryOptions,
+ },
+} satisfies Record<
+ string,
+ { to: keyof FileRoutesByTo; getQueryOptions: (id: string) => unknown }
+> as unknown as Record<
+ 'upstreams' | 'services' | 'pluginConfigs' | 'consumerGroups',
+ {
+ to: keyof FileRoutesByTo;
+ getQueryOptions: (id: string) => {
+ queryKey: readonly unknown[];
+ queryFn: () => Promise<RefDetail>;
+ };
+ }
+>;
+
+export type ResourceRefTarget = keyof typeof REFS;
+
+export type FormItemResourceRefProps<T extends FieldValues> =
+ FormItemTextInputProps<T> & {
+ resource: ResourceRefTarget;
+ };
+
+export const FormItemResourceRef = <T extends FieldValues>(
+ props: FormItemResourceRefProps<T>
+) => {
+ const { resource, ...inputProps } = props;
+ const { t } = useTranslation();
+
+ const value = useWatch({
+ control: props.control,
+ name: props.name,
+ }) as string | undefined;
+ // Without this the field fires one query per keystroke while it is being
+ // typed into, and all but the last are guaranteed to 404.
+ const [id] = useDebouncedValue((value ?? '').trim(), 300);
+
+ const { to, getQueryOptions } = REFS[resource];
+ const options = getQueryOptions(id);
+ const { data, error } = useQuery({
+ queryKey: options.queryKey,
+ queryFn: options.queryFn,
+ enabled: !!id,
+ // 404 = "no such resource", which is an answer, not a failure: the
+ // global default would retry it three times and delay the warning by
+ // ~7s of backoff — once per typing pause and once per window refocus.
+ // Real failures (5xx, network) still get two more tries.
+ retry: (failureCount, error) => failureCount < 2 && !isNotFound(error),
+ });
+
+ const singular = t(`${resource}.singular`);
+ const missing = t('form.ref.missing', { name: singular });
+ const name = data?.value?.name;
+ // Reads as an action, not as a bare noun: a screen reader announcing
+ // "link, xref upstream backend" says nothing about where it goes.
+ const label = name
+ ? // `name` is user-controlled (a resource's own name, e.g. "R&D
+ // backend"). i18next's default `escapeValue: true` would HTML-escape
+ // it (`R&D`) before it ever reaches React. That escaping is not
+ // just unneeded here, it is wrong: `label` lands in a React
+ // `aria-label` attribute and a Mantine `Tooltip` label, both of which
+ // React itself escapes on render. Double-escaping turns a literal
+ // "&" into a literal "&" in the rendered text.
+ t('form.ref.viewNamed', {
+ resource: singular,
+ name,
+ interpolation: { escapeValue: false },
+ })
+ : t('form.ref.view', { name: singular });
+
+ const rightSection = (() => {
+ if (!id) return null;
+ if (isNotFound(error)) {
+ return (
+ <Tooltip label={missing} withArrow>
+ <span role="img" aria-label={missing}>
+ <IconWarning />
+ </span>
+ </Tooltip>
+ );
+ }
+ // In flight, or a failure that is not a 404: we do not know that the
+ // reference is broken, so we do not say so. A refetch that fails this
+ // way after a successful resolve keeps react-query's cached `data`, so
+ // the link stays — last known good beats blanking a working link.
+ if (!data) return null;
+ return (
+ <Tooltip label={label} withArrow>
+ <ResourceRefLink to={to} params={{ id }} aria-label={label}>
+ <IconGoTo />
+ </ResourceRefLink>
+ </Tooltip>
+ );
+ })();
+
+ return (
+ <FormItemTextInput
+ {...inputProps}
+ rightSection={rightSection}
+ // Mantine sets `pointer-events: none` on input sections by default,
+ // which would render the link and then swallow every click on it.
+ rightSectionPointerEvents="all"
+ />
+ );
+};
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index ee9724bdc..c5a89eb8f 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -75,6 +75,11 @@
"content": "Inhalt",
"contentPlaceholder": "Datei {{fileTypes}} einfügen oder hochladen"
},
+ "ref": {
+ "view": "{{name}} anzeigen",
+ "viewNamed": "{{resource}} anzeigen: {{name}}",
+ "missing": "{{name}} mit dieser ID existiert nicht"
+ },
"routes": {
"enableWebsocket": "WebSocket aktivieren",
"filterFunc": "Filterfunktion",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 5dcc698f4..7271cdfef 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -75,6 +75,11 @@
"content": "Content",
"contentPlaceholder": "Paste or upload {{fileTypes}} file"
},
+ "ref": {
+ "view": "View {{name}}",
+ "viewNamed": "View {{resource}}: {{name}}",
+ "missing": "No {{name}} with this ID"
+ },
"routes": {
"enableWebsocket": "Enable WebSocket",
"filterFunc": "Filter Func",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 559c55d82..a0c7c8fe0 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -75,6 +75,11 @@
"content": "Contenido",
"contentPlaceholder": "Pegar o subir archivo {{fileTypes}}"
},
+ "ref": {
+ "view": "Ver {{name}}",
+ "viewNamed": "Ver {{resource}}: {{name}}",
+ "missing": "{{name}} con este ID no existe"
+ },
"routes": {
"enableWebsocket": "Habilitar WebSocket",
"filterFunc": "Función de Filtro",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index bf257a267..47e74bc5a 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -75,6 +75,11 @@
"content": "İçerik",
"contentPlaceholder": "{{fileTypes}} dosyasını yapıştırın veya yükleyin"
},
+ "ref": {
+ "view": "{{name}} görüntüle",
+ "viewNamed": "{{resource}} görüntüle: {{name}}",
+ "missing": "Bu ID'ye sahip {{name}} yok"
+ },
"routes": {
"enableWebsocket": "WebSocket Aktif",
"filterFunc": "Filter Fonksiyonu",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index c16f67d41..9f5714f18 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -75,6 +75,11 @@
"content": "内容",
"contentPlaceholder": "粘贴或上传 {{fileTypes}} 文件"
},
+ "ref": {
+ "view": "查看{{name}}",
+ "viewNamed": "查看{{resource}}:{{name}}",
+ "missing": "没有此 ID 的{{name}}"
+ },
"routes": {
"enableWebsocket": "启用WebSocket",
"filterFunc": "过滤函数",