@autobusal/common 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,18 +39,18 @@ const AiFieldAssist = ({ text, sourceText, targetLocale, onInsert, t }: Props):
39
39
  <>
40
40
  <Trigger>
41
41
  <button type="button" onClick={ () => setMode('generate') }>
42
- { t('ai_assist.trigger.generate') }
42
+ { t('ai_assist.trigger.generate', { ns: 'common' }) }
43
43
  </button>
44
44
 
45
45
  { hasText && (
46
46
  <button type="button" onClick={ () => setMode('improve') }>
47
- { t('ai_assist.trigger.improve') }
47
+ { t('ai_assist.trigger.improve', { ns: 'common' }) }
48
48
  </button>
49
49
  ) }
50
50
 
51
51
  { canTranslate && (
52
52
  <button type="button" onClick={ () => setMode('translate') }>
53
- { t('ai_assist.trigger.translate') }
53
+ { t('ai_assist.trigger.translate', { ns: 'common' }) }
54
54
  </button>
55
55
  ) }
56
56
  </Trigger>
@@ -2,7 +2,7 @@ import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import Modal from '../Modal/Modal';
4
4
  import { useAiAvailable, useAiGenerate, useAiTranslate, useAiImprove } from './services';
5
- import { Row, Field, Compare, Pane, Draft, Actions, Error } from './styles';
5
+ import { Row, Field, Compare, Pane, Draft, Actions } from './styles';
6
6
 
7
7
  interface Props {
8
8
  mode: 'generate' | 'improve' | 'translate'
@@ -25,6 +25,17 @@ interface Props {
25
25
  * Ferjolt Ozuni - Date: 2026-08-08
26
26
  */
27
27
  const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClose, t }: Props): JSX.Element => {
28
+ // Edited: Ferjolt Ozuni - Date: 2026-08-08
29
+ // Every ai_assist.* key lives in the 'common' namespace (obtapi's
30
+ // public/languages/{locale}/common.json), but the `t` this component
31
+ // receives is whatever namespace the CALLING page's own useTranslation()
32
+ // defaulted to (admin-pages/admin-label both default to 'normal') - a
33
+ // bare t('ai_assist.x') looked the key up in the wrong namespace, found
34
+ // nothing, and i18next's fallback is to render the raw key string. Every
35
+ // OTHER cross-namespace lookup in this codebase passes {ns:'common'}
36
+ // per call; this wrapper does it once instead of thirteen times.
37
+ const tc = (key: string): string => t(key, { ns: 'common' });
38
+
28
39
  const { data: available, isLoading: loadingAvailable } = useAiAvailable();
29
40
 
30
41
  const providers = Object.keys(available ?? {});
@@ -33,7 +44,6 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
33
44
  const [ model, setModel ] = useState<string>('');
34
45
  const [ instruction, setInstruction ] = useState<string>('');
35
46
  const [ draft, setDraft ] = useState<string | null>(null);
36
- const [ error, setError ] = useState<string | null>(null);
37
47
 
38
48
  const activeProvider = provider || providers[0] || '';
39
49
  const activeModel = model || available?.[activeProvider]?.default_model || '';
@@ -44,10 +54,17 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
44
54
 
45
55
  const running = generating || translating || improving;
46
56
 
57
+ // Edited: Ferjolt Ozuni - Date: 2026-08-08
58
+ // No local onError/error state here - apiClient's own response
59
+ // interceptor already surfaces the real backend message as a toast on
60
+ // any 422 (obtapi's Ai\AdminController returns provider errors that
61
+ // way). A local onError reading a plain react-query Error's .message
62
+ // would only ever see axios's generic "Request failed with status code
63
+ // 422", never the actual "Incorrect API key provided..." the backend
64
+ // sent - found live, the modal showed exactly that generic text while
65
+ // the real message went to the toast every other form in this app
66
+ // already relies on.
47
67
  const onRun = (): void => {
48
- setError(null);
49
-
50
- const onError = (err: Error): void => setError(err.message);
51
68
  const onSuccess = (result: { content: string }): void => {
52
69
  setDraft(result.content);
53
70
  setInstruction('');
@@ -58,13 +75,13 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
58
75
  // endpoint that takes existing text + an instruction, which is exactly
59
76
  // what a revision is.
60
77
  if (draft !== null) {
61
- Improve({ provider: activeProvider, model: activeModel, text: draft, instruction }, { onSuccess, onError });
78
+ Improve({ provider: activeProvider, model: activeModel, text: draft, instruction }, { onSuccess });
62
79
 
63
80
  return;
64
81
  }
65
82
 
66
83
  if (mode === 'generate') {
67
- Generate({ provider: activeProvider, model: activeModel, prompt: instruction }, { onSuccess, onError });
84
+ Generate({ provider: activeProvider, model: activeModel, prompt: instruction }, { onSuccess });
68
85
 
69
86
  return;
70
87
  }
@@ -76,12 +93,12 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
76
93
  text: text ?? '',
77
94
  target_locale: targetLocale ?? '',
78
95
  source_locale: sourceLocale
79
- }, { onSuccess, onError });
96
+ }, { onSuccess });
80
97
 
81
98
  return;
82
99
  }
83
100
 
84
- Improve({ provider: activeProvider, model: activeModel, text: text ?? '', instruction }, { onSuccess, onError });
101
+ Improve({ provider: activeProvider, model: activeModel, text: text ?? '', instruction }, { onSuccess });
85
102
  };
86
103
 
87
104
  const onInsertClick = (): void => {
@@ -94,11 +111,9 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
94
111
 
95
112
  const content = (
96
113
  <>
97
- { error && <Error>{ error }</Error> }
98
-
99
114
  <Row>
100
115
  <Field>
101
- { t('ai_assist.provider') }
116
+ { tc('ai_assist.provider') }
102
117
 
103
118
  <select value={ activeProvider } onChange={ event => { setProvider(event.target.value); setModel(''); } }>
104
119
  { providers.map(key => <option key={ key } value={ key }>{ key }</option>) }
@@ -106,7 +121,7 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
106
121
  </Field>
107
122
 
108
123
  <Field>
109
- { t('ai_assist.model') }
124
+ { tc('ai_assist.model') }
110
125
 
111
126
  <select value={ activeModel } onChange={ event => setModel(event.target.value) }>
112
127
  { (available?.[activeProvider]?.models ?? []).map(name => <option key={ name } value={ name }>{ name }</option>) }
@@ -118,14 +133,14 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
118
133
  <Row>
119
134
  <Field>
120
135
  { draft !== null
121
- ? t('ai_assist.revise_instruction')
122
- : (mode === 'generate' ? t('ai_assist.generate_prompt') : t('ai_assist.improve_instruction')) }
136
+ ? tc('ai_assist.revise_instruction')
137
+ : (mode === 'generate' ? tc('ai_assist.generate_prompt') : tc('ai_assist.improve_instruction')) }
123
138
 
124
139
  <input
125
140
  type="text"
126
141
  value={ instruction }
127
142
  onChange={ event => setInstruction(event.target.value) }
128
- placeholder={ draft !== null ? t('ai_assist.revise_placeholder') : undefined }
143
+ placeholder={ draft !== null ? tc('ai_assist.revise_placeholder') : undefined }
129
144
  />
130
145
  </Field>
131
146
  </Row>
@@ -133,7 +148,7 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
133
148
 
134
149
  { draft === null && mode === 'translate' && (
135
150
  <Pane>
136
- <h4>{ t('ai_assist.source') }</h4>
151
+ <h4>{ tc('ai_assist.source') }</h4>
137
152
 
138
153
  <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
139
154
  </Pane>
@@ -142,13 +157,13 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
142
157
  { draft !== null && mode === 'translate' && (
143
158
  <Compare>
144
159
  <Pane>
145
- <h4>{ t('ai_assist.source') }</h4>
160
+ <h4>{ tc('ai_assist.source') }</h4>
146
161
 
147
162
  <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
148
163
  </Pane>
149
164
 
150
165
  <Pane>
151
- <h4>{ t('ai_assist.draft') }</h4>
166
+ <h4>{ tc('ai_assist.draft') }</h4>
152
167
 
153
168
  <div dangerouslySetInnerHTML={ { __html: draft } } />
154
169
  </Pane>
@@ -161,16 +176,16 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
161
176
 
162
177
  <Actions>
163
178
  <button type="button" onClick={ onClose } disabled={ running }>
164
- { t('ai_assist.cancel') }
179
+ { tc('ai_assist.cancel') }
165
180
  </button>
166
181
 
167
182
  <button type="button" onClick={ onRun } disabled={ running || !activeProvider || (mode !== 'translate' && draft === null && instruction.trim() === '') }>
168
- { draft !== null ? t('ai_assist.revise') : t('ai_assist.run') }
183
+ { draft !== null ? tc('ai_assist.revise') : tc('ai_assist.run') }
169
184
  </button>
170
185
 
171
186
  { draft !== null && (
172
187
  <button type="button" onClick={ onInsertClick } disabled={ running }>
173
- { t('ai_assist.insert') }
188
+ { tc('ai_assist.insert') }
174
189
  </button>
175
190
  ) }
176
191
  </Actions>
@@ -181,7 +196,7 @@ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClo
181
196
  <Modal
182
197
  loading={ loadingAvailable }
183
198
  width={ 720 }
184
- title={ t(`ai_assist.title.${ mode }`) }
199
+ title={ tc(`ai_assist.title.${ mode }`) }
185
200
  content={ content }
186
201
  onClose={ onClose }
187
202
  />
package/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## 1.33.0
5
+
6
+ ### Fixed
7
+
8
+ - **`Viewer`'s `type: 'component'` rows never actually rendered, anywhere.** `Data.tsx` returned `null` for any item whose `name` was `undefined` before the switch statement that handled `'component'` ever ran - and a component row never HAS a name, it isn't a registered form field. Every SectionHeading divider, every `AiFieldAssist` Generate/Improve/Translate trigger, and every description paragraph moved inside a form's own box (SMS/Flex, this cycle) had silently never rendered, in any tab that used one, despite compiling clean and sitting right there in the bundle. Moved the `'component'` case to an early return above the name guard instead of weakening the guard's condition, which would have broken TypeScript's narrowing of `item.name` to `string` for every other case below it.
9
+ - **AI trigger buttons and the review modal rendered raw i18n keys** (`ai_assist.trigger.generate`) instead of translated text. `ai_assist.*` lives in the `common` namespace, but the `t` passed into `AiFieldAssist`/`AiReviewModal` defaults to whatever namespace the calling page's own `useTranslation()` uses (`normal`/`whitelabel`) - every call now passes `{ ns: 'common' }`.
10
+ - **`AiReviewModal` showed a generic "Request failed with status code 422"** instead of the real provider error on a failed generate/translate/improve. Removed its local error state/display entirely - `apiClient`'s existing global response interceptor already toasts `error.response.data.message` on any 422, the same mechanism every other form in the app already relies on; a local `onError` reading react-query's plain `Error.message` could only ever see Axios's generic text, never the backend's.
11
+
12
+ ## 1.32.0
13
+
14
+ ### Added
15
+
16
+ - **Drag & drop on `File`**, the shared upload control used everywhere in the app - drop a file directly onto it instead of always going through the native picker. The control's own footprint (a single 38px-tall inline button) is unchanged everywhere it is already used; only a border/background highlight appears while dragging. A dropped file is handed to the same hidden `<input>` react-hook-form already registered, via a real dispatched `change` event, so every existing caller's validation/onChange keeps working with no changes on their end.
17
+
18
+ ### Fixed
19
+
20
+ - **A checkbox row with a long notice no longer stretches sideways.** `.row-checkbox`'s label+input are laid out `flex-direction: row-reverse`, and the row's own notice/error text (a plain div) was sharing that same row - a short notice fit fine, but a longer one (two sentences, easily) stretched the row's WIDTH to fit on one line, visibly pushing the checkbox by however long the text happened to be. Every div child of a checkbox row now gets `flex-basis: 100%`, dropping straight onto its own full-width line; the label and input are unaffected.
21
+
4
22
  ## 1.31.0
5
23
 
6
24
  ### Added
package/File/File.tsx CHANGED
@@ -19,6 +19,7 @@ interface Props {
19
19
  const File = ({ name, value, validation, multiple, t, refs }: Props): JSX.Element => {
20
20
  const [ uploaded, setUploaded ] = useState<boolean>(false);
21
21
  const [ preview, setPreview ] = useState<boolean>(false);
22
+ const [ dragging, setDragging ] = useState<boolean>(false);
22
23
 
23
24
  const inputRef = useRef<HTMLInputElement | null>(null);
24
25
 
@@ -46,11 +47,53 @@ const File = ({ name, value, validation, multiple, t, refs }: Props): JSX.Elemen
46
47
  }
47
48
  };
48
49
 
50
+ /**
51
+ * Ferjolt Ozuni - Date: 2026-08-08
52
+ *
53
+ * A dropped file has to land on the actual hidden <input> - it is the
54
+ * one react-hook-form registered via refs(name, rules), and that
55
+ * registration is what onSave ultimately reads. The DataTransfer/
56
+ * dispatchEvent pair is the standard way to hand a File to a native
57
+ * file input programmatically: assigning to `.files` directly is
58
+ * read-only on the input itself, but an input accepts a `FileList`
59
+ * built via DataTransfer, and dispatching a real 'change' event (not
60
+ * calling the onChange prop directly) is what makes React's own
61
+ * synthetic-event system - and therefore RHF's registration - see it,
62
+ * exactly as if the browser's own file picker had fired it.
63
+ */
64
+ const onDrop = (event: React.DragEvent<HTMLDivElement>): void => {
65
+ event.preventDefault();
66
+ setDragging(false);
67
+
68
+ const dropped = event.dataTransfer.files;
69
+
70
+ if (!inputRef.current || dropped.length === 0) {
71
+ return;
72
+ }
73
+
74
+ const transfer = new DataTransfer();
75
+
76
+ Array.from(multiple ? dropped : [ dropped[0] ]).forEach(file => transfer.items.add(file));
77
+
78
+ inputRef.current.files = transfer.files;
79
+ inputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
80
+ };
81
+
82
+ const onDragOver = (event: React.DragEvent<HTMLDivElement>): void => {
83
+ event.preventDefault();
84
+ setDragging(true);
85
+ };
86
+
49
87
  const type = multiple ? 'multiple' : 'single';
50
88
 
51
89
  return (
52
90
  <>
53
- <Container>
91
+ <Container
92
+ $dragging={ dragging }
93
+ onDragOver={ onDragOver }
94
+ onDragLeave={ () => setDragging(false) }
95
+ onDrop={ onDrop }
96
+ >
54
97
  <input
55
98
  { ...rest }
56
99
  type="file"
package/File/styles.ts CHANGED
@@ -1,11 +1,27 @@
1
- import styled from 'styled-components';
1
+ import styled, { css } from 'styled-components';
2
2
 
3
- export const Container = styled.div`
3
+ /**
4
+ * Ferjolt Ozuni - Date: 2026-08-08
5
+ * `$dragging` only changes the border/background while a file is being
6
+ * dragged over it - the box itself stays the same 38px-tall inline
7
+ * control everywhere this component is already used (tables, dense
8
+ * forms), rather than growing into a big dropzone that would only fit
9
+ * some of its callers.
10
+ */
11
+ export const Container = styled.div<{ $dragging?: boolean }>`
4
12
  display: flex;
5
13
  align-items: center;
6
14
  justify-content: space-between;
7
15
  gap: 10px;
8
16
  height: 38px;
17
+ border-radius: ${ props => props.theme.borderRadius };
18
+ transition: background 0.15s ease;
19
+
20
+ ${ props => props.$dragging && css`
21
+ background: ${ props.theme.primary.neutral };
22
+ outline: 2px dashed ${ props.theme.primary.normal };
23
+ outline-offset: 2px;
24
+ ` }
9
25
  `;
10
26
 
11
27
  export const ImgView = styled.img`
package/Viewer/Data.tsx CHANGED
@@ -21,6 +21,29 @@ interface Props {
21
21
  }
22
22
 
23
23
  const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
24
+ // Edited: Ferjolt Ozuni - Date: 2026-08-08
25
+ // A 'component' row is not a registered form field - it never had a
26
+ // name to give, and the name-required guard below (meant to stop every
27
+ // OTHER type from registering with react-hook-form under an undefined
28
+ // key) was silently returning null for every one of them before ever
29
+ // reaching the 'component' case that used to sit in the switch further
30
+ // down. Found live: a whole category of content - SectionHeading
31
+ // dividers, AiFieldAssist's Generate/Improve/Translate triggers,
32
+ // description text moved inside a form's own box - had never actually
33
+ // rendered, in any of the tabs that used it, despite compiling clean
34
+ // and being right there in the production bundle. Handled here, before
35
+ // the name guard, rather than by weakening that guard's condition -
36
+ // TypeScript narrows item.name to a definite string for every case
37
+ // below FROM the guard, and a conditional guard cannot narrow it the
38
+ // same way.
39
+ if (item.type === 'component') {
40
+ return (
41
+ <div className="row-data">
42
+ { typeof item.value === 'function' ? item.value(onUpdate) : item.value }
43
+ </div>
44
+ );
45
+ }
46
+
24
47
  if (item.name === undefined) {
25
48
  return null;
26
49
  }
@@ -65,13 +88,6 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
65
88
  />
66
89
  );
67
90
 
68
- case 'component':
69
- return (
70
- <div className="row-data">
71
- { typeof item.value === 'function' ? item.value(onUpdate) : item.value }
72
- </div>
73
- );
74
-
75
91
  case 'display':
76
92
  return <Display className="row-data">{ value.string }</Display>;
77
93
 
package/Viewer/styles.ts CHANGED
@@ -3,8 +3,25 @@ import styled from 'styled-components';
3
3
  export const Container = styled.div`
4
4
  & > div.row-checkbox {
5
5
  flex-direction: row-reverse;
6
+ flex-wrap: wrap;
6
7
  justify-content: left;
7
- height: 20px;
8
+ min-height: 20px;
9
+
10
+ /*
11
+ * Edited: Ferjolt Ozuni - Date: 2026-08-08
12
+ * A checkbox row's own notice/error text is a DIV (Notice, Item's
13
+ * error Display) sharing this row's flex layout with the label
14
+ * (a span) and the checkbox input - row-reverse was fine for just
15
+ * those two, but any row with a real notice attached (Construction's
16
+ * "enabled" checkbox is two full sentences) stretched the row's WIDTH
17
+ * to fit the text on one line instead of wrapping it, visibly pushing
18
+ * the checkbox sideways by however long the notice happened to be.
19
+ * Forcing every div child onto its own full-width line - the label
20
+ * and the input stay row-reversed exactly as before.
21
+ */
22
+ & > div {
23
+ flex-basis: 100%;
24
+ }
8
25
  }
9
26
 
10
27
  @media (min-width: 540px) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"