@cosmicdrift/kumiko-renderer 0.237.2 → 0.239.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +147 -0
- package/src/app/__tests__/projection-list-reference-column.test.tsx +161 -0
- package/src/components/render-field.tsx +8 -0
- package/src/hooks/use-reference-lookup.ts +8 -1
- package/src/i18n-defaults.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.239.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.239.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.239.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.239.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// fw#2662: projectionDetail fields have no EntityDefinition to carry a real
|
|
2
|
+
// "reference" field type (the shim hardcodes every field as "text"), so a
|
|
3
|
+
// declared reference field used to render the raw id. This renders the real
|
|
4
|
+
// path (KumikoScreen -> ProjectionDetailScreen -> RenderEdit -> RenderField
|
|
5
|
+
// -> ReadOnlyReferenceValue) under a stub dispatcher.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
10
|
+
import { render, waitFor } from "@testing-library/react";
|
|
11
|
+
import type { ComponentType, ReactNode } from "react";
|
|
12
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
13
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
14
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
15
|
+
import { type CorePrimitives, PrimitivesProvider, type TextProps } from "../../primitives";
|
|
16
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
17
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
18
|
+
import type { NavApi } from "../nav";
|
|
19
|
+
import { NavProvider } from "../nav";
|
|
20
|
+
|
|
21
|
+
const noop = (): ReactNode => null;
|
|
22
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
23
|
+
// passChildren would drop testId — ReadOnlyReferenceValue/readOnlyDisplayText
|
|
24
|
+
// render their resolved text into `<Text testId="field-value-<field>">`, and
|
|
25
|
+
// the assertions below read it back by that testid.
|
|
26
|
+
const TestText: ComponentType<TextProps> = ({ children, testId }) => (
|
|
27
|
+
<span data-testid={testId}>{children}</span>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const testPrimitives: CorePrimitives = {
|
|
31
|
+
Button: noop,
|
|
32
|
+
Banner: passChildren,
|
|
33
|
+
Field: passChildren,
|
|
34
|
+
Input: noop,
|
|
35
|
+
DataTable: noop,
|
|
36
|
+
Form: passChildren,
|
|
37
|
+
Section: passChildren,
|
|
38
|
+
Card: passChildren,
|
|
39
|
+
Grid: passChildren,
|
|
40
|
+
GridCell: passChildren,
|
|
41
|
+
Text: TestText,
|
|
42
|
+
Heading: noop,
|
|
43
|
+
Dialog: noop,
|
|
44
|
+
Modal: noop,
|
|
45
|
+
Lightbox: noop,
|
|
46
|
+
ConfigSourceBadge: noop,
|
|
47
|
+
ConfigCascadeView: noop,
|
|
48
|
+
Link: noop,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function stubDispatcher(): Dispatcher {
|
|
52
|
+
return {
|
|
53
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
54
|
+
query: (async (type: string) => {
|
|
55
|
+
if (type === "sessions:query:session:detail") {
|
|
56
|
+
return {
|
|
57
|
+
isSuccess: true,
|
|
58
|
+
data: { id: "sess-1", userId: "u-1", ip: "10.0.0.1" },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (type === "user:query:user:list") {
|
|
62
|
+
return {
|
|
63
|
+
isSuccess: true,
|
|
64
|
+
data: { rows: [{ id: "u-1", displayName: "Jane Doe" }], nextCursor: null },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
68
|
+
}) as unknown as Dispatcher["query"],
|
|
69
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
70
|
+
statusStore: {
|
|
71
|
+
getState: () => "online",
|
|
72
|
+
subscribe: () => () => {},
|
|
73
|
+
} as unknown as Dispatcher["statusStore"],
|
|
74
|
+
async *stream() {},
|
|
75
|
+
pendingWrites: () => [],
|
|
76
|
+
pendingFiles: () => [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
81
|
+
id: "session-detail",
|
|
82
|
+
type: "projectionDetail",
|
|
83
|
+
query: "sessions:query:session:detail",
|
|
84
|
+
layout: {
|
|
85
|
+
sections: [
|
|
86
|
+
{
|
|
87
|
+
fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }, "ip"],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function buildSchema(): FeatureSchema {
|
|
94
|
+
return {
|
|
95
|
+
featureName: "sessions",
|
|
96
|
+
entities: {},
|
|
97
|
+
screens: [detailScreen],
|
|
98
|
+
} as FeatureSchema;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const staticNav: NavApi = {
|
|
102
|
+
route: { screenId: "sessions:screen:session-detail" },
|
|
103
|
+
navigate: () => {},
|
|
104
|
+
replace: () => {},
|
|
105
|
+
hrefFor: () => "",
|
|
106
|
+
searchParams: {},
|
|
107
|
+
setSearchParams: () => {},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function renderDetailScreen(): ReturnType<typeof render> {
|
|
111
|
+
return render(
|
|
112
|
+
<LocaleProvider
|
|
113
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
114
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
115
|
+
>
|
|
116
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
117
|
+
<NavProvider value={staticNav}>
|
|
118
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
119
|
+
<KumikoScreen
|
|
120
|
+
schema={buildSchema()}
|
|
121
|
+
qn="sessions:screen:session-detail"
|
|
122
|
+
entityId="sess-1"
|
|
123
|
+
/>
|
|
124
|
+
</PrimitivesProvider>
|
|
125
|
+
</NavProvider>
|
|
126
|
+
</DispatcherProvider>
|
|
127
|
+
</LocaleProvider>,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("projectionDetail reference field resolves labels (fw#2662)", () => {
|
|
132
|
+
test("a field with refEntity metadata shows the resolved display name instead of the raw id", async () => {
|
|
133
|
+
const { getByTestId } = renderDetailScreen();
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(getByTestId("field-value-userId").textContent).toBe("Jane Doe");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("non-regression: a field without refEntity metadata renders its raw value unchanged", async () => {
|
|
141
|
+
const { getByTestId } = renderDetailScreen();
|
|
142
|
+
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(getByTestId("field-value-ip").textContent).toBe("10.0.0.1");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// fw#2662: projectionList columns have no EntityDefinition to carry a real
|
|
2
|
+
// "reference" field type (the shim hardcodes every field as "text"), so a
|
|
3
|
+
// declared reference column used to render the raw GUID. This renders the
|
|
4
|
+
// real path (KumikoScreen -> ProjectionListScreen -> RenderList ->
|
|
5
|
+
// useReferenceLookup) under a stub dispatcher, and reads the resolved cell
|
|
6
|
+
// text back off the reference column's injected runtime renderer.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
|
+
import type { Dispatcher, RuntimeRenderer } from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import { render, waitFor } from "@testing-library/react";
|
|
12
|
+
import type { ComponentType, ReactNode } from "react";
|
|
13
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
14
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
15
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
16
|
+
import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
|
|
17
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
18
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
19
|
+
import type { NavApi } from "../nav";
|
|
20
|
+
import { NavProvider } from "../nav";
|
|
21
|
+
|
|
22
|
+
const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000";
|
|
23
|
+
const REAL_TENANT_ID = "11111111-1111-4111-8111-111111111111";
|
|
24
|
+
|
|
25
|
+
let capturedProps: DataTableProps | undefined;
|
|
26
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
27
|
+
capturedProps = props;
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
const getCapturedProps = (): DataTableProps | undefined => capturedProps;
|
|
31
|
+
const noop = (): ReactNode => null;
|
|
32
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
33
|
+
|
|
34
|
+
const testPrimitives: CorePrimitives = {
|
|
35
|
+
Button: noop,
|
|
36
|
+
Banner: passChildren,
|
|
37
|
+
Field: passChildren,
|
|
38
|
+
Input: noop,
|
|
39
|
+
DataTable: captureDataTable,
|
|
40
|
+
Form: passChildren,
|
|
41
|
+
Section: passChildren,
|
|
42
|
+
Card: passChildren,
|
|
43
|
+
Grid: passChildren,
|
|
44
|
+
GridCell: passChildren,
|
|
45
|
+
Text: passChildren,
|
|
46
|
+
Heading: noop,
|
|
47
|
+
Dialog: noop,
|
|
48
|
+
Modal: noop,
|
|
49
|
+
Lightbox: noop,
|
|
50
|
+
ConfigSourceBadge: noop,
|
|
51
|
+
ConfigCascadeView: noop,
|
|
52
|
+
Link: noop,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function stubDispatcher(): Dispatcher {
|
|
56
|
+
return {
|
|
57
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
58
|
+
query: (async (type: string) => {
|
|
59
|
+
if (type === "delivery:query:log:list") {
|
|
60
|
+
return {
|
|
61
|
+
isSuccess: true,
|
|
62
|
+
data: {
|
|
63
|
+
rows: [
|
|
64
|
+
{ id: "row-1", tenantId: REAL_TENANT_ID },
|
|
65
|
+
{ id: "row-2", tenantId: SYSTEM_TENANT_ID },
|
|
66
|
+
],
|
|
67
|
+
nextCursor: null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (type === "tenant:query:tenant:list") {
|
|
72
|
+
return {
|
|
73
|
+
isSuccess: true,
|
|
74
|
+
data: { rows: [{ id: REAL_TENANT_ID, name: "Acme Inc" }], nextCursor: null },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
78
|
+
}) as unknown as Dispatcher["query"],
|
|
79
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
80
|
+
statusStore: {
|
|
81
|
+
getState: () => "online",
|
|
82
|
+
subscribe: () => () => {},
|
|
83
|
+
} as unknown as Dispatcher["statusStore"],
|
|
84
|
+
async *stream() {},
|
|
85
|
+
pendingWrites: () => [],
|
|
86
|
+
pendingFiles: () => [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
|
|
91
|
+
return {
|
|
92
|
+
featureName: "delivery",
|
|
93
|
+
entities: {},
|
|
94
|
+
screens: [screen],
|
|
95
|
+
} as FeatureSchema;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const staticNav: NavApi = {
|
|
99
|
+
route: { screenId: "delivery:screen:log" },
|
|
100
|
+
navigate: () => {},
|
|
101
|
+
replace: () => {},
|
|
102
|
+
hrefFor: () => "",
|
|
103
|
+
searchParams: {},
|
|
104
|
+
setSearchParams: () => {},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function renderLogScreen(): void {
|
|
108
|
+
const screen: ProjectionListScreenDefinition = {
|
|
109
|
+
id: "log",
|
|
110
|
+
type: "projectionList",
|
|
111
|
+
query: "delivery:query:log:list",
|
|
112
|
+
columns: [
|
|
113
|
+
{
|
|
114
|
+
field: "tenantId",
|
|
115
|
+
label: "delivery.log.col.tenantId",
|
|
116
|
+
refEntity: "tenant:tenant",
|
|
117
|
+
refLabelField: "name",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
render(
|
|
122
|
+
<LocaleProvider
|
|
123
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
124
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
125
|
+
>
|
|
126
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
127
|
+
<NavProvider value={staticNav}>
|
|
128
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
129
|
+
<KumikoScreen schema={buildSchema(screen)} qn="delivery:screen:log" />
|
|
130
|
+
</PrimitivesProvider>
|
|
131
|
+
</NavProvider>
|
|
132
|
+
</DispatcherProvider>
|
|
133
|
+
</LocaleProvider>,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function referenceRenderer(): RuntimeRenderer {
|
|
138
|
+
const col = getCapturedProps()?.columns.find((c) => c.field === "tenantId");
|
|
139
|
+
if (typeof col?.renderer !== "function") throw new Error("reference column has no renderer yet");
|
|
140
|
+
return col.renderer as RuntimeRenderer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("projectionList reference column resolves labels (fw#2662)", () => {
|
|
144
|
+
test("a real tenant id resolves to its name instead of the raw GUID", async () => {
|
|
145
|
+
capturedProps = undefined;
|
|
146
|
+
renderLogScreen();
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(referenceRenderer()(REAL_TENANT_ID, { tenantId: REAL_TENANT_ID })).toBe("Acme Inc");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("SYSTEM_TENANT_ID resolves to the system label instead of the raw GUID", async () => {
|
|
154
|
+
capturedProps = undefined;
|
|
155
|
+
renderLogScreen();
|
|
156
|
+
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
expect(referenceRenderer()(SYSTEM_TENANT_ID, { tenantId: SYSTEM_TENANT_ID })).toBe("System");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type FieldIconKey,
|
|
4
4
|
type FieldRenderer,
|
|
5
5
|
isFormatSpec,
|
|
6
|
+
SYSTEM_REFERENCE_LABELS,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
8
|
import {
|
|
8
9
|
applyFormatSpec,
|
|
@@ -403,6 +404,7 @@ function ReadOnlyReferenceValue({
|
|
|
403
404
|
readonly featureName: string;
|
|
404
405
|
}): ReactNode {
|
|
405
406
|
const { Text } = usePrimitives();
|
|
407
|
+
const t = useTranslation();
|
|
406
408
|
const refEntity = field.refEntity ?? "";
|
|
407
409
|
const refFeature = field.refFeature ?? featureName;
|
|
408
410
|
const labelField = field.refLabelField ?? "id";
|
|
@@ -420,7 +422,13 @@ function ReadOnlyReferenceValue({
|
|
|
420
422
|
: [];
|
|
421
423
|
if (ids.length === 0) return <Text testId={`field-value-${field.field}`}>—</Text>;
|
|
422
424
|
const rows = queryResult.data?.rows ?? [];
|
|
425
|
+
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
|
|
426
|
+
// same central fallback as useReferenceLookup (render-list.tsx), so a
|
|
427
|
+
// reference field showing one on the detail path also gets a label
|
|
428
|
+
// instead of the raw id.
|
|
429
|
+
const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
|
|
423
430
|
const labels = ids.map((id) => {
|
|
431
|
+
if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
|
|
424
432
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
425
433
|
return row !== undefined ? String(row[labelField] ?? id) : id;
|
|
426
434
|
});
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
// internen Cache shared zwischen List + Edit-Form für die gleiche
|
|
18
18
|
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
19
|
|
|
20
|
+
import { SYSTEM_REFERENCE_LABELS } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
21
|
import { useMemo } from "react";
|
|
21
22
|
import { toKebab } from "../app/qn";
|
|
23
|
+
import { useTranslation } from "../i18n";
|
|
22
24
|
import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
|
|
23
25
|
import { useQuery } from "./use-query";
|
|
24
26
|
|
|
@@ -40,6 +42,8 @@ export function useReferenceLookup(
|
|
|
40
42
|
const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
41
43
|
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
42
44
|
});
|
|
45
|
+
const translate = useTranslation();
|
|
46
|
+
const systemLabel = SYSTEM_REFERENCE_LABELS[`${featureName}:${refEntity}`];
|
|
43
47
|
const map = useMemo(() => {
|
|
44
48
|
const out = new Map<string, string>();
|
|
45
49
|
for (const row of result.data?.rows ?? []) {
|
|
@@ -49,7 +53,10 @@ export function useReferenceLookup(
|
|
|
49
53
|
const label = row[labelField] ?? id;
|
|
50
54
|
out.set(idStr, String(label));
|
|
51
55
|
}
|
|
56
|
+
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
|
|
57
|
+
// the bulk lookup above never covers them — inject the label directly.
|
|
58
|
+
if (systemLabel !== undefined) out.set(systemLabel.id, translate(systemLabel.labelKey));
|
|
52
59
|
return out;
|
|
53
|
-
}, [result.data, labelField]);
|
|
60
|
+
}, [result.data, labelField, systemLabel, translate]);
|
|
54
61
|
return { map, loading: result.loading };
|
|
55
62
|
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -64,6 +64,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
64
64
|
"kumiko.list.end-of-list": "— End of list —",
|
|
65
65
|
"kumiko.list.sort.label": "Sort",
|
|
66
66
|
"kumiko.list.sort.unsorted": "Unsorted",
|
|
67
|
+
"kumiko.reference.system-tenant": "System",
|
|
67
68
|
|
|
68
69
|
"kumiko.pager.status": "{from}–{to} of {total}",
|
|
69
70
|
"kumiko.pager.previousPage": "Previous page",
|