feat(10-02): backend strings, controller messages and declarative toolbar
- phrasebook ships the backend::lang admin strings (pl, en) with CLDR plural maps, loads them as namespace backend, applies pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml) after every namespace, and fails activation when a backend key cannot convert to plural forms - Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps - Public GET /lang returns every backend::lang key for the request locale over the fallback locale, Cache-Control no-cache - config_list, config_form and config_relation accept a strict messages block; omitted keys take framework defaults, schemas serve every message as CLDR forms, and activation fails on a missing phrase key - toolbar.buttons is an ordered [create, delete] list; the Winter string form, duplicates, unknown actions and delete without showCheckboxes fail at boot, and create is dropped when the controller has no form - Form schema serves the raw Winter redirects; scaffold emits the list syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
@@ -154,6 +154,36 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-cabana_FormView": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormView"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.Envelope-cabana_LangBundle": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.LangBundle"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.Envelope-cabana_ListSchema": {
|
"cabana.Envelope-cabana_ListSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -169,6 +199,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-cabana_RelationSchema": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationSchema"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.ErrorBody": {
|
"cabana.ErrorBody": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
@@ -200,6 +245,99 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.FormField": {
|
||||||
|
"properties": {
|
||||||
|
"attributes": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/components/schemas/cabana.jsonScalar"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"$ref": "#/components/schemas/cabana.fieldContext"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"$ref": "#/components/schemas/cabana.jsonScalar"
|
||||||
|
},
|
||||||
|
"emptyOption": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"multiple": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nameFrom": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormOption"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"readOnly": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"relation": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.FormMessages": {
|
||||||
|
"properties": {
|
||||||
|
"create": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"deleted": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"saved": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"create",
|
||||||
|
"deleteConfirm",
|
||||||
|
"deleted",
|
||||||
|
"saved",
|
||||||
|
"update"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.FormMeta": {
|
"cabana.FormMeta": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"locale": {
|
"locale": {
|
||||||
@@ -226,6 +364,88 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.FormRedirect": {
|
||||||
|
"properties": {
|
||||||
|
"redirect": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"redirectClose": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"redirect",
|
||||||
|
"redirectClose"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.FormRedirects": {
|
||||||
|
"properties": {
|
||||||
|
"create": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormRedirect"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormRedirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"create",
|
||||||
|
"default",
|
||||||
|
"update"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.FormView": {
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormField"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.FormMessages"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Messages is the form's copy resolved in the request locale (D-13)."
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.FormMeta"
|
||||||
|
},
|
||||||
|
"modelClass": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"redirects": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.FormRedirects"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Redirects are the raw Winter config_form.yaml targets; the SPA maps\nthem onto its routes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fields",
|
||||||
|
"messages",
|
||||||
|
"meta",
|
||||||
|
"redirects"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.LangBundle": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.ListColumn": {
|
"cabana.ListColumn": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": {
|
"key": {
|
||||||
@@ -337,6 +557,53 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.ListMessages": {
|
||||||
|
"properties": {
|
||||||
|
"create": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"deleteSelected": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"deleted": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"emptySearch": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"emptySearchHint": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"recordCount": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"searchPrompt": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"selected": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"create",
|
||||||
|
"deleteConfirm",
|
||||||
|
"deleteSelected",
|
||||||
|
"deleted",
|
||||||
|
"empty",
|
||||||
|
"emptySearch",
|
||||||
|
"emptySearchHint",
|
||||||
|
"recordCount",
|
||||||
|
"searchPrompt",
|
||||||
|
"selected"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.ListMeta": {
|
"cabana.ListMeta": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"last_page": {
|
"last_page": {
|
||||||
@@ -383,6 +650,14 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"messages": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.ListMessages"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Messages is the list's copy (D-13). The cached schema carries each\nphrase key as its own form; a response resolves them in its locale."
|
||||||
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"$ref": "#/components/schemas/cabana.FormMeta"
|
"$ref": "#/components/schemas/cabana.FormMeta"
|
||||||
},
|
},
|
||||||
@@ -442,6 +717,7 @@
|
|||||||
"bulkActions",
|
"bulkActions",
|
||||||
"columns",
|
"columns",
|
||||||
"filters",
|
"filters",
|
||||||
|
"messages",
|
||||||
"perPageOptions",
|
"perPageOptions",
|
||||||
"recordsPerPage",
|
"recordsPerPage",
|
||||||
"rowActions",
|
"rowActions",
|
||||||
@@ -469,6 +745,12 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.MessageForms": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.NavigationEntry": {
|
"cabana.NavigationEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
@@ -535,6 +817,82 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.RelationColumn": {
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"searchable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sortable": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"label",
|
||||||
|
"searchable",
|
||||||
|
"sortable"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.RelationList": {
|
||||||
|
"properties": {
|
||||||
|
"columns": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationColumn"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"columns"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.RelationMessages": {
|
||||||
|
"properties": {
|
||||||
|
"candidateSearch": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"linkHint": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"linked": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"unlinkConfirm": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"unlinkSelected": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
},
|
||||||
|
"unlinked": {
|
||||||
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"candidateSearch",
|
||||||
|
"empty",
|
||||||
|
"link",
|
||||||
|
"linkHint",
|
||||||
|
"linked",
|
||||||
|
"unlinkConfirm",
|
||||||
|
"unlinkSelected",
|
||||||
|
"unlinked"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.RelationOption": {
|
"cabana.RelationOption": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": {
|
"label": {
|
||||||
@@ -550,6 +908,60 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.RelationPanel": {
|
||||||
|
"properties": {
|
||||||
|
"list": {
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationList"
|
||||||
|
},
|
||||||
|
"showSearch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"toolbarButtons": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"list",
|
||||||
|
"showSearch",
|
||||||
|
"toolbarButtons"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cabana.RelationSchema": {
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationPanel"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationMessages"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Messages is the relation manager's copy (D-13); the cached schema\ncarries each phrase key as its own form."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"$ref": "#/components/schemas/cabana.RelationPanel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"manage",
|
||||||
|
"messages",
|
||||||
|
"name",
|
||||||
|
"view"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.RowAction": {
|
"cabana.RowAction": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": {
|
"label": {
|
||||||
@@ -600,6 +1012,19 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.fieldContext": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"cabana.jsonScalar": {
|
"cabana.jsonScalar": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
@@ -803,6 +1228,27 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/lang": {
|
||||||
|
"get": {
|
||||||
|
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_LangBundle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Admin UI strings",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/navigation": {
|
"/navigation": {
|
||||||
"get": {
|
"get": {
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1544,7 +1990,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_FormView"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1721,7 +2167,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_RelationSchema"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
164
admin/src/api/schema.d.ts
vendored
164
admin/src/api/schema.d.ts
vendored
@@ -204,6 +204,45 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/lang": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Admin UI strings
|
||||||
|
* @description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_LangBundle"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/navigation": {
|
"/navigation": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -823,7 +862,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_FormView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -965,7 +1004,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_RelationSchema"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1523,10 +1562,22 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.AdminProfile"];
|
data: components["schemas"]["cabana.AdminProfile"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_FormView": {
|
||||||
|
data: components["schemas"]["cabana.FormView"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
|
"cabana.Envelope-cabana_LangBundle": {
|
||||||
|
data: components["schemas"]["cabana.LangBundle"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_ListSchema": {
|
"cabana.Envelope-cabana_ListSchema": {
|
||||||
data: components["schemas"]["cabana.ListSchema"];
|
data: components["schemas"]["cabana.ListSchema"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_RelationSchema": {
|
||||||
|
data: components["schemas"]["cabana.RelationSchema"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.ErrorBody": {
|
"cabana.ErrorBody": {
|
||||||
code: string;
|
code: string;
|
||||||
details: {
|
details: {
|
||||||
@@ -1537,6 +1588,34 @@ export interface components {
|
|||||||
"cabana.ErrorEnvelope": {
|
"cabana.ErrorEnvelope": {
|
||||||
error: components["schemas"]["cabana.ErrorBody"];
|
error: components["schemas"]["cabana.ErrorBody"];
|
||||||
};
|
};
|
||||||
|
"cabana.FormField": {
|
||||||
|
attributes?: {
|
||||||
|
[key: string]: components["schemas"]["cabana.jsonScalar"];
|
||||||
|
};
|
||||||
|
comment?: string;
|
||||||
|
context?: components["schemas"]["cabana.fieldContext"];
|
||||||
|
default?: components["schemas"]["cabana.jsonScalar"];
|
||||||
|
emptyOption?: string;
|
||||||
|
label?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
name: string;
|
||||||
|
nameFrom?: string;
|
||||||
|
options?: components["schemas"]["cabana.FormOption"][];
|
||||||
|
readOnly?: boolean;
|
||||||
|
relation?: string;
|
||||||
|
required?: boolean;
|
||||||
|
size?: string;
|
||||||
|
span?: string;
|
||||||
|
tab?: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
"cabana.FormMessages": {
|
||||||
|
create: components["schemas"]["cabana.MessageForms"];
|
||||||
|
deleteConfirm: components["schemas"]["cabana.MessageForms"];
|
||||||
|
deleted: components["schemas"]["cabana.MessageForms"];
|
||||||
|
saved: components["schemas"]["cabana.MessageForms"];
|
||||||
|
update: components["schemas"]["cabana.MessageForms"];
|
||||||
|
};
|
||||||
"cabana.FormMeta": {
|
"cabana.FormMeta": {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
@@ -1544,6 +1623,31 @@ export interface components {
|
|||||||
label: string;
|
label: string;
|
||||||
value: components["schemas"]["cabana.jsonScalar"];
|
value: components["schemas"]["cabana.jsonScalar"];
|
||||||
};
|
};
|
||||||
|
"cabana.FormRedirect": {
|
||||||
|
redirect: string;
|
||||||
|
redirectClose: string;
|
||||||
|
};
|
||||||
|
"cabana.FormRedirects": {
|
||||||
|
create: components["schemas"]["cabana.FormRedirect"];
|
||||||
|
default: string;
|
||||||
|
update: components["schemas"]["cabana.FormRedirect"];
|
||||||
|
};
|
||||||
|
"cabana.FormView": {
|
||||||
|
fields: components["schemas"]["cabana.FormField"][];
|
||||||
|
/** @description Messages is the form's copy resolved in the request locale (D-13). */
|
||||||
|
messages: components["schemas"]["cabana.FormMessages"];
|
||||||
|
meta: components["schemas"]["cabana.FormMeta"];
|
||||||
|
modelClass?: string;
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* @description Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
||||||
|
* them onto its routes.
|
||||||
|
*/
|
||||||
|
redirects: components["schemas"]["cabana.FormRedirects"];
|
||||||
|
};
|
||||||
|
"cabana.LangBundle": {
|
||||||
|
[key: string]: components["schemas"]["cabana.MessageForms"];
|
||||||
|
};
|
||||||
"cabana.ListColumn": {
|
"cabana.ListColumn": {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -1575,6 +1679,18 @@ export interface components {
|
|||||||
trueValue?: components["schemas"]["cabana.jsonScalar"];
|
trueValue?: components["schemas"]["cabana.jsonScalar"];
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
"cabana.ListMessages": {
|
||||||
|
create: components["schemas"]["cabana.MessageForms"];
|
||||||
|
deleteConfirm: components["schemas"]["cabana.MessageForms"];
|
||||||
|
deleteSelected: components["schemas"]["cabana.MessageForms"];
|
||||||
|
deleted: components["schemas"]["cabana.MessageForms"];
|
||||||
|
empty: components["schemas"]["cabana.MessageForms"];
|
||||||
|
emptySearch: components["schemas"]["cabana.MessageForms"];
|
||||||
|
emptySearchHint: components["schemas"]["cabana.MessageForms"];
|
||||||
|
recordCount: components["schemas"]["cabana.MessageForms"];
|
||||||
|
searchPrompt: components["schemas"]["cabana.MessageForms"];
|
||||||
|
selected: components["schemas"]["cabana.MessageForms"];
|
||||||
|
};
|
||||||
"cabana.ListMeta": {
|
"cabana.ListMeta": {
|
||||||
last_page: number;
|
last_page: number;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -1586,6 +1702,11 @@ export interface components {
|
|||||||
columns: components["schemas"]["cabana.ListColumn"][];
|
columns: components["schemas"]["cabana.ListColumn"][];
|
||||||
defaultSort?: components["schemas"]["cabana.ListSort"];
|
defaultSort?: components["schemas"]["cabana.ListSort"];
|
||||||
filters: components["schemas"]["cabana.ListFilter"][];
|
filters: components["schemas"]["cabana.ListFilter"][];
|
||||||
|
/**
|
||||||
|
* @description Messages is the list's copy (D-13). The cached schema carries each
|
||||||
|
* phrase key as its own form; a response resolves them in its locale.
|
||||||
|
*/
|
||||||
|
messages: components["schemas"]["cabana.ListMessages"];
|
||||||
meta?: components["schemas"]["cabana.FormMeta"];
|
meta?: components["schemas"]["cabana.FormMeta"];
|
||||||
modelClass?: string;
|
modelClass?: string;
|
||||||
noRecordsMessage?: string;
|
noRecordsMessage?: string;
|
||||||
@@ -1606,6 +1727,9 @@ export interface components {
|
|||||||
column: string;
|
column: string;
|
||||||
direction: string;
|
direction: string;
|
||||||
};
|
};
|
||||||
|
"cabana.MessageForms": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
"cabana.NavigationEntry": {
|
"cabana.NavigationEntry": {
|
||||||
code: string;
|
code: string;
|
||||||
controller: string;
|
controller: string;
|
||||||
@@ -1623,10 +1747,45 @@ export interface components {
|
|||||||
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"cabana.RelationColumn": {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
searchable: boolean;
|
||||||
|
sortable: boolean;
|
||||||
|
};
|
||||||
|
"cabana.RelationList": {
|
||||||
|
columns: components["schemas"]["cabana.RelationColumn"][];
|
||||||
|
};
|
||||||
|
"cabana.RelationMessages": {
|
||||||
|
candidateSearch: components["schemas"]["cabana.MessageForms"];
|
||||||
|
empty: components["schemas"]["cabana.MessageForms"];
|
||||||
|
link: components["schemas"]["cabana.MessageForms"];
|
||||||
|
linkHint: components["schemas"]["cabana.MessageForms"];
|
||||||
|
linked: components["schemas"]["cabana.MessageForms"];
|
||||||
|
unlinkConfirm: components["schemas"]["cabana.MessageForms"];
|
||||||
|
unlinkSelected: components["schemas"]["cabana.MessageForms"];
|
||||||
|
unlinked: components["schemas"]["cabana.MessageForms"];
|
||||||
|
};
|
||||||
"cabana.RelationOption": {
|
"cabana.RelationOption": {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
"cabana.RelationPanel": {
|
||||||
|
list: components["schemas"]["cabana.RelationList"];
|
||||||
|
showSearch: boolean;
|
||||||
|
toolbarButtons: string[];
|
||||||
|
};
|
||||||
|
"cabana.RelationSchema": {
|
||||||
|
label: string;
|
||||||
|
manage: components["schemas"]["cabana.RelationPanel"];
|
||||||
|
/**
|
||||||
|
* @description Messages is the relation manager's copy (D-13); the cached schema
|
||||||
|
* carries each phrase key as its own form.
|
||||||
|
*/
|
||||||
|
messages: components["schemas"]["cabana.RelationMessages"];
|
||||||
|
name: string;
|
||||||
|
view: components["schemas"]["cabana.RelationPanel"];
|
||||||
|
};
|
||||||
"cabana.RowAction": {
|
"cabana.RowAction": {
|
||||||
label?: string;
|
label?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1643,6 +1802,7 @@ export interface components {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
"cabana.fieldContext": string | string[];
|
||||||
"cabana.jsonScalar": (string | number | boolean) | null;
|
"cabana.jsonScalar": (string | number | boolean) | null;
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ func AdminLogin() {}
|
|||||||
// @Router /auth/refresh [post]
|
// @Router /auth/refresh [post]
|
||||||
func AdminRefresh() {}
|
func AdminRefresh() {}
|
||||||
|
|
||||||
|
// LangBundle is the public string bundle: full backend::lang key to CLDR
|
||||||
|
// forms (D-20).
|
||||||
|
type LangBundle map[string]MessageForms
|
||||||
|
|
||||||
|
// AdminLang documents GET /lang.
|
||||||
|
//
|
||||||
|
// @Summary Admin UI strings
|
||||||
|
// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} Envelope[LangBundle]
|
||||||
|
// @Router /lang [get]
|
||||||
|
func AdminLang() {}
|
||||||
|
|
||||||
// AdminLogout documents POST /auth/logout.
|
// AdminLogout documents POST /auth/logout.
|
||||||
//
|
//
|
||||||
// @Summary Admin logout
|
// @Summary Admin logout
|
||||||
@@ -233,7 +247,7 @@ func AdminListSchema() {}
|
|||||||
// @Param vendor path string true "Vendor"
|
// @Param vendor path string true "Vendor"
|
||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[FormView]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
@@ -250,7 +264,7 @@ func AdminFormSchema() {}
|
|||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[RelationSchema]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type formConfigDocument struct {
|
type formConfigDocument struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Form string `yaml:"form"`
|
Form string `yaml:"form"`
|
||||||
ModelClass string `yaml:"modelClass"`
|
ModelClass string `yaml:"modelClass"`
|
||||||
DefaultRedirect string `yaml:"defaultRedirect"`
|
DefaultRedirect string `yaml:"defaultRedirect"`
|
||||||
Create *formRedirects `yaml:"create"`
|
Create *formRedirects `yaml:"create"`
|
||||||
Update *formRedirects `yaml:"update"`
|
Update *formRedirects `yaml:"update"`
|
||||||
|
Messages *formMessageKeys `yaml:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type formRedirects struct {
|
type formRedirects struct {
|
||||||
@@ -102,11 +103,22 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
if err := requireDropdownProvider(ctl, fields); err != nil {
|
if err := requireDropdownProvider(ctl, fields); err != nil {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||||
}
|
}
|
||||||
return &FormSchema{
|
schema := &FormSchema{
|
||||||
Name: doc.Name,
|
Name: doc.Name,
|
||||||
ModelClass: doc.ModelClass,
|
ModelClass: doc.ModelClass,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
}, nil
|
redirects: FormRedirects{Default: doc.DefaultRedirect},
|
||||||
|
}
|
||||||
|
if doc.Messages != nil {
|
||||||
|
schema.messageKeys = *doc.Messages
|
||||||
|
}
|
||||||
|
if doc.Create != nil {
|
||||||
|
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
|
||||||
|
}
|
||||||
|
if doc.Update != nil {
|
||||||
|
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
|
||||||
|
}
|
||||||
|
return schema, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Localize copies the cached schema and resolves display strings for one request.
|
// Localize copies the cached schema and resolves display strings for one request.
|
||||||
@@ -141,6 +153,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
|
|||||||
Name: translateKey(ctx, tr, s.Name),
|
Name: translateKey(ctx, tr, s.Name),
|
||||||
ModelClass: s.ModelClass,
|
ModelClass: s.ModelClass,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
|
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
|
||||||
|
Redirects: s.redirects,
|
||||||
Meta: FormMeta{Locale: locale},
|
Meta: FormMeta{Locale: locale},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
|||||||
if app == nil {
|
if app == nil {
|
||||||
return nil, errors.New("cabana: app is nil")
|
return nil, errors.New("cabana: app is nil")
|
||||||
}
|
}
|
||||||
|
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
||||||
|
if err := validateMessageKeys(reg, tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
guards, ok := app.Lookup[*bouncer.Registry]()
|
guards, ok := app.Lookup[*bouncer.Registry]()
|
||||||
if !ok || guards == nil {
|
if !ok || guards == nil {
|
||||||
guards = bouncer.NewRegistry()
|
guards = bouncer.NewRegistry()
|
||||||
@@ -183,6 +188,10 @@ func (s *service) mount(r pact.Router) {
|
|||||||
g.Post("/login", s.login, throttle)
|
g.Post("/login", s.login, throttle)
|
||||||
g.Post("/refresh", requireAjax(s.refresh))
|
g.Post("/refresh", requireAjax(s.refresh))
|
||||||
})
|
})
|
||||||
|
// The string bundle is public: the login screen needs it before auth.
|
||||||
|
r.GroupRaw(api, nil, func(g pact.Router) {
|
||||||
|
g.Get("/lang", s.langBundle)
|
||||||
|
})
|
||||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||||
g.Post("/auth/logout", requireAjax(s.logout))
|
g.Post("/auth/logout", requireAjax(s.logout))
|
||||||
g.Get("/auth/me", s.me)
|
g.Get("/auth/me", s.me)
|
||||||
|
|||||||
24
cabana/lang.go
Normal file
24
cabana/lang.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminLangPrefix is the only namespace the public bundle serves (D-20).
|
||||||
|
const adminLangPrefix = "backend::lang."
|
||||||
|
|
||||||
|
// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR
|
||||||
|
// forms for the request locale, with the fallback locale's keys underneath.
|
||||||
|
// It is public (the login screen needs it) and never includes another
|
||||||
|
// namespace.
|
||||||
|
func (s *service) langBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tr := s.translator()
|
||||||
|
locale := schemaLocale(r.Context(), tr)
|
||||||
|
data := map[string]map[string]string{}
|
||||||
|
if tr != nil {
|
||||||
|
locale = tr.Resolved(locale, adminLangPrefix)
|
||||||
|
data = tr.Bundle(locale, adminLangPrefix)
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale})
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/phrasebook"
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
"git.golem15.com/golem15/summercms/towel"
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/goccy/go-yaml/ast"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ type listDocument struct {
|
|||||||
DefaultSort *listSortDocument `yaml:"defaultSort"`
|
DefaultSort *listSortDocument `yaml:"defaultSort"`
|
||||||
Toolbar *listToolbar `yaml:"toolbar"`
|
Toolbar *listToolbar `yaml:"toolbar"`
|
||||||
Filter string `yaml:"filter"`
|
Filter string `yaml:"filter"`
|
||||||
|
Messages *listMessageKeys `yaml:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type listSortDocument struct {
|
type listSortDocument struct {
|
||||||
@@ -45,7 +47,7 @@ type listSortDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type listToolbar struct {
|
type listToolbar struct {
|
||||||
Buttons string `yaml:"buttons"`
|
Buttons toolbarButtons `yaml:"buttons"`
|
||||||
Search *struct {
|
Search *struct {
|
||||||
Prompt string `yaml:"prompt"`
|
Prompt string `yaml:"prompt"`
|
||||||
Mode string `yaml:"mode"`
|
Mode string `yaml:"mode"`
|
||||||
@@ -123,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
|||||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buttons, err := compileToolbarButtons(doc.Toolbar)
|
buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +156,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &ListSchema{
|
schema := &ListSchema{
|
||||||
Title: doc.Title,
|
Title: doc.Title,
|
||||||
ModelClass: doc.ModelClass,
|
ModelClass: doc.ModelClass,
|
||||||
RecordURL: doc.RecordURL,
|
RecordURL: doc.RecordURL,
|
||||||
@@ -173,7 +175,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
|||||||
Filters: filters,
|
Filters: filters,
|
||||||
RowActions: rowActions,
|
RowActions: rowActions,
|
||||||
BulkActions: bulk,
|
BulkActions: bulk,
|
||||||
}, nil
|
}
|
||||||
|
if doc.Messages != nil {
|
||||||
|
schema.messageKeys = *doc.Messages
|
||||||
|
}
|
||||||
|
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet())
|
||||||
|
schema.Messages = &keys
|
||||||
|
return schema, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
|
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
|
||||||
@@ -270,16 +278,58 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
|
|||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileToolbarButtons(toolbar *listToolbar) ([]string, error) {
|
// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
|
||||||
if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" {
|
// actions in display order. A scalar (Winter's partial name) is rejected
|
||||||
|
// with a pointer at the list syntax.
|
||||||
|
type toolbarButtons struct {
|
||||||
|
items []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
|
||||||
|
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
|
||||||
|
|
||||||
|
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
|
||||||
|
node = unwrapNode(node)
|
||||||
|
switch n := node.(type) {
|
||||||
|
case nil, *ast.NullNode:
|
||||||
|
b.items = nil
|
||||||
|
return nil
|
||||||
|
case *ast.SequenceNode:
|
||||||
|
values := sequenceValues(n)
|
||||||
|
items := make([]string, 0, len(values))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range values {
|
||||||
|
action, err := nodeString(unwrapNode(item))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
|
||||||
|
}
|
||||||
|
if _, ok := toolbarActions[action]; !ok {
|
||||||
|
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
|
||||||
|
}
|
||||||
|
if _, dup := seen[action]; dup {
|
||||||
|
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
|
||||||
|
}
|
||||||
|
seen[action] = struct{}{}
|
||||||
|
items = append(items, action)
|
||||||
|
}
|
||||||
|
b.items = items
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
|
||||||
|
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
switch toolbar.Buttons {
|
out := append([]string(nil), toolbar.Buttons.items...)
|
||||||
case "list_toolbar":
|
for _, action := range out {
|
||||||
return []string{"create"}, nil
|
if action == "delete" && !showCheckboxes {
|
||||||
default:
|
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
|
||||||
return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons)
|
}
|
||||||
}
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
|
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
|
||||||
@@ -391,6 +441,8 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
|
|||||||
out.Filters = localizeListFilters(ctx, tr, s.Filters)
|
out.Filters = localizeListFilters(ctx, tr, s.Filters)
|
||||||
out.RowActions = localizeRowActions(ctx, tr, s.RowActions)
|
out.RowActions = localizeRowActions(ctx, tr, s.RowActions)
|
||||||
out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions)
|
out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions)
|
||||||
|
messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet())
|
||||||
|
out.Messages = &messages
|
||||||
out.Meta = &FormMeta{Locale: locale}
|
out.Meta = &FormMeta{Locale: locale}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,16 @@ defaultSort:
|
|||||||
column: name
|
column: name
|
||||||
direction: asc
|
direction: asc
|
||||||
toolbar:
|
toolbar:
|
||||||
buttons: list_toolbar
|
buttons: [create, delete]
|
||||||
search:
|
search:
|
||||||
prompt: backend::lang.list.search_prompt
|
prompt: backend::lang.list.search_prompt
|
||||||
`
|
`
|
||||||
|
|
||||||
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}]}`
|
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}`
|
||||||
|
|
||||||
|
// defaultListMessagesJSON is a compiled list's messages block when the YAML
|
||||||
|
// declares none: every key is a framework default phrase key (D-13).
|
||||||
|
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
|
||||||
|
|
||||||
func TestListSchemaCompile(t *testing.T) {
|
func TestListSchemaCompile(t *testing.T) {
|
||||||
columns := readListFixture(t, "testdata/list/all_columns.yaml")
|
columns := readListFixture(t, "testdata/list/all_columns.yaml")
|
||||||
@@ -52,7 +56,7 @@ func TestListSchemaCompile(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
|
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
|
||||||
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
|
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
|
||||||
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create"]`, `"filters":[]`,
|
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Fatalf("compiled list missing %s:\n%s", want, got)
|
t.Fatalf("compiled list missing %s:\n%s", want, got)
|
||||||
@@ -68,7 +72,7 @@ noRecordsMessage: backend::lang.list.no_records
|
|||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
showCheckboxes: true
|
showCheckboxes: true
|
||||||
toolbar:
|
toolbar:
|
||||||
buttons: list_toolbar
|
buttons: [create, delete]
|
||||||
search:
|
search:
|
||||||
prompt: backend::lang.list.search_prompt
|
prompt: backend::lang.list.search_prompt
|
||||||
`
|
`
|
||||||
@@ -98,7 +102,7 @@ modelClass: Widget
|
|||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
`
|
`
|
||||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
|
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
|
||||||
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[]}`
|
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
|
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
|
||||||
}
|
}
|
||||||
@@ -115,7 +119,7 @@ modelClass: Widget
|
|||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
`
|
`
|
||||||
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
|
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
|
||||||
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[]}`
|
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
|
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
|
||||||
}
|
}
|
||||||
@@ -217,7 +221,7 @@ func TestListSchemaRejects(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unsupported action",
|
name: "unsupported action",
|
||||||
config: strings.Replace(allColumnsConfig, "buttons: list_toolbar", "buttons: drop_database", 1),
|
config: strings.Replace(allColumnsConfig, "buttons: [create, delete]", "buttons: [create, drop_database]", 1),
|
||||||
columns: columns,
|
columns: columns,
|
||||||
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
|
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
|
||||||
},
|
},
|
||||||
@@ -307,7 +311,7 @@ const filterColumns = `columns:
|
|||||||
searchable: true
|
searchable: true
|
||||||
`
|
`
|
||||||
|
|
||||||
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[]}`
|
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
|
||||||
|
|
||||||
func TestListSchemaFilter(t *testing.T) {
|
func TestListSchemaFilter(t *testing.T) {
|
||||||
filters := readListFixture(t, "testdata/list/all_filters.yaml")
|
filters := readListFixture(t, "testdata/list/all_filters.yaml")
|
||||||
|
|||||||
257
cabana/messages.go
Normal file
257
cabana/messages.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageForms is one controller message resolved to its CLDR plural forms
|
||||||
|
// (D-13). "other" is always present; the SPA picks a form with
|
||||||
|
// Intl.PluralRules and interpolates :count, :name and :term (D-24).
|
||||||
|
type MessageForms map[string]string
|
||||||
|
|
||||||
|
// MarshalJSON keeps an unset message an object, never null.
|
||||||
|
func (m MessageForms) MarshalJSON() ([]byte, error) {
|
||||||
|
if m == nil {
|
||||||
|
return []byte("{}"), nil
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]string(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listMessageKeys is the config_list.yaml messages block: phrase keys.
|
||||||
|
type listMessageKeys struct {
|
||||||
|
RecordCount string `yaml:"recordCount"`
|
||||||
|
Create string `yaml:"create"`
|
||||||
|
SearchPrompt string `yaml:"searchPrompt"`
|
||||||
|
Empty string `yaml:"empty"`
|
||||||
|
EmptySearch string `yaml:"emptySearch"`
|
||||||
|
EmptySearchHint string `yaml:"emptySearchHint"`
|
||||||
|
Selected string `yaml:"selected"`
|
||||||
|
DeleteSelected string `yaml:"deleteSelected"`
|
||||||
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
||||||
|
Deleted string `yaml:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages is a list schema's copy, every key resolved (same field order
|
||||||
|
// as listMessageKeys).
|
||||||
|
type ListMessages struct {
|
||||||
|
RecordCount MessageForms `json:"recordCount"`
|
||||||
|
Create MessageForms `json:"create"`
|
||||||
|
SearchPrompt MessageForms `json:"searchPrompt"`
|
||||||
|
Empty MessageForms `json:"empty"`
|
||||||
|
EmptySearch MessageForms `json:"emptySearch"`
|
||||||
|
EmptySearchHint MessageForms `json:"emptySearchHint"`
|
||||||
|
Selected MessageForms `json:"selected"`
|
||||||
|
DeleteSelected MessageForms `json:"deleteSelected"`
|
||||||
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
||||||
|
Deleted MessageForms `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// formMessageKeys is the config_form.yaml messages block: phrase keys.
|
||||||
|
type formMessageKeys struct {
|
||||||
|
Create string `yaml:"create"`
|
||||||
|
Update string `yaml:"update"`
|
||||||
|
Saved string `yaml:"saved"`
|
||||||
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
||||||
|
Deleted string `yaml:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormMessages is a form's copy, every key resolved.
|
||||||
|
type FormMessages struct {
|
||||||
|
Create MessageForms `json:"create"`
|
||||||
|
Update MessageForms `json:"update"`
|
||||||
|
Saved MessageForms `json:"saved"`
|
||||||
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
||||||
|
Deleted MessageForms `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationMessageKeys is one config_relation.yaml relation's messages block.
|
||||||
|
type relationMessageKeys struct {
|
||||||
|
Link string `yaml:"link"`
|
||||||
|
LinkHint string `yaml:"linkHint"`
|
||||||
|
CandidateSearch string `yaml:"candidateSearch"`
|
||||||
|
Linked string `yaml:"linked"`
|
||||||
|
UnlinkSelected string `yaml:"unlinkSelected"`
|
||||||
|
UnlinkConfirm string `yaml:"unlinkConfirm"`
|
||||||
|
Unlinked string `yaml:"unlinked"`
|
||||||
|
Empty string `yaml:"empty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationMessages is a relation manager's copy, every key resolved.
|
||||||
|
type RelationMessages struct {
|
||||||
|
Link MessageForms `json:"link"`
|
||||||
|
LinkHint MessageForms `json:"linkHint"`
|
||||||
|
CandidateSearch MessageForms `json:"candidateSearch"`
|
||||||
|
Linked MessageForms `json:"linked"`
|
||||||
|
UnlinkSelected MessageForms `json:"unlinkSelected"`
|
||||||
|
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
|
||||||
|
Unlinked MessageForms `json:"unlinked"`
|
||||||
|
Empty MessageForms `json:"empty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework defaults (D-13): every omitted key falls back to backend::lang.
|
||||||
|
var (
|
||||||
|
listMessageDefaults = listMessageKeys{
|
||||||
|
RecordCount: "backend::lang.messages.list.record_count",
|
||||||
|
Create: "backend::lang.messages.list.create",
|
||||||
|
SearchPrompt: "backend::lang.list.search_prompt",
|
||||||
|
Empty: "backend::lang.list.no_records",
|
||||||
|
EmptySearch: "backend::lang.messages.list.empty_search",
|
||||||
|
EmptySearchHint: "backend::lang.messages.list.empty_search_hint",
|
||||||
|
Selected: "backend::lang.messages.list.selected",
|
||||||
|
DeleteSelected: "backend::lang.list.delete_selected",
|
||||||
|
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
|
||||||
|
Deleted: "backend::lang.messages.list.deleted",
|
||||||
|
}
|
||||||
|
formMessageDefaults = formMessageKeys{
|
||||||
|
Create: "backend::lang.messages.form.create",
|
||||||
|
Update: "backend::lang.messages.form.update",
|
||||||
|
Saved: "backend::lang.messages.form.saved",
|
||||||
|
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
|
||||||
|
Deleted: "backend::lang.messages.form.deleted",
|
||||||
|
}
|
||||||
|
relationMessageDefaults = relationMessageKeys{
|
||||||
|
Link: "backend::lang.messages.relation.link",
|
||||||
|
LinkHint: "backend::lang.messages.relation.link_hint",
|
||||||
|
CandidateSearch: "backend::lang.messages.relation.candidate_search",
|
||||||
|
Linked: "backend::lang.messages.relation.linked",
|
||||||
|
UnlinkSelected: "backend::lang.messages.relation.unlink_selected",
|
||||||
|
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
|
||||||
|
Unlinked: "backend::lang.messages.relation.unlinked",
|
||||||
|
Empty: "backend::lang.messages.relation.empty",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// withDefaults fills every empty key of keys from defaults.
|
||||||
|
func withDefaults[K any](keys, defaults K) K {
|
||||||
|
out := keys
|
||||||
|
dst := reflect.ValueOf(&out).Elem()
|
||||||
|
src := reflect.ValueOf(defaults)
|
||||||
|
for i := 0; i < dst.NumField(); i++ {
|
||||||
|
if dst.Field(i).String() == "" {
|
||||||
|
dst.Field(i).SetString(src.Field(i).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageKeyPairs lists (yaml name, phrase key) for every key, in field order.
|
||||||
|
func messageKeyPairs[K any](keys K) [][2]string {
|
||||||
|
v := reflect.ValueOf(keys)
|
||||||
|
t := v.Type()
|
||||||
|
out := make([][2]string, 0, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
out = append(out, [2]string{t.Field(i).Tag.Get("yaml"), v.Field(i).String()})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// localizeMessages resolves each key of K into the same-index MessageForms
|
||||||
|
// field of M. Without a translator, or for a key that cannot convert, the
|
||||||
|
// form is {"other": text} where text is the translation or the key itself.
|
||||||
|
func localizeMessages[K any, M any](ctx context.Context, tr *phrasebook.Translator, keys K) M {
|
||||||
|
var out M
|
||||||
|
dst := reflect.ValueOf(&out).Elem()
|
||||||
|
src := reflect.ValueOf(keys)
|
||||||
|
locale := schemaLocale(ctx, tr)
|
||||||
|
for i := 0; i < src.NumField(); i++ {
|
||||||
|
key := src.Field(i).String()
|
||||||
|
dst.Field(i).Set(reflect.ValueOf(messageForms(tr, locale, key)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageForms(tr *phrasebook.Translator, locale, key string) MessageForms {
|
||||||
|
if key == "" {
|
||||||
|
return MessageForms{"other": ""}
|
||||||
|
}
|
||||||
|
if tr == nil {
|
||||||
|
return MessageForms{"other": key}
|
||||||
|
}
|
||||||
|
if forms, ok := tr.Forms(locale, key); ok {
|
||||||
|
return MessageForms(forms)
|
||||||
|
}
|
||||||
|
return MessageForms{"other": tr.GetIn(locale, key, nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listMessageKeySet is the list's resolved key set: declared keys over the
|
||||||
|
// framework defaults, with searchPrompt and empty defaulting to the list's own
|
||||||
|
// toolbar.search.prompt and noRecordsMessage when those are set.
|
||||||
|
func (s *ListSchema) listMessageKeySet() listMessageKeys {
|
||||||
|
defaults := listMessageDefaults
|
||||||
|
if s.SearchPrompt != "" {
|
||||||
|
defaults.SearchPrompt = s.SearchPrompt
|
||||||
|
}
|
||||||
|
if s.NoRecordsMessage != "" {
|
||||||
|
defaults.Empty = s.NoRecordsMessage
|
||||||
|
}
|
||||||
|
return withDefaults(s.messageKeys, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formMessageKeySet is the form's resolved key set; create defaults to the
|
||||||
|
// config_form.yaml name when it is set.
|
||||||
|
func (s *FormSchema) formMessageKeySet() formMessageKeys {
|
||||||
|
defaults := formMessageDefaults
|
||||||
|
if s.Name != "" {
|
||||||
|
defaults.Create = s.Name
|
||||||
|
}
|
||||||
|
return withDefaults(s.messageKeys, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RelationSchema) relationMessageKeySet() relationMessageKeys {
|
||||||
|
return withDefaults(s.messageKeys, relationMessageDefaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMessageKeys fails activation when a controller's messages block (or
|
||||||
|
// a framework default) names a phrase key no locale defines (D-13). Defaults
|
||||||
|
// taken from the list's own search prompt, empty message or the form name are
|
||||||
|
// Winter strings that may be literal text, so they are not checked.
|
||||||
|
func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
|
||||||
|
if reg == nil || tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(reg.byID))
|
||||||
|
for id := range reg.byID {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
check := func(pluginID, id, file string, pairs [][2]string) error {
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if pair[1] != "" && !tr.Has(pair[1]) {
|
||||||
|
return bootErr(pluginID, id, file, fmt.Errorf("messages.%s names missing phrase key %s", pair[0], pair[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
cc := reg.byID[id]
|
||||||
|
if cc.List != nil {
|
||||||
|
if err := check(cc.PluginID, id, "config_list.yaml", messageKeyPairs(withDefaults(cc.List.messageKeys, listMessageDefaults))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cc.Form != nil {
|
||||||
|
if err := check(cc.PluginID, id, "config_form.yaml", messageKeyPairs(withDefaults(cc.Form.messageKeys, formMessageDefaults))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(cc.Relations))
|
||||||
|
for name := range cc.Relations {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
if schema := cc.Relations[name].Schema; schema != nil {
|
||||||
|
if err := check(cc.PluginID, id, "config_relation.yaml", messageKeyPairs(schema.relationMessageKeySet())); err != nil {
|
||||||
|
return fmt.Errorf("relation %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
354
cabana/messages_test.go
Normal file
354
cabana/messages_test.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type langPlugin struct {
|
||||||
|
id string
|
||||||
|
lang fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p langPlugin) ID() string { return p.id }
|
||||||
|
func (p langPlugin) LangFS() fs.FS { return p.lang }
|
||||||
|
|
||||||
|
// messagesTranslator activates phrasebook with the framework backend strings
|
||||||
|
// and an acme.demo catalog.
|
||||||
|
func messagesTranslator(t *testing.T) *phrasebook.Translator {
|
||||||
|
t.Helper()
|
||||||
|
app := backpack.New(nil)
|
||||||
|
demo := fstest.MapFS{
|
||||||
|
"lang/pl/lang.yaml": {Data: []byte(`widgets:
|
||||||
|
count:
|
||||||
|
one: ":count widżet"
|
||||||
|
few: ":count widżety"
|
||||||
|
many: ":count widżetów"
|
||||||
|
other: ":count widżetu"
|
||||||
|
create: Nowy widżet
|
||||||
|
saved: Widżet „:name” zapisany
|
||||||
|
link: Dodaj edytora
|
||||||
|
`)},
|
||||||
|
"lang/en/lang.yaml": {Data: []byte(`widgets:
|
||||||
|
count:
|
||||||
|
one: ":count widget"
|
||||||
|
other: ":count widgets"
|
||||||
|
create: New widget
|
||||||
|
saved: Widget “:name” saved
|
||||||
|
link: Add editor
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr, ok := app.Lookup[*phrasebook.Translator]()
|
||||||
|
if !ok || tr == nil {
|
||||||
|
t.Fatal("translator missing")
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesListConfig = `modelClass: Widget
|
||||||
|
list: ~/plugins/acme/demo/models/widget/columns.yaml
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
|
messages:
|
||||||
|
recordCount: acme.demo::lang.widgets.count
|
||||||
|
create: acme.demo::lang.widgets.create
|
||||||
|
`
|
||||||
|
|
||||||
|
func localeCtx(locale string) context.Context {
|
||||||
|
return towel.WithLocale(context.Background(), locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase10Messages(t *testing.T) {
|
||||||
|
tr := messagesTranslator(t)
|
||||||
|
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pl, err := schema.Localize(localeCtx("pl"), tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := pl.Messages
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("localized list has no messages")
|
||||||
|
}
|
||||||
|
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
|
||||||
|
t.Fatalf("plural recordCount=%v", m.RecordCount)
|
||||||
|
}
|
||||||
|
if m.Create["other"] != "Nowy widżet" {
|
||||||
|
t.Fatalf("plugin create=%v", m.Create)
|
||||||
|
}
|
||||||
|
for name, got := range map[string]MessageForms{
|
||||||
|
"deleteConfirm": m.DeleteConfirm,
|
||||||
|
"searchPrompt": m.SearchPrompt,
|
||||||
|
"empty": m.Empty,
|
||||||
|
"deleteSelected": m.DeleteSelected,
|
||||||
|
"selected": m.Selected,
|
||||||
|
} {
|
||||||
|
if got["other"] == "" || strings.Contains(got["other"], "::") {
|
||||||
|
t.Fatalf("default %s did not resolve: %v", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
|
||||||
|
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
|
||||||
|
}
|
||||||
|
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
|
||||||
|
t.Fatalf("default plural deleted=%v", m.Deleted)
|
||||||
|
}
|
||||||
|
en, err := schema.Localize(localeCtx("en"), tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
|
||||||
|
t.Fatalf("en messages=%+v", en.Messages)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(pl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
|
||||||
|
t.Fatalf("served list JSON has no messages: %s", raw)
|
||||||
|
}
|
||||||
|
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
|
||||||
|
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("form", func(t *testing.T) {
|
||||||
|
config := `name: acme.demo::lang.widgets.create
|
||||||
|
form: ~/plugins/acme/demo/models/widget/fields.yaml
|
||||||
|
modelClass: Widget
|
||||||
|
defaultRedirect: acme/demo/widgets
|
||||||
|
create:
|
||||||
|
redirect: acme/demo/widgets/update/:id
|
||||||
|
redirectClose: acme/demo/widgets
|
||||||
|
update:
|
||||||
|
redirect: acme/demo/widgets
|
||||||
|
redirectClose: acme/demo/widgets
|
||||||
|
messages:
|
||||||
|
saved: acme.demo::lang.widgets.saved
|
||||||
|
`
|
||||||
|
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := form.Localize(localeCtx("pl"), tr, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
|
||||||
|
t.Fatalf("form messages=%+v", view.Messages)
|
||||||
|
}
|
||||||
|
want := FormRedirects{Default: "acme/demo/widgets",
|
||||||
|
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
|
||||||
|
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
|
||||||
|
if view.Redirects != want {
|
||||||
|
t.Fatalf("redirects=%+v", view.Redirects)
|
||||||
|
}
|
||||||
|
raw, err := view.CanonicalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
|
||||||
|
t.Fatalf("form view JSON=%s", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("relation", func(t *testing.T) {
|
||||||
|
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
|
||||||
|
ctl := relationTestController{}
|
||||||
|
fsys := relationTestFS(yaml)
|
||||||
|
form, err := CompileForm("acme.demo", ctl, fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relations, err := compileRelations("acme.demo", ctl, fsys, form)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
|
||||||
|
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
|
||||||
|
t.Fatalf("relation messages=%+v", view.Messages)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown key fails at boot", func(t *testing.T) {
|
||||||
|
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
|
||||||
|
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
|
||||||
|
t.Fatalf("list err=%v", err)
|
||||||
|
}
|
||||||
|
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
|
||||||
|
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
|
||||||
|
t.Fatalf("form err=%v", err)
|
||||||
|
}
|
||||||
|
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
|
||||||
|
ctl := relationTestController{}
|
||||||
|
fsys := relationTestFS(yaml)
|
||||||
|
compiled, _ := CompileForm("acme.demo", ctl, fsys)
|
||||||
|
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
|
||||||
|
t.Fatalf("relation err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing phrase key fails at boot", func(t *testing.T) {
|
||||||
|
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
|
||||||
|
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = validateMessageKeys(reg, tr)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateMessageKeys(ok, tr); err != nil {
|
||||||
|
t.Fatalf("declared keys failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateMessageKeys(reg, nil); err != nil {
|
||||||
|
t.Fatalf("no translator must skip the check: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase10Toolbar(t *testing.T) {
|
||||||
|
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
|
||||||
|
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
|
||||||
|
config := strings.Replace(base, "BUTTONS", buttons, 1)
|
||||||
|
if !checkboxes {
|
||||||
|
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
|
||||||
|
}
|
||||||
|
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
buttons string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"[create, delete]", []string{"create", "delete"}},
|
||||||
|
{"[delete, create]", []string{"delete", "create"}},
|
||||||
|
{"[create]", []string{"create"}},
|
||||||
|
{"[]", []string{}},
|
||||||
|
{"\n - delete\n - create", []string{"delete", "create"}},
|
||||||
|
} {
|
||||||
|
schema, err := compile(tc.buttons, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.buttons, err)
|
||||||
|
}
|
||||||
|
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
buttons string
|
||||||
|
checkboxes bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"list_toolbar", true, "toolbar.buttons must be a list"},
|
||||||
|
{"list_toolbar", true, `"list_toolbar" is not supported`},
|
||||||
|
{"[create, delete]", false, "showCheckboxes"},
|
||||||
|
{"[create, create]", true, "duplicate action create"},
|
||||||
|
{"[create, export]", true, "unsupported action export"},
|
||||||
|
{"[{create: true}]", true, "toolbar.buttons"},
|
||||||
|
} {
|
||||||
|
_, err := compile(tc.buttons, tc.checkboxes)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
|
||||||
|
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller without a compiled form cannot create: create is dropped.
|
||||||
|
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cc, _ := reg.Get("acme.demo.widgets")
|
||||||
|
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
|
||||||
|
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase10Bundle(t *testing.T) {
|
||||||
|
tr := messagesTranslator(t)
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish(tr); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := &service{app: app}
|
||||||
|
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
||||||
|
if locale != "" {
|
||||||
|
req = req.WithContext(towel.WithLocale(req.Context(), locale))
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
svc.langBundle(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data map[string]map[string]string `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return body.Data, body.Meta.Locale, rec
|
||||||
|
}
|
||||||
|
pl, locale, rec := call("pl")
|
||||||
|
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||||
|
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
|
||||||
|
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
|
||||||
|
}
|
||||||
|
if len(pl) < 50 {
|
||||||
|
t.Fatalf("pl bundle has only %d keys", len(pl))
|
||||||
|
}
|
||||||
|
for key := range pl {
|
||||||
|
if !strings.HasPrefix(key, "backend::lang.") {
|
||||||
|
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
|
||||||
|
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
en, locale, _ := call("en-GB")
|
||||||
|
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
|
||||||
|
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
|
||||||
|
}
|
||||||
|
fallback, locale, _ := call("de")
|
||||||
|
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
|
||||||
|
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
|
||||||
|
}
|
||||||
|
// No translator: an empty bundle, still an envelope.
|
||||||
|
empty := &service{app: backpack.New(nil)}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
empty.langBundle(out, req)
|
||||||
|
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
|
||||||
|
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,9 @@ func TestPhase09ContractInventory(t *testing.T) {
|
|||||||
if !ok200 && !ok201 {
|
if !ok200 && !ok201 {
|
||||||
t.Fatalf("%s has no 200 or 201 response", key)
|
t.Fatalf("%s has no 200 or 201 response", key)
|
||||||
}
|
}
|
||||||
if public[key] {
|
if route.public {
|
||||||
if _, ok := op.Responses["401"]; !ok {
|
// Login and refresh answer 401; the public string bundle does not.
|
||||||
|
if _, ok := op.Responses["401"]; public[key] && !ok {
|
||||||
t.Fatalf("%s has no 401 response", key)
|
t.Fatalf("%s has no 401 response", key)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if form == nil && list != nil {
|
||||||
|
// Without a compiled form there is nothing to create (D-14).
|
||||||
|
list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create")
|
||||||
|
}
|
||||||
compiled := &CompiledController{
|
compiled := &CompiledController{
|
||||||
PluginID: item.plugin.ID(),
|
PluginID: item.plugin.ID(),
|
||||||
Controller: item.ctl,
|
Controller: item.ctl,
|
||||||
@@ -93,6 +97,16 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
|||||||
return &Registry{byID: byID}, nil
|
return &Registry{byID: byID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withoutAction(actions []string, drop string) []string {
|
||||||
|
out := make([]string, 0, len(actions))
|
||||||
|
for _, action := range actions {
|
||||||
|
if action != drop {
|
||||||
|
out = append(out, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
||||||
if reg == nil {
|
if reg == nil {
|
||||||
return fmt.Errorf("cabana: registry is nil")
|
return fmt.Errorf("cabana: registry is nil")
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ type RelationSchema struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
View RelationPanel `json:"view"`
|
View RelationPanel `json:"view"`
|
||||||
Manage RelationPanel `json:"manage"`
|
Manage RelationPanel `json:"manage"`
|
||||||
|
// Messages is the relation manager's copy (D-13); the cached schema
|
||||||
|
// carries each phrase key as its own form.
|
||||||
|
Messages *RelationMessages `json:"messages"`
|
||||||
|
|
||||||
|
messageKeys relationMessageKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompiledRelation combines trusted YAML with model-owned metadata.
|
// CompiledRelation combines trusted YAML with model-owned metadata.
|
||||||
@@ -100,6 +105,10 @@ type RelationMutationResult struct {
|
|||||||
type RelationService struct{ DB *gorm.DB }
|
type RelationService struct{ DB *gorm.DB }
|
||||||
|
|
||||||
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
||||||
|
if s.Messages == nil {
|
||||||
|
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet())
|
||||||
|
s.Messages = &keys
|
||||||
|
}
|
||||||
type alias RelationSchema
|
type alias RelationSchema
|
||||||
out := alias(s)
|
out := alias(s)
|
||||||
ensureRelationArrays(&out.View)
|
ensureRelationArrays(&out.View)
|
||||||
@@ -125,6 +134,8 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
|
|||||||
out.Label = translateKey(ctx, tr, s.Label)
|
out.Label = translateKey(ctx, tr, s.Label)
|
||||||
out.View = localizeRelationPanel(ctx, tr, s.View)
|
out.View = localizeRelationPanel(ctx, tr, s.View)
|
||||||
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
|
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
|
||||||
|
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
|
||||||
|
out.Messages = &messages
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +155,10 @@ type relationRoot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type relationDocument struct {
|
type relationDocument struct {
|
||||||
Label string `yaml:"label"`
|
Label string `yaml:"label"`
|
||||||
View relationPanelDocument `yaml:"view"`
|
View relationPanelDocument `yaml:"view"`
|
||||||
Manage relationPanelDocument `yaml:"manage"`
|
Manage relationPanelDocument `yaml:"manage"`
|
||||||
|
Messages *relationMessageKeys `yaml:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type relationPanelDocument struct {
|
type relationPanelDocument struct {
|
||||||
@@ -236,8 +248,14 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
|
|||||||
}
|
}
|
||||||
orderRelationColumns(raw, &view)
|
orderRelationColumns(raw, &view)
|
||||||
orderRelationColumns(raw, &manage)
|
orderRelationColumns(raw, &manage)
|
||||||
|
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
|
||||||
|
if doc.Messages != nil {
|
||||||
|
schema.messageKeys = *doc.Messages
|
||||||
|
}
|
||||||
|
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
|
||||||
|
schema.Messages = &keys
|
||||||
out[name] = &CompiledRelation{
|
out[name] = &CompiledRelation{
|
||||||
Schema: &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage},
|
Schema: schema,
|
||||||
Contract: contract,
|
Contract: contract,
|
||||||
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
|
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package cabana
|
package cabana
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
||||||
type ListSort struct {
|
type ListSort struct {
|
||||||
@@ -68,11 +71,20 @@ type ListSchema struct {
|
|||||||
Filters []ListFilter `json:"filters"`
|
Filters []ListFilter `json:"filters"`
|
||||||
RowActions []RowAction `json:"rowActions"`
|
RowActions []RowAction `json:"rowActions"`
|
||||||
BulkActions []BulkAction `json:"bulkActions"`
|
BulkActions []BulkAction `json:"bulkActions"`
|
||||||
Meta *FormMeta `json:"meta,omitempty"`
|
// Messages is the list's copy (D-13). The cached schema carries each
|
||||||
|
// phrase key as its own form; a response resolves them in its locale.
|
||||||
|
Messages *ListMessages `json:"messages"`
|
||||||
|
Meta *FormMeta `json:"meta,omitempty"`
|
||||||
|
|
||||||
|
messageKeys listMessageKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
|
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
|
||||||
func (s ListSchema) MarshalJSON() ([]byte, error) {
|
func (s ListSchema) MarshalJSON() ([]byte, error) {
|
||||||
|
if s.Messages == nil {
|
||||||
|
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet())
|
||||||
|
s.Messages = &keys
|
||||||
|
}
|
||||||
type alias ListSchema
|
type alias ListSchema
|
||||||
out := alias(s)
|
out := alias(s)
|
||||||
if out.PerPageOptions == nil {
|
if out.PerPageOptions == nil {
|
||||||
@@ -102,6 +114,9 @@ type FormSchema struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
ModelClass string `json:"modelClass,omitempty"`
|
ModelClass string `json:"modelClass,omitempty"`
|
||||||
Fields []FormField `json:"fields"`
|
Fields []FormField `json:"fields"`
|
||||||
|
|
||||||
|
messageKeys formMessageKeys
|
||||||
|
redirects FormRedirects
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormView is one request's localized form, including the locale actually used.
|
// FormView is one request's localized form, including the locale actually used.
|
||||||
@@ -109,7 +124,25 @@ type FormView struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
ModelClass string `json:"modelClass,omitempty"`
|
ModelClass string `json:"modelClass,omitempty"`
|
||||||
Fields []FormField `json:"fields"`
|
Fields []FormField `json:"fields"`
|
||||||
Meta FormMeta `json:"meta"`
|
// Messages is the form's copy resolved in the request locale (D-13).
|
||||||
|
Messages FormMessages `json:"messages"`
|
||||||
|
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
||||||
|
// them onto its routes.
|
||||||
|
Redirects FormRedirects `json:"redirects"`
|
||||||
|
Meta FormMeta `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
|
||||||
|
type FormRedirects struct {
|
||||||
|
Default string `json:"default"`
|
||||||
|
Create FormRedirect `json:"create"`
|
||||||
|
Update FormRedirect `json:"update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormRedirect is one Winter redirect pair.
|
||||||
|
type FormRedirect struct {
|
||||||
|
Redirect string `json:"redirect"`
|
||||||
|
RedirectClose string `json:"redirectClose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormMeta reports the locale selected for a schema response.
|
// FormMeta reports the locale selected for a schema response.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name
|
|||||||
var phase09Routes = []adminRoute{
|
var phase09Routes = []adminRoute{
|
||||||
{key: "POST /auth/login", public: true},
|
{key: "POST /auth/login", public: true},
|
||||||
{key: "POST /auth/refresh", public: true},
|
{key: "POST /auth/refresh", public: true},
|
||||||
|
{key: "GET /lang", public: true},
|
||||||
{key: "POST /auth/logout"},
|
{key: "POST /auth/logout"},
|
||||||
{key: "GET /auth/me"},
|
{key: "GET /auth/me"},
|
||||||
{key: "GET /navigation"},
|
{key: "GET /navigation"},
|
||||||
|
|||||||
@@ -978,6 +978,8 @@ func TestMakeAdminControllerLayout(t *testing.T) {
|
|||||||
for _, text := range []string{
|
for _, text := range []string{
|
||||||
"modelClass: Albums",
|
"modelClass: Albums",
|
||||||
"list: ~/plugins/golem15/demo/models/albums/columns.yaml",
|
"list: ~/plugins/golem15/demo/models/albums/columns.yaml",
|
||||||
|
"buttons: [create]",
|
||||||
|
"prompt: backend::lang.list.search_prompt",
|
||||||
} {
|
} {
|
||||||
if !bytes.Contains(listSrc, []byte(text)) {
|
if !bytes.Contains(listSrc, []byte(text)) {
|
||||||
t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc)
|
t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc)
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ update:
|
|||||||
modelClass: {{.Ident}}
|
modelClass: {{.Ident}}
|
||||||
title: {{.Ident}}
|
title: {{.Ident}}
|
||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
showSearch: true
|
toolbar:
|
||||||
|
buttons: [create]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "fields.yaml"}}fields:
|
{{define "fields.yaml"}}fields:
|
||||||
|
|||||||
@@ -268,6 +268,15 @@ type HasLang interface {
|
|||||||
LangFS() fs.FS
|
LangFS() fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasLangOverrides is implemented by plugins (usually the app plugin) that
|
||||||
|
// replace or add translations of any loaded namespace, including the
|
||||||
|
// framework's backend::lang admin strings, and may add locales (D-20).
|
||||||
|
// Layout: lang/<locale>/<namespace>/<group>.yaml, e.g.
|
||||||
|
// lang/pl/backend/lang.yaml.
|
||||||
|
type HasLangOverrides interface {
|
||||||
|
LangOverridesFS() fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
// HasMailTemplates is implemented by plugins that ship Winter-shaped mail
|
// HasMailTemplates is implemented by plugins that ship Winter-shaped mail
|
||||||
// templates and layout aliases. MailTemplatesFS contains views/mail assets;
|
// templates and layout aliases. MailTemplatesFS contains views/mail assets;
|
||||||
// MailTemplates lists dotted template names; MailLayouts maps a short layout
|
// MailTemplates lists dotted template names; MailLayouts maps a short layout
|
||||||
|
|||||||
119
phrasebook/backend/lang/en/lang.yaml
Normal file
119
phrasebook/backend/lang/en/lang.yaml
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||||
|
# English equivalents of the Polish copy; plural texts are CLDR maps (one,
|
||||||
|
# other), never pipe strings. Placeholders use :count, :name, :term (D-24).
|
||||||
|
auth:
|
||||||
|
title: Welcome back
|
||||||
|
login: Login or email
|
||||||
|
password: Password
|
||||||
|
invalid: Invalid login details
|
||||||
|
submit: Sign in
|
||||||
|
logout: Sign out
|
||||||
|
session_expired: Your session has expired. Please sign in again.
|
||||||
|
nav:
|
||||||
|
plugins: Plugins
|
||||||
|
sections: Sections
|
||||||
|
breadcrumbs: Breadcrumbs
|
||||||
|
empty: No sections are available.
|
||||||
|
settings: Settings
|
||||||
|
collapse: Collapse menu
|
||||||
|
expand: Expand menu
|
||||||
|
user_menu: User menu
|
||||||
|
page:
|
||||||
|
not_found: Page not found.
|
||||||
|
list:
|
||||||
|
search_prompt: Search…
|
||||||
|
no_records: There are no records in this view.
|
||||||
|
no_results: 0 results
|
||||||
|
loading: Loading…
|
||||||
|
load_failed: The list could not be loaded.
|
||||||
|
delete_selected: Delete selected
|
||||||
|
prev_page: Previous
|
||||||
|
next_page: Next
|
||||||
|
records_per_page: Per page
|
||||||
|
column_switch_true: "Yes"
|
||||||
|
column_switch_false: "No"
|
||||||
|
column_created: Created
|
||||||
|
column_updated: Updated
|
||||||
|
pagination_range: ":from–:to of :total"
|
||||||
|
results:
|
||||||
|
one: ":count result"
|
||||||
|
other: ":count results"
|
||||||
|
empty_value: "—"
|
||||||
|
clear_search: Clear search
|
||||||
|
select_all: Select all on this page
|
||||||
|
select_row: Select row
|
||||||
|
sort_ascending: Sort ascending
|
||||||
|
sort_descending: Sort descending
|
||||||
|
form:
|
||||||
|
save: Save
|
||||||
|
save_and_close: Save and close
|
||||||
|
cancel: Cancel
|
||||||
|
delete: Delete
|
||||||
|
none: — none —
|
||||||
|
update: Edit
|
||||||
|
create: Create
|
||||||
|
return_to_list: Back to list
|
||||||
|
close: Close
|
||||||
|
confirm: Confirm
|
||||||
|
saving: Saving…
|
||||||
|
deleting: Deleting…
|
||||||
|
add: Add
|
||||||
|
remove_item: "Remove: :name"
|
||||||
|
add_item: Add…
|
||||||
|
select_placeholder: Select…
|
||||||
|
loading_options: Loading…
|
||||||
|
no_options: No results
|
||||||
|
unsupported_field: "Unsupported field type: :type"
|
||||||
|
error_title: Could not save.
|
||||||
|
error_fields:
|
||||||
|
one: "Fix :count field marked below."
|
||||||
|
other: "Fix :count fields marked below."
|
||||||
|
error_generic: The changes could not be saved. Please try again.
|
||||||
|
unsaved_confirm: You have unsaved changes. Discard them?
|
||||||
|
tab_errors:
|
||||||
|
one: ":count error"
|
||||||
|
other: ":count errors"
|
||||||
|
relation:
|
||||||
|
add: Add
|
||||||
|
link: Link
|
||||||
|
unlink: Unlink
|
||||||
|
remove: Remove
|
||||||
|
search: Search…
|
||||||
|
add_count: "Add (:count)"
|
||||||
|
candidates_range: ":from–:to of :total"
|
||||||
|
settings:
|
||||||
|
title: Settings
|
||||||
|
empty: No settings are available.
|
||||||
|
saved: Settings saved
|
||||||
|
messages:
|
||||||
|
list:
|
||||||
|
record_count:
|
||||||
|
one: ":count record"
|
||||||
|
other: ":count records"
|
||||||
|
create: New record
|
||||||
|
empty_search: Nothing found
|
||||||
|
empty_search_hint: No record matches “:term”. Try another search.
|
||||||
|
selected: ":count selected"
|
||||||
|
delete_confirm: "Delete the selected (:count)?"
|
||||||
|
deleted:
|
||||||
|
one: "Deleted :count record"
|
||||||
|
other: "Deleted :count records"
|
||||||
|
form:
|
||||||
|
create: New record
|
||||||
|
update: Edit record
|
||||||
|
saved: Saved
|
||||||
|
delete_confirm: Delete this record?
|
||||||
|
deleted: Record deleted
|
||||||
|
relation:
|
||||||
|
link: Add
|
||||||
|
link_hint: Choose the records to link.
|
||||||
|
candidate_search: Search…
|
||||||
|
linked:
|
||||||
|
one: "Linked :count record"
|
||||||
|
other: "Linked :count records"
|
||||||
|
unlink_selected: Unlink selected
|
||||||
|
unlink_confirm: "Unlink the selected (:count)?"
|
||||||
|
unlinked:
|
||||||
|
one: "Unlinked :count record"
|
||||||
|
other: "Unlinked :count records"
|
||||||
|
empty: No linked records.
|
||||||
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||||
|
# Winter backend key names are kept where Winter has them; values follow the
|
||||||
|
# admin design copy (D-06). Plural texts are CLDR maps (one, few, many,
|
||||||
|
# other), never pipe strings, so every key converts to the SPA's plural forms.
|
||||||
|
# Placeholders use :count, :name, :term and friends (D-24).
|
||||||
|
auth:
|
||||||
|
title: Witaj ponownie
|
||||||
|
login: Login lub e-mail
|
||||||
|
password: Hasło
|
||||||
|
invalid: Nieprawidłowe dane logowania
|
||||||
|
submit: Zaloguj
|
||||||
|
logout: Wyloguj
|
||||||
|
session_expired: Sesja wygasła. Zaloguj się ponownie.
|
||||||
|
nav:
|
||||||
|
plugins: Wtyczki
|
||||||
|
sections: Sekcje
|
||||||
|
breadcrumbs: Ścieżka nawigacji
|
||||||
|
empty: Brak dostępnych sekcji.
|
||||||
|
settings: Ustawienia
|
||||||
|
collapse: Zwiń menu
|
||||||
|
expand: Rozwiń menu
|
||||||
|
user_menu: Menu użytkownika
|
||||||
|
page:
|
||||||
|
not_found: Nie znaleziono strony.
|
||||||
|
list:
|
||||||
|
search_prompt: Szukaj…
|
||||||
|
no_records: Brak rekordów w tym widoku.
|
||||||
|
no_results: 0 wyników
|
||||||
|
loading: Wczytywanie…
|
||||||
|
load_failed: Nie udało się wczytać listy.
|
||||||
|
delete_selected: Usuń zaznaczone
|
||||||
|
prev_page: Poprzednia
|
||||||
|
next_page: Następna
|
||||||
|
records_per_page: Na stronę
|
||||||
|
column_switch_true: Tak
|
||||||
|
column_switch_false: Nie
|
||||||
|
column_created: Utworzono
|
||||||
|
column_updated: Zaktualizowano
|
||||||
|
pagination_range: ":from–:to z :total"
|
||||||
|
results:
|
||||||
|
one: ":count wynik"
|
||||||
|
few: ":count wyniki"
|
||||||
|
many: ":count wyników"
|
||||||
|
other: ":count wyniku"
|
||||||
|
empty_value: "—"
|
||||||
|
clear_search: Wyczyść wyszukiwanie
|
||||||
|
select_all: Zaznacz wszystkie na stronie
|
||||||
|
select_row: Zaznacz wiersz
|
||||||
|
sort_ascending: Sortuj rosnąco
|
||||||
|
sort_descending: Sortuj malejąco
|
||||||
|
form:
|
||||||
|
save: Zapisz
|
||||||
|
save_and_close: Zapisz i zamknij
|
||||||
|
cancel: Anuluj
|
||||||
|
delete: Usuń
|
||||||
|
none: — brak —
|
||||||
|
update: Edytuj
|
||||||
|
create: Utwórz
|
||||||
|
return_to_list: Wróć do listy
|
||||||
|
close: Zamknij
|
||||||
|
confirm: Potwierdź
|
||||||
|
saving: Zapisywanie…
|
||||||
|
deleting: Usuwanie…
|
||||||
|
add: Dodaj
|
||||||
|
remove_item: "Usuń: :name"
|
||||||
|
add_item: Dodaj…
|
||||||
|
select_placeholder: Wybierz…
|
||||||
|
loading_options: Wczytywanie…
|
||||||
|
no_options: Brak wyników
|
||||||
|
unsupported_field: "Nieobsługiwany typ pola: :type"
|
||||||
|
error_title: Nie udało się zapisać.
|
||||||
|
error_fields:
|
||||||
|
one: "Popraw :count pole oznaczone poniżej."
|
||||||
|
few: "Popraw :count pola oznaczone poniżej."
|
||||||
|
many: "Popraw :count pól oznaczonych poniżej."
|
||||||
|
other: "Popraw :count pola oznaczonego poniżej."
|
||||||
|
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
||||||
|
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||||
|
tab_errors:
|
||||||
|
one: ":count błąd"
|
||||||
|
few: ":count błędy"
|
||||||
|
many: ":count błędów"
|
||||||
|
other: ":count błędu"
|
||||||
|
relation:
|
||||||
|
add: Dodaj
|
||||||
|
link: Dołącz
|
||||||
|
unlink: Odłącz
|
||||||
|
remove: Usuń
|
||||||
|
search: Szukaj…
|
||||||
|
add_count: "Dodaj (:count)"
|
||||||
|
candidates_range: ":from–:to z :total"
|
||||||
|
settings:
|
||||||
|
title: Ustawienia
|
||||||
|
empty: Brak dostępnych ustawień.
|
||||||
|
saved: Zapisano ustawienia
|
||||||
|
messages:
|
||||||
|
list:
|
||||||
|
record_count:
|
||||||
|
one: ":count rekord"
|
||||||
|
few: ":count rekordy"
|
||||||
|
many: ":count rekordów"
|
||||||
|
other: ":count rekordu"
|
||||||
|
create: Nowy rekord
|
||||||
|
empty_search: Nic nie znaleziono
|
||||||
|
empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy.
|
||||||
|
selected: "Zaznaczono :count"
|
||||||
|
delete_confirm: "Usunąć zaznaczone (:count)?"
|
||||||
|
deleted:
|
||||||
|
one: "Usunięto :count rekord"
|
||||||
|
few: "Usunięto :count rekordy"
|
||||||
|
many: "Usunięto :count rekordów"
|
||||||
|
other: "Usunięto :count rekordu"
|
||||||
|
form:
|
||||||
|
create: Nowy rekord
|
||||||
|
update: Edycja rekordu
|
||||||
|
saved: Zapisano
|
||||||
|
delete_confirm: Usunąć ten rekord?
|
||||||
|
deleted: Usunięto rekord
|
||||||
|
relation:
|
||||||
|
link: Dodaj
|
||||||
|
link_hint: Wybierz rekordy, które chcesz dołączyć.
|
||||||
|
candidate_search: Szukaj…
|
||||||
|
linked:
|
||||||
|
one: "Dołączono :count rekord"
|
||||||
|
few: "Dołączono :count rekordy"
|
||||||
|
many: "Dołączono :count rekordów"
|
||||||
|
other: "Dołączono :count rekordu"
|
||||||
|
unlink_selected: Odłącz zaznaczone
|
||||||
|
unlink_confirm: "Odłączyć zaznaczone (:count)?"
|
||||||
|
unlinked:
|
||||||
|
one: "Odłączono :count rekord"
|
||||||
|
few: "Odłączono :count rekordy"
|
||||||
|
many: "Odłączono :count rekordów"
|
||||||
|
other: "Odłączono :count rekordu"
|
||||||
|
empty: Brak powiązanych rekordów.
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
package phrasebook
|
package phrasebook
|
||||||
|
|
||||||
import "embed"
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
//go:embed lang
|
//go:embed lang
|
||||||
var systemLangFS embed.FS
|
var systemLangFS embed.FS
|
||||||
|
|
||||||
|
//go:embed backend/lang
|
||||||
|
var backendLangFS embed.FS
|
||||||
|
|
||||||
|
// backendLang is the framework admin string tree (D-20), laid out as
|
||||||
|
// lang/<locale>/lang.yaml so its keys resolve as backend::lang.<group>.<key>.
|
||||||
|
func backendLang() fs.FS {
|
||||||
|
sub, err := fs.Sub(backendLangFS, "backend")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type Catalog struct {
|
|||||||
messages map[string]map[string]entry
|
messages map[string]map[string]entry
|
||||||
// namespace (plugin ID) -> owning plugin ID
|
// namespace (plugin ID) -> owning plugin ID
|
||||||
namespaces map[string]string
|
namespaces map[string]string
|
||||||
|
// replace lets put overwrite an existing key; only Override sets it.
|
||||||
|
replace bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
@@ -221,7 +223,7 @@ func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
|
|||||||
byKey = make(map[string]entry)
|
byKey = make(map[string]entry)
|
||||||
c.messages[locale] = byKey
|
c.messages[locale] = byKey
|
||||||
}
|
}
|
||||||
if _, exists := byKey[key]; exists {
|
if _, exists := byKey[key]; exists && !c.replace {
|
||||||
return catalogErrorf(pluginID, file, key, "duplicate key")
|
return catalogErrorf(pluginID, file, key, "duplicate key")
|
||||||
}
|
}
|
||||||
byKey[key] = e
|
byKey[key] = e
|
||||||
@@ -240,6 +242,106 @@ func (c *Catalog) lookup(locale, key string) (entry, bool) {
|
|||||||
return e, ok
|
return e, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override applies owner's override tree, laid out as
|
||||||
|
// lang/<locale>/<namespace>/<group>.yaml, over the loaded catalog. It may
|
||||||
|
// replace existing keys of a loaded namespace and add locales; a malformed
|
||||||
|
// path or an unknown namespace fails.
|
||||||
|
func (c *Catalog) Override(owner string, fsys fs.FS) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("phrasebook: catalog is nil")
|
||||||
|
}
|
||||||
|
if fsys == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
files = append(files, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error())
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
c.replace = true
|
||||||
|
defer func() { c.replace = false }()
|
||||||
|
for _, file := range files {
|
||||||
|
locale, namespace, group, err := parseOverridePath(file)
|
||||||
|
if err != nil {
|
||||||
|
return catalogErrorf(owner, file, "", "%s", err.Error())
|
||||||
|
}
|
||||||
|
if _, loaded := c.namespaces[namespace]; !loaded {
|
||||||
|
return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace)
|
||||||
|
}
|
||||||
|
raw, err := fs.ReadFile(fsys, file)
|
||||||
|
if err != nil {
|
||||||
|
return catalogErrorf(owner, file, "", "read: %s", err.Error())
|
||||||
|
}
|
||||||
|
if err := c.loadFile(namespace, file, locale, group, raw); err != nil {
|
||||||
|
return fmt.Errorf("phrasebook: override by %s: %w", owner, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOverridePath(p string) (locale, namespace, group string, err error) {
|
||||||
|
bad := fmt.Errorf("malformed override path (want lang/<locale>/<namespace>/<group>.yaml)")
|
||||||
|
if strings.Contains(p, "\\") || path.Clean(p) != p {
|
||||||
|
return "", "", "", bad
|
||||||
|
}
|
||||||
|
parts := strings.Split(p, "/")
|
||||||
|
if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") {
|
||||||
|
return "", "", "", bad
|
||||||
|
}
|
||||||
|
locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml")
|
||||||
|
if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) {
|
||||||
|
return "", "", "", bad
|
||||||
|
}
|
||||||
|
return locale, namespace, group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validNamespace(s string) bool {
|
||||||
|
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForms fails when a key under prefix cannot convert to CLDR forms, so
|
||||||
|
// strings served to the admin SPA never need Laravel exact or range pipes.
|
||||||
|
func (c *Catalog) checkForms(prefix string) error {
|
||||||
|
locales := make([]string, 0, len(c.messages))
|
||||||
|
for locale := range c.messages {
|
||||||
|
locales = append(locales, locale)
|
||||||
|
}
|
||||||
|
sort.Strings(locales)
|
||||||
|
for _, locale := range locales {
|
||||||
|
keys := make([]string, 0)
|
||||||
|
for key := range c.messages[locale] {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := entryForms(c.messages[locale][key]); !ok {
|
||||||
|
return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseLangPath(p string) (locale, group string, err error) {
|
func parseLangPath(p string) (locale, group string, err error) {
|
||||||
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
|
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
|
||||||
p = strings.TrimPrefix(p, "/")
|
p = strings.TrimPrefix(p, "/")
|
||||||
|
|||||||
199
phrasebook/phase10_test.go
Normal file
199
phrasebook/phase10_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package phrasebook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPhase10Forms(t *testing.T) {
|
||||||
|
cat := NewCatalog()
|
||||||
|
if err := cat.Load("acme.demo", fstest.MapFS{
|
||||||
|
"lang/en/lang.yaml": {Data: []byte(`plain: Hello :name
|
||||||
|
plural:
|
||||||
|
one: ":count apple"
|
||||||
|
other: ":count apples"
|
||||||
|
pipe: ":count apple|:count apples"
|
||||||
|
exact: "{0} No apples|[1,*] :count apples"
|
||||||
|
`)},
|
||||||
|
"lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka"
|
||||||
|
`)},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
|
||||||
|
|
||||||
|
check := func(locale, key string, want map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
got, ok := tr.Forms(locale, key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s %s: not convertible", locale, key)
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
|
||||||
|
}
|
||||||
|
for cat, text := range want {
|
||||||
|
if got[cat] != text {
|
||||||
|
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
|
||||||
|
check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"})
|
||||||
|
check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"})
|
||||||
|
check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"})
|
||||||
|
// pl falls back to en for a key it does not define.
|
||||||
|
check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
|
||||||
|
|
||||||
|
if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok {
|
||||||
|
t.Fatalf("exact/range pipe converted to %v", forms)
|
||||||
|
}
|
||||||
|
if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok {
|
||||||
|
t.Fatalf("missing key converted to %v", forms)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := tr.Forms("en", "acme.demo::lang.plural")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("plural not convertible")
|
||||||
|
}
|
||||||
|
got["one"] = "mutated"
|
||||||
|
if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" {
|
||||||
|
t.Fatal("Forms returned the catalog's own map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type overridePlugin struct {
|
||||||
|
id string
|
||||||
|
lang fs.FS
|
||||||
|
overrides fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p overridePlugin) ID() string { return p.id }
|
||||||
|
func (p overridePlugin) LangFS() fs.FS { return p.lang }
|
||||||
|
func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides }
|
||||||
|
|
||||||
|
func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) {
|
||||||
|
t.Helper()
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := Activate(app, plugins); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tr, ok := app.Lookup[*Translator]()
|
||||||
|
if !ok || tr == nil {
|
||||||
|
t.Fatal("translator was not published")
|
||||||
|
}
|
||||||
|
return tr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase10LangOverride(t *testing.T) {
|
||||||
|
demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}}
|
||||||
|
tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{
|
||||||
|
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")},
|
||||||
|
"lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")},
|
||||||
|
"lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activate: %v", err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ locale, key, want string }{
|
||||||
|
{"pl", "backend::lang.form.save", "Zachowaj"},
|
||||||
|
{"pl", "backend::lang.form.extra", "Dodatkowy"},
|
||||||
|
{"pl", "backend::lang.form.cancel", "Anuluj"},
|
||||||
|
{"de", "backend::lang.form.save", "Speichern"},
|
||||||
|
{"en", "backend::lang.form.save", "Save"},
|
||||||
|
{"en", "acme.demo::lang.title", "Demo app"},
|
||||||
|
} {
|
||||||
|
if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want {
|
||||||
|
t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" {
|
||||||
|
t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
overrides fstest.MapFS
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||||
|
{"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||||
|
{"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
|
||||||
|
{"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}},
|
||||||
|
{"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("activation accepted a bad override")
|
||||||
|
}
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("err=%v missing %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`)
|
||||||
|
|
||||||
|
// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every
|
||||||
|
// backend::lang key literal in pl and en, each without falling back to the
|
||||||
|
// other locale.
|
||||||
|
func TestPhase10SPAKeysResolve(t *testing.T) {
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("caller")
|
||||||
|
}
|
||||||
|
root := filepath.Join(filepath.Dir(file), "..", "admin", "src")
|
||||||
|
keys := map[string]bool{}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range spaKey.FindAllString(string(raw), -1) {
|
||||||
|
keys[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(keys) < 10 {
|
||||||
|
t.Fatalf("found only %d backend::lang keys under %s", len(keys), root)
|
||||||
|
}
|
||||||
|
sorted := make([]string, 0, len(keys))
|
||||||
|
for key := range keys {
|
||||||
|
sorted = append(sorted, key)
|
||||||
|
}
|
||||||
|
sort.Strings(sorted)
|
||||||
|
cat := NewCatalog()
|
||||||
|
if err := cat.Load("backend", backendLang()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, locale := range []string{"pl", "en"} {
|
||||||
|
tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale})
|
||||||
|
for _, key := range sorted {
|
||||||
|
forms, ok := tr.Forms(locale, key)
|
||||||
|
if !ok || forms["other"] == "" {
|
||||||
|
t.Errorf("%s: %s does not resolve", locale, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
|||||||
if err := cat.Load("lagoon", systemLangFS); err != nil {
|
if err := cat.Load("lagoon", systemLangFS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := cat.Load("backend", backendLang()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, p := range plugins {
|
for _, p := range plugins {
|
||||||
hl, ok := any(p).(pact.HasLang)
|
hl, ok := any(p).(pact.HasLang)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -100,6 +103,21 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Overrides run after every namespace is loaded (D-20): a project can
|
||||||
|
// replace framework or plugin strings and add locales without a rebuild
|
||||||
|
// of the admin SPA.
|
||||||
|
for _, p := range plugins {
|
||||||
|
ho, ok := any(p).(pact.HasLangOverrides)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := cat.Override(p.ID(), ho.LangOverridesFS()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cat.checkForms("backend::"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
opts := Options{Locale: defaultLocale, Fallback: defaultFallback}
|
opts := Options{Locale: defaultLocale, Fallback: defaultFallback}
|
||||||
if app.Config != nil {
|
if app.Config != nil {
|
||||||
if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" {
|
if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" {
|
||||||
@@ -460,3 +478,102 @@ func ucfirst(s string) string {
|
|||||||
}
|
}
|
||||||
return string(unicode.ToUpper(r)) + s[size:]
|
return string(unicode.ToUpper(r)) + s[size:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forms returns key's text as CLDR plural forms for the SPA (D-13, D-20): a
|
||||||
|
// plain text is {"other": text}, a YAML plural map is copied, and a pipe of
|
||||||
|
// category forms maps by category. Exact and range pipes ({0}, [2,*]) have
|
||||||
|
// no Intl.PluralRules equivalent and report false, as does a missing key.
|
||||||
|
// Placeholders are left for the client to interpolate.
|
||||||
|
func (t *Translator) Forms(locale, key string) (map[string]string, bool) {
|
||||||
|
if t == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
e, _, ok := t.find(locale, key)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entryForms(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle returns every key starting with prefix as CLDR forms, keys of the
|
||||||
|
// requested locale over those of its fallback chain. Keys that cannot convert
|
||||||
|
// are left out.
|
||||||
|
func (t *Translator) Bundle(locale, prefix string) map[string]map[string]string {
|
||||||
|
out := map[string]map[string]string{}
|
||||||
|
if t == nil || t.cat == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
chain := fallbackChain(locale, t.fallbackLocale())
|
||||||
|
for i := len(chain) - 1; i >= 0; i-- {
|
||||||
|
for key, e := range t.cat.messages[chain[i]] {
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if forms, ok := entryForms(e); ok {
|
||||||
|
out[key] = forms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved returns the first locale of locale's fallback chain that the
|
||||||
|
// catalog has any key for under prefix: the locale a Bundle is mostly in.
|
||||||
|
func (t *Translator) Resolved(locale, prefix string) string {
|
||||||
|
if t == nil || t.cat == nil {
|
||||||
|
return locale
|
||||||
|
}
|
||||||
|
for _, step := range fallbackChain(locale, t.fallbackLocale()) {
|
||||||
|
for key := range t.cat.messages[step] {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.fallbackLocale()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether any locale of the catalog defines key.
|
||||||
|
func (t *Translator) Has(key string) bool {
|
||||||
|
if t == nil || t.cat == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, byKey := range t.cat.messages {
|
||||||
|
if _, ok := byKey[key]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Translator) fallbackLocale() string {
|
||||||
|
if t == nil || strings.TrimSpace(t.fallback) == "" {
|
||||||
|
return defaultFallback
|
||||||
|
}
|
||||||
|
return t.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryForms(e entry) (map[string]string, bool) {
|
||||||
|
switch {
|
||||||
|
case len(e.pipes) > 0:
|
||||||
|
out := make(map[string]string, len(e.pipes))
|
||||||
|
for _, part := range e.pipes {
|
||||||
|
if part.exact != nil || part.lo != nil || part.cat == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out[part.cat] = part.text
|
||||||
|
}
|
||||||
|
if _, ok := out["other"]; !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
case e.plurals != nil:
|
||||||
|
out := make(map[string]string, len(e.plurals))
|
||||||
|
for cat, text := range e.plurals {
|
||||||
|
out[cat] = text
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
default:
|
||||||
|
return map[string]string{"other": e.text}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user