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 `&amp;` 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&amp;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 "&amp;" 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": "过滤函数",

Reply via email to