diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 43e3358..82a4bf0 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -58,6 +58,8 @@ export type ControllerAssets = Schemas['cabana.ControllerAssets'] export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets'] /** Answer of a plugin field type upload: the URL of the stored file. */ export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse'] +/** One save notice of a plugin field type: field, content locale and message. */ +export type FieldNotice = Schemas['cabana.FieldNotice'] /** A registered toolbar action the admin may run, with its localized label. */ export type ToolbarAction = Schemas['cabana.ToolbarAction'] /** One node of a rendered partial: an allowlisted element or a text node. */ diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue index ef2b859..154ea13 100644 --- a/admin/src/components/form/FormField.vue +++ b/admin/src/components/form/FormField.vue @@ -6,11 +6,15 @@ import FieldRenderer from './FieldRenderer.vue' import { groupLabelled, isPluginField, ownsLabel } from './registry' // One form row: label (600, red aria-hidden asterisk when required), the -// control, the comment and the error line linked by aria-describedby. +// control, the comment, the error line and the save notices (what a plugin +// field type changed, D-10) linked by aria-describedby. Notices are text +// interpolation only: a notice may quote markup an administrator submitted. const props = defineProps<{ field: FormField modelValue: unknown errors?: string[] + /** Save notices of this field: warning status lines under the control. */ + notices?: string[] labels?: RelationOption[] source?: ControllerParams | null recordId?: number | null @@ -23,7 +27,9 @@ const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`) const controlId = computed(() => base.value) const commentId = computed(() => `${base.value}-comment`) const errorId = computed(() => `${base.value}-error`) +const noticeId = computed(() => `${base.value}-notice`) const invalid = computed(() => (props.errors?.length ?? 0) > 0) +const noticed = computed(() => (props.notices?.length ?? 0) > 0) const selfLabelled = computed(() => ownsLabel(props.field.type)) // A widget, partial or plugin field type row labels a role="group" host, so // its label is a span the host names with aria-labelledby, not a label for @@ -32,7 +38,11 @@ const labelsGroup = computed(() => groupLabelled(props.field.type) || isPluginFi const showComment = computed(() => !selfLabelled.value && !!props.field.comment) const describedBy = computed(() => - [showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : ''] + [ + showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', + invalid.value ? errorId.value : '', + noticed.value ? noticeId.value : '', + ] .filter(Boolean) .join(' '), ) @@ -58,6 +68,9 @@ const describedBy = computed(() => @update:model-value="(value: unknown) => emit('update:modelValue', value)" />
{{ field.comment }}
+{{ text }}
+
diff --git a/admin/src/components/form/FormGrid.vue b/admin/src/components/form/FormGrid.vue
index 42b6f5a..46591cb 100644
--- a/admin/src/components/form/FormGrid.vue
+++ b/admin/src/components/form/FormGrid.vue
@@ -10,6 +10,8 @@ defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record
a
' }) + await flushPromises() + await wrapper.setProps({ modelValue: 'a
' }) + await flushPromises() + expect(el.writes).toEqual(['a
', 'a
']) + }) +}) + +describe('MLPluginField', () => { + class AcmeDemoNotesml extends AcmeDemoNotes {} + if (!customElements.get('acme-demo-notesml')) { + customElements.define('acme-demo-notesml', AcmeDemoNotesml) + } + const mlField = notesField({ name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true }) + + it('renders through the ML switcher and edits one locale', async () => { + expect(rendererForField(mlField)).toBe(MLPluginField) + const wrapper = mount(MLPluginField, { + props: { field: mlField, modelValue: { pl: 'Polski', en: 'English' }, controlId: 'f-body', recordId: 7, source: SOURCE }, + global: { provide: { [FORM_ENABLED_LOCALES as symbol]: ref(['pl', 'en']), [FORM_LOCALE as symbol]: ref('pl') } }, + }) + await flushPromises() + const el = wrapper.find('acme-demo-notesml').element as AcmeDemoNotesml + expect(el.getAttribute('locale')).toBe('pl') + expect(el.value).toBe('Polski') + expect(el.getAttribute('record-id')).toBe('7') + + await wrapper.find('select[data-ml-locale]').setValue('en') + await flushPromises() + expect(el.getAttribute('locale')).toBe('en') + expect(el.value).toBe('English') + + el.edit('English 2') + expect(wrapper.emitted('update:modelValue')).toEqual([[{ pl: 'Polski', en: 'English 2' }]]) + }) + + it('is a locale map in the form state helpers', () => { + expect(isMLField(mlField)).toBe(true) + expect(isMLField(notesField())).toBe(false) + expect(isMLField({ type: 'notesml', translatable: true })).toBe(false) + expect(initialValues([mlField], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } }) + expect(editablePayload([mlField, notesField()], { body: { pl: 'P', en: 'E', xx: 3 }, notes: { blocks: [] } })).toEqual({ + body: { pl: 'P', en: 'E' }, + notes: { blocks: [] }, + }) + expect(recordDisplayName([mlField], { body: { pl: 'Tytuł', en: 'Title' } }, ['pl', 'en'])).toBe('Tytuł') + }) +}) diff --git a/admin/tests/form/formState.test.ts b/admin/tests/form/formState.test.ts index 1c1e83f..00f246d 100644 --- a/admin/tests/form/formState.test.ts +++ b/admin/tests/form/formState.test.ts @@ -8,6 +8,7 @@ import { focusField, initialValues, mergeMLValue, + noticeLines, panelDomId, presetUpdates, presetValue, @@ -302,3 +303,24 @@ describe('recordDisplayName', () => { expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post') }) }) + +describe('noticeLines', () => { + it('maps notices to lines per field, prefixing the content locale', () => { + expect( + noticeLines([ + { field: 'content', locale: 'pl', message: '2 elements were removed: style, iframe' }, + { field: 'content', locale: 'en', message: 'A script was removed' }, + { field: 'notes', message: 'Trimmed' }, + ]), + ).toEqual({ + content: ['PL: 2 elements were removed: style, iframe', 'EN: A script was removed'], + notes: ['Trimmed'], + }) + }) + + it('is empty without notices', () => { + expect(noticeLines(undefined)).toEqual({}) + expect(noticeLines([])).toEqual({}) + expect(noticeLines([{ field: 'x', message: '' }])).toEqual({}) + }) +}) diff --git a/admin/tests/list/CellValue.test.ts b/admin/tests/list/CellValue.test.ts index 1fa0b90..03eaaf6 100644 --- a/admin/tests/list/CellValue.test.ts +++ b/admin/tests/list/CellValue.test.ts @@ -17,6 +17,14 @@ beforeEach(() => { resetState() }) +describe('plugin field type columns (16.2)', () => { + it('renders the ListValue text of a plugin type as plain text', () => { + const wrapper = cell({ ...text, type: 'notes' }, '3 blocks') + expect(wrapper.text()).toBe('3 blocks') + expect(wrapper.find('b').exists()).toBe(false) + }) +}) + describe('cell value (D-12)', () => { it.each([ ['a string', 'Blue', 'Blue'], diff --git a/docs/backend/field-types.md b/docs/backend/field-types.md index fb2aad3..03b30c0 100644 --- a/docs/backend/field-types.md +++ b/docs/backend/field-types.md @@ -122,11 +122,19 @@ A type that stores files (images in a document, say) sets `Upload`. The element ## The element -The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else: +The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else. The element contract: -- The `value` property holds the field's value: any JSON value for a type that is not translatable. -- The attributes `field-name`, `record-id` (empty on the create form), `locale` (empty for a type that is not translatable), `ui-locale` (the admin's interface language), `options` (the JSON of `typeOptions`), `disabled` (present when the field is read-only) and `invalid` (present while the field has an error). -- The element dispatches a bubbling `summer-change` event with `detail.value` when the administrator edits the value, and only then; the admin takes `detail.value` as the field's new value. -- The admin sets `value` only when the form value differs from the element's current `value`, so setting the value the element just emitted never resets it. An element must still accept a `value` it did not emit, such as the stored value after a save. +- The `value` property holds the field's value: a string (the active locale's text) for a translatable type, any JSON value otherwise. +- The attributes are `field-name`; `record-id`, empty on the create form; `locale`, the content locale of the value for a translatable type and empty otherwise; `ui-locale`, the admin's interface language; `options`, the JSON of the field's `typeOptions`; `disabled`, present when the field is read-only (on a preview screen, for example); and `invalid`, present while the field has an error. +- `summer-change` is a bubbling `CustomEvent` the element dispatches when the administrator edits the value, and only then. The admin takes `detail.value` as the field's new value; for a translatable type it replaces only the active locale's string in the locale map. +- `summer-upload` is a bubbling `CustomEvent` the element dispatches to upload a file, with `detail` `{id, file}`: an id of the element's choosing and the `File`. The admin posts the file with the administrator's session to the field's [upload route](#uploads) and answers by dispatching `summer-upload-result` on the element, with `detail` `{id, url}` on success or `{id, error}` with a message to show (the server's message for a refused file, a generic one for a file that is too large or a network failure). +- The admin sets `value` only when the form value differs from the element's current `value` (compared by JSON for objects), so setting the value the element just emitted never resets it. The element must still accept a `value` it did not emit: the stored value after a save, which may differ from what it emitted when `Decode` changed it, and the other locale's text when the administrator switches locales (the admin sets `locale` first, then `value`). +- An element should not re-serialize a value the administrator did not edit, so an untouched value stays equal to the one it was served and the save skips it. -The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own. It styles itself only through the admin's public `--c-*` CSS variables, with every selector scoped under its own tag, because its stylesheet is enabled on every admin page. +The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own: the admin owns HTTP and the session. It styles itself only through the admin's public `--c-*` CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists, which switch with dark mode), with every selector scoped under its own tag, because its stylesheet is enabled on every admin page. A type whose script fails to load, or whose tag is not defined within five seconds, shows the admin's failure box in place of the field. + +A translatable type renders inside the admin's locale switcher, the same one `mlmarkdown` uses: one element edits the active locale, and switching locales changes its `locale` attribute and its `value`. + +### How the admin shows notices + +After a save whose response carries `meta.notices`, the admin shows each message under its field as a warning status line, prefixed with the upper-case locale for a translatable type (`PL: 2 elements were removed: style, iframe`), and appends the messages to the saved toast, so "Save and close", which leaves the form, still tells the administrator. The lines go away on the next save attempt. The admin then reloads the stored values from the response into the form, and the element receives the stored value. diff --git a/modules/boardwalk/dist/assets/index-By4vm3Ku.js b/modules/boardwalk/dist/assets/index-By4vm3Ku.js deleted file mode 100644 index 4e8e822..0000000 --- a/modules/boardwalk/dist/assets/index-By4vm3Ku.js +++ /dev/null @@ -1,9 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))a(r);new MutationObserver(r=>{for(const l of r)if(l.type==="childList")for(const o of l.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&a(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const l={};return r.integrity&&(l.integrity=r.integrity),r.referrerPolicy&&(l.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?l.credentials="include":r.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function a(r){if(r.ep)return;r.ep=!0;const l=n(r);fetch(r.href,l)}})();function Yu(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const nt={},br=[],kn=()=>{},jp=()=>!1,ss=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),is=e=>e.startsWith("onUpdate:"),ct=Object.assign,Ju=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},ug=Object.prototype.hasOwnProperty,et=(e,t)=>ug.call(e,t),Ie=Array.isArray,_r=e=>zl(e)==="[object Map]",zp=e=>zl(e)==="[object Set]",hd=e=>zl(e)==="[object Date]",je=e=>typeof e=="function",lt=e=>typeof e=="string",Ht=e=>typeof e=="symbol",Qe=e=>e!==null&&typeof e=="object",Up=e=>(Qe(e)||je(e))&&je(e.then)&&je(e.catch),Hp=Object.prototype.toString,zl=e=>Hp.call(e),cg=e=>zl(e).slice(8,-1),Kp=e=>zl(e)==="[object Object]",us=e=>lt(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,dl=Yu(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),cs=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},dg=/-\w/g,bt=cs(e=>e.replace(dg,t=>t.slice(1).toUpperCase())),fg=/\B([A-Z])/g,wa=cs(e=>e.replace(fg,"-$1").toLowerCase()),ds=cs(e=>e.charAt(0).toUpperCase()+e.slice(1)),fl=cs(e=>e?`on${ds(e)}`:""),yn=(e,t)=>!Object.is(e,t),So=(e,...t)=>{for(let n=0;nJ<=0)){var K,T;const J=(((K=l.flip)==null?void 0:K.index)||0)+1,O=z[J];if(O&&(!(f==="alignment"?k!==bn(O):!1)||P.every(ce=>bn(ce.placement)===k?ce.overflows[0]>0:!0)))return{data:{index:J,overflows:P},reset:{placement:O}};let ne=(T=P.filter(X=>X.overflows[0]<=0).sort((X,ce)=>X.overflows[1]-ce.overflows[1])[0])==null?void 0:T.placement;if(!ne)switch(v){case"bestFit":{var R;const X=(R=P.filter(ce=>{if(W){const ge=bn(ce.placement);return ge===k||ge==="y"}return!0}).map(ce=>[ce.placement,ce.overflows.filter(ge=>ge>0).reduce((ge,be)=>ge+be,0)]).sort((ce,ge)=>ce[1]-ge[1])[0])==null?void 0:R[0];X&&(ne=X);break}case"initialPlacement":ne=s;break}if(r!==ne)return{reset:{placement:ne}}}return{}}}};function hp(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function mp(e){return ZC.some(t=>e[t]>=0)}const d3=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){const{rects:n,platform:a}=t,{strategy:r="referenceHidden",...l}=Gn(e,t);switch(r){case"referenceHidden":{const o=await a.detectOverflow(t,{...l,elementContext:"reference"}),s=hp(o,n.reference);return{data:{referenceHiddenOffsets:s,referenceHidden:mp(s)}}}case"escaped":{const o=await a.detectOverflow(t,{...l,altBoundary:!0}),s=hp(o,n.floating);return{data:{escapedOffsets:s,escaped:mp(s)}}}default:return{}}}}},Wm=new Set(["left","top"]);async function f3(e,t){const{placement:n,platform:a,elements:r}=e,l=await(a.isRTL==null?void 0:a.isRTL(r.floating)),o=_a(n),s=zr(n),i=bn(n)==="y",c=Wm.has(o)?-1:1,d=l&&i?-1:1,f=Gn(t,e);let{mainAxis:p,crossAxis:v,alignmentAxis:m}=typeof f=="number"?{mainAxis:f,crossAxis:0,alignmentAxis:null}:{mainAxis:f.mainAxis||0,crossAxis:f.crossAxis||0,alignmentAxis:f.alignmentAxis};return s&&typeof m=="number"&&(v=s==="end"?m*-1:m),i?{x:v*d,y:p*c}:{x:p*c,y:v*d}}const p3=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var n,a;const{x:r,y:l,placement:o,middlewareData:s}=t,i=await f3(t,e);return o===((n=s.offset)==null?void 0:n.placement)&&(a=s.arrow)!=null&&a.alignmentOffset?{}:{x:r+i.x,y:l+i.y,data:{...i,placement:o}}}}},v3=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){const{x:n,y:a,placement:r,platform:l}=t,{mainAxis:o=!0,crossAxis:s=!1,limiter:i={fn:k=>{let{x:C,y:S}=k;return{x:C,y:S}}},...c}=Gn(e,t),d={x:n,y:a},f=await l.detectOverflow(t,c),p=bn(r),v=zc(p);let m=d[v],b=d[p];const g=(k,C)=>Hm(C+f[k==="y"?"top":"left"],C,C-f[k==="y"?"bottom":"right"]);o&&(m=g(v,m)),s&&(b=g(p,b));const y=i.fn({...t,[v]:m,[p]:b});return{...y,data:{x:y.x-n,y:y.y-a,enabled:{[v]:o,[p]:s}}}}}},h3=function(e){return e===void 0&&(e={}),{options:e,fn(t){var n,a;const{x:r,y:l,placement:o,rects:s,middlewareData:i}=t,{offset:c=0,mainAxis:d=!0,crossAxis:f=!0}=Gn(e,t),p={x:r,y:l},v=bn(o),m=zc(v);let b=p[m],g=p[v];const y=Gn(c,t),k=typeof y=="number"?{mainAxis:y,crossAxis:0}:{mainAxis:(n=y.mainAxis)!=null?n:0,crossAxis:(a=y.crossAxis)!=null?a:0};if(d){const B=m==="y"?"height":"width",W=s.reference[m]-s.floating[B]+k.mainAxis,z=s.reference[m]+s.reference[B]-k.mainAxis;b