@autobusal/common 1.27.5 → 1.29.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.
@@ -0,0 +1,72 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useAiAvailable } from './services';
4
+ import AiReviewModal from './AiReviewModal';
5
+ import { Trigger } from './styles';
6
+
7
+ interface Props {
8
+ /** The field's own current value - the base text for "Improve". */
9
+ text?: string
10
+ /** The English source text, for a "Translate from EN" trigger. */
11
+ sourceText?: string
12
+ /** This field's locale, e.g. "sq" - required for the translate trigger. */
13
+ targetLocale?: string
14
+ onInsert: (content: string) => void
15
+ t: TFunction<'common'>
16
+ }
17
+
18
+ /**
19
+ * Sits next to one content field (a 'component'-type Viewer row - see
20
+ * Data.tsx's render-prop support for that type) and renders nothing at all
21
+ * when no AI provider is enabled for this label, which is the whole
22
+ * visibility gate the plan calls for - no separate flag to keep in sync.
23
+ *
24
+ * Ferjolt Ozuni - Date: 2026-08-08
25
+ */
26
+ const AiFieldAssist = ({ text, sourceText, targetLocale, onInsert, t }: Props): JSX.Element | null => {
27
+ const { data: available } = useAiAvailable();
28
+
29
+ const [ mode, setMode ] = useState<'generate' | 'improve' | 'translate' | null>(null);
30
+
31
+ if (!available || Object.keys(available).length === 0) {
32
+ return null;
33
+ }
34
+
35
+ const hasText = (text ?? '').trim() !== '';
36
+ const canTranslate = (sourceText ?? '').trim() !== '' && !!targetLocale;
37
+
38
+ return (
39
+ <>
40
+ <Trigger>
41
+ <button type="button" onClick={ () => setMode('generate') }>
42
+ { t('ai_assist.trigger.generate') }
43
+ </button>
44
+
45
+ { hasText && (
46
+ <button type="button" onClick={ () => setMode('improve') }>
47
+ { t('ai_assist.trigger.improve') }
48
+ </button>
49
+ ) }
50
+
51
+ { canTranslate && (
52
+ <button type="button" onClick={ () => setMode('translate') }>
53
+ { t('ai_assist.trigger.translate') }
54
+ </button>
55
+ ) }
56
+ </Trigger>
57
+
58
+ { mode && (
59
+ <AiReviewModal
60
+ mode={ mode }
61
+ text={ mode === 'translate' ? sourceText : text }
62
+ targetLocale={ targetLocale }
63
+ onInsert={ onInsert }
64
+ onClose={ () => setMode(null) }
65
+ t={ t }
66
+ />
67
+ ) }
68
+ </>
69
+ );
70
+ };
71
+
72
+ export default AiFieldAssist;
@@ -0,0 +1,191 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import Modal from '../Modal/Modal';
4
+ import { useAiAvailable, useAiGenerate, useAiTranslate, useAiImprove } from './services';
5
+ import { Row, Field, Compare, Pane, Draft, Actions, Error } from './styles';
6
+
7
+ interface Props {
8
+ mode: 'generate' | 'improve' | 'translate'
9
+ text?: string
10
+ targetLocale?: string
11
+ sourceLocale?: string
12
+ onInsert: (content: string) => void
13
+ onClose: () => void
14
+ t: TFunction<'common'>
15
+ }
16
+
17
+ /**
18
+ * The one AI-review surface every content admin form shares - a draft
19
+ * NEVER lands in a field on its own. It renders in here first, the admin
20
+ * can hand-edit it or ask for a revision (which re-runs improve() with the
21
+ * CURRENT draft as the base text, regardless of what mode this opened in),
22
+ * and only an explicit Insert writes it back to the caller's field. Cancel
23
+ * discards everything; the form's own Save is a separate, second step.
24
+ *
25
+ * Ferjolt Ozuni - Date: 2026-08-08
26
+ */
27
+ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClose, t }: Props): JSX.Element => {
28
+ const { data: available, isLoading: loadingAvailable } = useAiAvailable();
29
+
30
+ const providers = Object.keys(available ?? {});
31
+
32
+ const [ provider, setProvider ] = useState<string>('');
33
+ const [ model, setModel ] = useState<string>('');
34
+ const [ instruction, setInstruction ] = useState<string>('');
35
+ const [ draft, setDraft ] = useState<string | null>(null);
36
+ const [ error, setError ] = useState<string | null>(null);
37
+
38
+ const activeProvider = provider || providers[0] || '';
39
+ const activeModel = model || available?.[activeProvider]?.default_model || '';
40
+
41
+ const { mutate: Generate, isPending: generating } = useAiGenerate();
42
+ const { mutate: Translate, isPending: translating } = useAiTranslate();
43
+ const { mutate: Improve, isPending: improving } = useAiImprove();
44
+
45
+ const running = generating || translating || improving;
46
+
47
+ const onRun = (): void => {
48
+ setError(null);
49
+
50
+ const onError = (err: Error): void => setError(err.message);
51
+ const onSuccess = (result: { content: string }): void => {
52
+ setDraft(result.content);
53
+ setInstruction('');
54
+ };
55
+
56
+ // Once a draft exists, EVERY run (regardless of the mode this modal
57
+ // opened in) is a revision on that draft - improve() is the one
58
+ // endpoint that takes existing text + an instruction, which is exactly
59
+ // what a revision is.
60
+ if (draft !== null) {
61
+ Improve({ provider: activeProvider, model: activeModel, text: draft, instruction }, { onSuccess, onError });
62
+
63
+ return;
64
+ }
65
+
66
+ if (mode === 'generate') {
67
+ Generate({ provider: activeProvider, model: activeModel, prompt: instruction }, { onSuccess, onError });
68
+
69
+ return;
70
+ }
71
+
72
+ if (mode === 'translate') {
73
+ Translate({
74
+ provider: activeProvider,
75
+ model: activeModel,
76
+ text: text ?? '',
77
+ target_locale: targetLocale ?? '',
78
+ source_locale: sourceLocale
79
+ }, { onSuccess, onError });
80
+
81
+ return;
82
+ }
83
+
84
+ Improve({ provider: activeProvider, model: activeModel, text: text ?? '', instruction }, { onSuccess, onError });
85
+ };
86
+
87
+ const onInsertClick = (): void => {
88
+ if (draft !== null) {
89
+ onInsert(draft);
90
+ }
91
+
92
+ onClose();
93
+ };
94
+
95
+ const content = (
96
+ <>
97
+ { error && <Error>{ error }</Error> }
98
+
99
+ <Row>
100
+ <Field>
101
+ { t('ai_assist.provider') }
102
+
103
+ <select value={ activeProvider } onChange={ event => { setProvider(event.target.value); setModel(''); } }>
104
+ { providers.map(key => <option key={ key } value={ key }>{ key }</option>) }
105
+ </select>
106
+ </Field>
107
+
108
+ <Field>
109
+ { t('ai_assist.model') }
110
+
111
+ <select value={ activeModel } onChange={ event => setModel(event.target.value) }>
112
+ { (available?.[activeProvider]?.models ?? []).map(name => <option key={ name } value={ name }>{ name }</option>) }
113
+ </select>
114
+ </Field>
115
+ </Row>
116
+
117
+ { mode !== 'translate' && (
118
+ <Row>
119
+ <Field>
120
+ { draft !== null
121
+ ? t('ai_assist.revise_instruction')
122
+ : (mode === 'generate' ? t('ai_assist.generate_prompt') : t('ai_assist.improve_instruction')) }
123
+
124
+ <input
125
+ type="text"
126
+ value={ instruction }
127
+ onChange={ event => setInstruction(event.target.value) }
128
+ placeholder={ draft !== null ? t('ai_assist.revise_placeholder') : undefined }
129
+ />
130
+ </Field>
131
+ </Row>
132
+ ) }
133
+
134
+ { draft === null && mode === 'translate' && (
135
+ <Pane>
136
+ <h4>{ t('ai_assist.source') }</h4>
137
+
138
+ <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
139
+ </Pane>
140
+ ) }
141
+
142
+ { draft !== null && mode === 'translate' && (
143
+ <Compare>
144
+ <Pane>
145
+ <h4>{ t('ai_assist.source') }</h4>
146
+
147
+ <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
148
+ </Pane>
149
+
150
+ <Pane>
151
+ <h4>{ t('ai_assist.draft') }</h4>
152
+
153
+ <div dangerouslySetInnerHTML={ { __html: draft } } />
154
+ </Pane>
155
+ </Compare>
156
+ ) }
157
+
158
+ { draft !== null && (
159
+ <Draft value={ draft } onChange={ event => setDraft(event.target.value) } />
160
+ ) }
161
+
162
+ <Actions>
163
+ <button type="button" onClick={ onClose } disabled={ running }>
164
+ { t('ai_assist.cancel') }
165
+ </button>
166
+
167
+ <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') }
169
+ </button>
170
+
171
+ { draft !== null && (
172
+ <button type="button" onClick={ onInsertClick } disabled={ running }>
173
+ { t('ai_assist.insert') }
174
+ </button>
175
+ ) }
176
+ </Actions>
177
+ </>
178
+ );
179
+
180
+ return (
181
+ <Modal
182
+ loading={ loadingAvailable }
183
+ width={ 720 }
184
+ title={ t(`ai_assist.title.${ mode }`) }
185
+ content={ content }
186
+ onClose={ onClose }
187
+ />
188
+ );
189
+ };
190
+
191
+ export default AiReviewModal;
@@ -0,0 +1,84 @@
1
+ import { useQuery, UseQueryResult, useMutation, UseMutationResult } from '@tanstack/react-query';
2
+ import { apiClient } from '@autobusal/providers';
3
+ import { AiAvailable, AiCompletion } from './types';
4
+
5
+ /**
6
+ * The providers+models this label may actually use right now - empty means
7
+ * no provider is enabled, which is the signal every AI-assist entry point
8
+ * hides itself on. Cached briefly (not Infinity like the settings query -
9
+ * this is admin-only and an admin who just enabled a provider should not
10
+ * have to reload the whole app to see it).
11
+ */
12
+ export const useAiAvailable = (): UseQueryResult<AiAvailable> => (
13
+ useQuery({
14
+ queryKey: [ 'ai-available' ],
15
+ queryFn: async () => (
16
+ await apiClient
17
+ .get('/api/ai/admin/available')
18
+ .then(response => (
19
+ response.data
20
+ ))
21
+ ),
22
+ staleTime: 60000
23
+ })
24
+ );
25
+
26
+ interface GenerateInput {
27
+ provider?: string
28
+ model?: string
29
+ prompt: string
30
+ }
31
+
32
+ export const useAiGenerate = (): UseMutationResult<AiCompletion, Error, GenerateInput, unknown> => (
33
+ useMutation({
34
+ mutationKey: [ 'ai-generate' ],
35
+ mutationFn: async (data: GenerateInput) => (
36
+ await apiClient
37
+ .post('/api/ai/admin/generate', data)
38
+ .then(response => (
39
+ response.data
40
+ ))
41
+ )
42
+ })
43
+ );
44
+
45
+ interface TranslateInput {
46
+ provider?: string
47
+ model?: string
48
+ text: string
49
+ target_locale: string
50
+ source_locale?: string
51
+ }
52
+
53
+ export const useAiTranslate = (): UseMutationResult<AiCompletion, Error, TranslateInput, unknown> => (
54
+ useMutation({
55
+ mutationKey: [ 'ai-translate' ],
56
+ mutationFn: async (data: TranslateInput) => (
57
+ await apiClient
58
+ .post('/api/ai/admin/translate', data)
59
+ .then(response => (
60
+ response.data
61
+ ))
62
+ )
63
+ })
64
+ );
65
+
66
+ interface ImproveInput {
67
+ provider?: string
68
+ model?: string
69
+ text: string
70
+ instruction: string
71
+ }
72
+
73
+ export const useAiImprove = (): UseMutationResult<AiCompletion, Error, ImproveInput, unknown> => (
74
+ useMutation({
75
+ mutationKey: [ 'ai-improve' ],
76
+ mutationFn: async (data: ImproveInput) => (
77
+ await apiClient
78
+ .post('/api/ai/admin/improve', data)
79
+ .then(response => (
80
+ response.data
81
+ ))
82
+ )
83
+ })
84
+ );
@@ -0,0 +1,94 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Trigger = styled.div`
4
+ display: flex;
5
+ gap: 6px;
6
+ margin: 6px 0 12px;
7
+
8
+ button {
9
+ font-size: ${ props => props.theme.size.xs };
10
+ padding: 4px 10px;
11
+ border-radius: ${ props => props.theme.borderRadius };
12
+ border: 1px solid ${ props => props.theme.inputs.border };
13
+ background: ${ props => props.theme.background.box };
14
+ cursor: pointer;
15
+
16
+ &:hover {
17
+ background: ${ props => props.theme.background.neutral };
18
+ }
19
+ }
20
+ `;
21
+
22
+ export const Row = styled.div`
23
+ display: flex;
24
+ gap: 10px;
25
+ align-items: flex-end;
26
+ margin-bottom: 14px;
27
+ flex-wrap: wrap;
28
+ `;
29
+
30
+ export const Field = styled.label`
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 4px;
34
+ font-size: ${ props => props.theme.size.s };
35
+ flex: 1;
36
+ min-width: 160px;
37
+
38
+ select, input, textarea {
39
+ padding: 8px 10px;
40
+ border: 1px solid ${ props => props.theme.inputs.border };
41
+ border-radius: ${ props => props.theme.borderRadius };
42
+ font-size: ${ props => props.theme.size.s };
43
+ }
44
+ `;
45
+
46
+ export const Compare = styled.div`
47
+ display: flex;
48
+ gap: 16px;
49
+ margin-bottom: 14px;
50
+
51
+ > div {
52
+ flex: 1;
53
+ min-width: 0;
54
+ }
55
+ `;
56
+
57
+ export const Pane = styled.div`
58
+ border: 1px solid ${ props => props.theme.inputs.border };
59
+ border-radius: ${ props => props.theme.borderRadius };
60
+ padding: 10px;
61
+ max-height: 320px;
62
+ overflow-y: auto;
63
+ font-size: ${ props => props.theme.size.s };
64
+
65
+ h4 {
66
+ margin: 0 0 8px;
67
+ color: ${ props => props.theme.font.faded };
68
+ font-size: ${ props => props.theme.size.xs };
69
+ text-transform: uppercase;
70
+ }
71
+ `;
72
+
73
+ export const Draft = styled.textarea`
74
+ width: 100%;
75
+ min-height: 260px;
76
+ padding: 10px;
77
+ border: 1px solid ${ props => props.theme.inputs.border };
78
+ border-radius: ${ props => props.theme.borderRadius };
79
+ font-family: monospace;
80
+ font-size: ${ props => props.theme.size.s };
81
+ margin-bottom: 14px;
82
+ `;
83
+
84
+ export const Actions = styled.div`
85
+ display: flex;
86
+ justify-content: flex-end;
87
+ gap: 10px;
88
+ `;
89
+
90
+ export const Error = styled.p`
91
+ color: ${ props => props.theme.font.error };
92
+ font-size: ${ props => props.theme.size.s };
93
+ margin-bottom: 10px;
94
+ `;
@@ -0,0 +1,12 @@
1
+ export interface AiProviderOption {
2
+ models: string[]
3
+ default_model: string
4
+ }
5
+
6
+ export type AiAvailable = Record<string, AiProviderOption>;
7
+
8
+ export interface AiCompletion {
9
+ content: string
10
+ provider: string
11
+ model: string
12
+ }
package/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## 1.29.0
5
+
6
+ ### Added
7
+
8
+ - **`AiFieldAssist` + the AI review modal.** The one AI-review surface every
9
+ content admin form shares - Generate/Improve/Translate triggers that open
10
+ a modal where a draft is always reviewed (and can be hand-edited or
11
+ revised on a follow-up instruction) before an explicit Insert writes it
12
+ into the calling field. Renders nothing at all when no AI provider is
13
+ enabled for the label - the visibility gate the AI plan calls for, with
14
+ no separate flag to keep in sync.
15
+ - **`Viewer`'s `'component'` field type accepts a function** of the form's
16
+ own `setValue`, not just plain JSX - the only way a component embedded in
17
+ the field list (AiFieldAssist writing a draft into a SIBLING field like
18
+ content_en) can reach state Viewer has always kept internal. Existing
19
+ callers passing plain JSX are unaffected.
20
+
21
+ ## 1.28.0
22
+
23
+ ### Added
24
+
25
+ - **`Row` takes an optional `actions`**, overriding the table's list for that
26
+ row. The table-level prop is one array for every row, which cannot express
27
+ an action only some rows have earned - a ticket download belongs to a paid
28
+ order and to no other. `Rows` falls back to the table's list, so every
29
+ existing caller is unaffected.
30
+ - **`print` and `invoice` row-action icons.** Distinct from the existing
31
+ generic `download`.
32
+
33
+
4
34
  ## 1.27.5
5
35
 
6
36
  ### Added
@@ -4,7 +4,8 @@ import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
4
4
  import { FaTrash } from 'react-icons/fa';
5
5
  import { GiMoneyStack } from 'react-icons/gi';
6
6
  import { IoDocumentLockOutline } from 'react-icons/io5';
7
- import { RiBusLine, RiExternalLinkLine, RiEyeLine, RiLoginCircleLine, RiReplyLine } from 'react-icons/ri';
7
+ import { FaFileInvoice } from 'react-icons/fa';
8
+ import { RiBusLine, RiExternalLinkLine, RiEyeLine, RiLoginCircleLine, RiPrinterLine, RiReplyLine } from 'react-icons/ri';
8
9
 
9
10
  interface Props {
10
11
  type: string
@@ -57,6 +58,16 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
57
58
  case 'download':
58
59
  return <AiOutlineCloudDownload title={ t('table.actions.custom.download', { ns: 'common' }) } />;
59
60
 
61
+ // Edited: Ferjolt Ozuni - Date: 2026-08-07
62
+ // The ticket and its invoice, for tables that can hand both over
63
+ // directly - the order listing does. Distinct from 'download' above,
64
+ // which is the generic one already spoken for by the invoice queue.
65
+ case 'print':
66
+ return <RiPrinterLine title={ t('table.actions.custom.print', { ns: 'common' }) } />;
67
+
68
+ case 'invoice':
69
+ return <FaFileInvoice title={ t('table.actions.custom.invoice', { ns: 'common' }) } />;
70
+
60
71
  case 'bus':
61
72
  return <RiBusLine title={ t('table.actions.custom.bus', { ns: 'common' }) } />;
62
73
 
package/Table/Row.tsx CHANGED
@@ -2,6 +2,18 @@ import { Td } from './styles';
2
2
 
3
3
  interface Props {
4
4
  id?: number
5
+ /*
6
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
7
+ *
8
+ * Options for THIS row, overriding the table's own list. The table-level
9
+ * `actions` prop is one array for every row, which cannot express an
10
+ * action that only some rows have earned - a ticket download belongs to a
11
+ * paid order and to no other.
12
+ *
13
+ * Read by Rows/Rows.tsx off this element's props; Row itself renders only
14
+ * the data cells, so it does not use the value.
15
+ */
16
+ actions?: string[]
5
17
  data: any[]
6
18
  }
7
19
 
@@ -4,15 +4,17 @@ import { ContainerNotFound } from './styles';
4
4
 
5
5
  interface Props {
6
6
  length: number
7
+ // An explanation, where there is one worth giving - see Table's `empty`.
8
+ message?: string
7
9
  t: TFunction<'common'>
8
10
  }
9
11
 
10
- const NotFound = ({ length, t }: Props): JSX.Element => (
12
+ const NotFound = ({ length, message, t }: Props): JSX.Element => (
11
13
  <tbody>
12
14
  <tr>
13
15
  <td colSpan={ length }>
14
16
  <ContainerNotFound>
15
- <RiEmotionUnhappyLine />{ t('table.no_data', { ns: 'common' }) }
17
+ <RiEmotionUnhappyLine />{ message ?? t('table.no_data', { ns: 'common' }) }
16
18
  </ContainerNotFound>
17
19
  </td>
18
20
  </tr>
@@ -13,19 +13,20 @@ interface Props {
13
13
  loading: boolean
14
14
  fetching: boolean
15
15
  colLength: number
16
+ empty?: string
16
17
  t: TFunction<'common'>
17
18
  actions?: string[]
18
19
  handlers?: any
19
20
  navigate: NavigateFunction
20
21
  }
21
22
 
22
- const Rows = ({ url, urlWith, data, loading, fetching, colLength, t, actions, handlers, navigate }: Props): JSX.Element => {
23
+ const Rows = ({ url, urlWith, data, loading, fetching, colLength, empty, t, actions, handlers, navigate }: Props): JSX.Element => {
23
24
  if (loading) {
24
25
  return <Loading length={ colLength } t={ t } />;
25
26
  }
26
27
 
27
28
  if (data?.length === 0) {
28
- return <NotFound length={ colLength } t={ t } />;
29
+ return <NotFound length={ colLength } message={ empty } t={ t } />;
29
30
  }
30
31
 
31
32
  const isViewable = actions?.includes('view');
@@ -36,23 +37,30 @@ const Rows = ({ url, urlWith, data, loading, fetching, colLength, t, actions, ha
36
37
  }
37
38
  };
38
39
 
39
- const columns = data?.map((item, index) => (
40
+ const columns = data?.map((item, index) => {
41
+ // Edited: Ferjolt Ozuni - Date: 2026-08-07
42
+ // A row may name its own options - see Row.tsx. Falls back to the
43
+ // table's list, which is what every existing caller relies on.
44
+ const available = item.props.actions ?? actions;
45
+
46
+ return (
40
47
  <Tr key={ index } $viewable={ isViewable } onClick={ () => onClick(item.props.id) }>
41
48
  { item }
42
49
 
43
- { rowActions(actions).length > 0 && (
50
+ { rowActions(available).length > 0 && (
44
51
  <Actions
45
52
  id={ item.props.id }
46
53
  url={ url }
47
54
  urlWith={ urlWith }
48
- available={ actions }
55
+ available={ available }
49
56
  t={ t }
50
57
  handlers={ handlers }
51
58
  navigate={ navigate }
52
59
  />
53
60
  ) }
54
61
  </Tr>
55
- ));
62
+ );
63
+ });
56
64
 
57
65
  return (
58
66
  <Tbody $fetching={ fetching }>
package/Table/Table.tsx CHANGED
@@ -21,10 +21,21 @@ interface Props {
21
21
  t: TFunction<'common'>
22
22
  actions?: string[]
23
23
  extra?: JSX.Element
24
+ /*
25
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
26
+ * What to say when the table is empty for a REASON, rather than merely
27
+ * empty. "No data found" is right for a search that matched nothing and
28
+ * useless for a screen that will stay empty until somebody else acts -
29
+ * a new agency's route list reads as a broken page rather than as one
30
+ * waiting on an admin to grant it operators.
31
+ *
32
+ * Optional: a table that does not pass one keeps the generic message.
33
+ */
34
+ empty?: string
24
35
  handlers?: any
25
36
  }
26
37
 
27
- const Table = ({ url, urlWith, columns, rows, pages, sorting, search, loading, fetching, t, actions, extra, handlers }: Props): JSX.Element => {
38
+ const Table = ({ url, urlWith, columns, rows, pages, sorting, search, loading, fetching, t, actions, extra, empty, handlers }: Props): JSX.Element => {
28
39
  const navigate = useNavigate();
29
40
 
30
41
  const colLength = columns.length + 1;
@@ -59,6 +70,7 @@ const Table = ({ url, urlWith, columns, rows, pages, sorting, search, loading, f
59
70
  loading={ loading }
60
71
  fetching={ fetching }
61
72
  colLength={ colLength }
73
+ empty={ empty }
62
74
  t={ t }
63
75
  actions={ actions }
64
76
  handlers={ handlers }
package/Viewer/Data.tsx CHANGED
@@ -66,7 +66,11 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
66
66
  );
67
67
 
68
68
  case 'component':
69
- return <div className="row-data">{ item.value }</div>;
69
+ return (
70
+ <div className="row-data">
71
+ { typeof item.value === 'function' ? item.value(onUpdate) : item.value }
72
+ </div>
73
+ );
70
74
 
71
75
  case 'display':
72
76
  return <Display className="row-data">{ value.string }</Display>;
@@ -115,7 +119,9 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
115
119
  return <input type="number" defaultValue={ value.number } { ...refs(item.name, Validate(String(item.rules), t)) } step="0.01" />;
116
120
 
117
121
  case 'link':
118
- return <Linked id={ id } value={ item.value } url={ item.url } />;
122
+ // The function-value variant only ever arrives on a 'component' row -
123
+ // see ViewData.value's docblock.
124
+ return <Linked id={ id } value={ item.value as (string | number | JSX.Element | undefined) } url={ item.url } />;
119
125
 
120
126
  case 'number':
121
127
  return <input type="number" defaultValue={ value.number } { ...refs(item.name, Validate(String(item.rules), t)) } />
package/Viewer/Item.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form';
2
2
  import { Display } from '@autobusal/utilities';
3
3
  import Data from './Data';
4
- import { Notice } from './styles';
4
+ import { Notice, Label, Required } from './styles';
5
5
  import { ViewData } from './types';
6
6
  import { TFunction } from 'i18next';
7
7
 
@@ -16,7 +16,28 @@ interface Props {
16
16
 
17
17
  const Item = ({ id, data, refs, t, errors, onUpdate }: Props): JSX.Element => (
18
18
  <div className={ `row row-${ data.type } ${ data.label === undefined && 'spacer' }` }>
19
- { data.label }
19
+ {/*
20
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
21
+ *
22
+ * Required fields say so. Read straight off the validation rules rather
23
+ * than a second flag per field, so a label cannot promise something the
24
+ * form does not enforce - or stay silent about something it does.
25
+ *
26
+ * Label and asterisk share ONE element on purpose. The row is a flex
27
+ * COLUMN, so a bare text node beside a sibling span makes two flex
28
+ * items and the asterisk drops onto a line of its own underneath.
29
+ *
30
+ * Only rendered when there is a label at all - a row without one is the
31
+ * spacer, and an empty element there would take a flex slot and open a
32
+ * gap where the divider should be.
33
+ */}
34
+ { data.label !== undefined && (
35
+ <Label>
36
+ { data.label }
37
+
38
+ { data.rules?.includes('required') && <Required aria-hidden="true">*</Required> }
39
+ </Label>
40
+ ) }
20
41
 
21
42
  <Data
22
43
  id={ id }
package/Viewer/styles.ts CHANGED
@@ -25,6 +25,17 @@ export const Container = styled.div`
25
25
  height: 1px;
26
26
  background: ${ props => props.theme.background.neutral };
27
27
  }
28
+
29
+ /*
30
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
31
+ * A rich-text editor gets the full row. At a third of the width it has
32
+ * less room for its own toolbar than for the text, and every consumer
33
+ * that uses one wants it wide - so this belongs here rather than as a
34
+ * flag each caller has to remember to pass.
35
+ */
36
+ & > div.row-textarea-html {
37
+ flex-basis: 100% !important;
38
+ }
28
39
  }
29
40
 
30
41
  @media (min-width: 1300px) {
@@ -55,4 +66,17 @@ export const Notice = styled.div`
55
66
  font-size: ${ props => props.theme.size.xxs };
56
67
  color: ${ props => props.theme.font.info };
57
68
  font-weight: 400;
58
- `;
69
+ `;
70
+ /*
71
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
72
+ * The asterisk beside a required field's label. aria-hidden where it is
73
+ * used - the requirement is already carried to assistive tech by the
74
+ * field's own validation, and a lone "*" read aloud says nothing.
75
+ */
76
+ export const Label = styled.span``;
77
+
78
+ export const Required = styled.span`
79
+ margin-left: 3px;
80
+ color: ${ props => props.theme.font.error };
81
+ font-weight: 700;
82
+ `;
package/Viewer/types.ts CHANGED
@@ -1,10 +1,19 @@
1
+ import { UseFormSetValue } from 'react-hook-form';
1
2
  import { DropdownData } from '@autobusal/providers/types/other';
2
3
 
3
4
  export interface ViewData {
4
5
  label?: string
5
6
  name?: string
6
7
  type?: 'text' | 'select' | 'date' | 'dob' | 'picker' | 'textarea' | 'textarea-html' | 'file' | 'files' | 'email' | 'password' | 'display' | 'component' | 'checkbox' | 'checkbox-list' | 'checkbox-list-all' | 'link' | 'number' | 'float' | 'date-picker'
7
- value?: string | number | JSX.Element
8
+ /**
9
+ * Edited: Ferjolt Ozuni - Date: 2026-08-08
10
+ * A 'component' row's value may be a function of the form's own
11
+ * setValue instead of plain JSX - the only way a component embedded in
12
+ * the field list (e.g. AiFieldAssist, writing an AI draft into a
13
+ * SIBLING field like content_en) can reach the Viewer's internal
14
+ * react-hook-form state, which nothing outside Viewer has access to.
15
+ */
16
+ value?: string | number | JSX.Element | ((onUpdate: UseFormSetValue<any>) => JSX.Element)
8
17
  /**
9
18
  * Edited: Ferjolt Ozuni - Date: 2026-08-06
10
19
  * Leading empty option for a select, for "nothing chosen". Without one a
package/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import AiFieldAssist from './AiAssist/AiFieldAssist';
1
2
  import ApiDetails from './ApiDetails/ApiDetails';
2
3
  import Autocomplete from './Autocomplete/Autocomplete';
3
4
  import Avatar from './Avatar';
@@ -45,6 +46,7 @@ import useUnderConstruction from './UnderConstruction/useUnderConstruction';
45
46
  import Viewer from './Viewer/Viewer';
46
47
 
47
48
  export {
49
+ AiFieldAssist,
48
50
  ApiDetails,
49
51
  Autocomplete,
50
52
  Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.27.5",
3
+ "version": "1.29.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"