@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.
- package/AiAssist/AiFieldAssist.tsx +72 -0
- package/AiAssist/AiReviewModal.tsx +191 -0
- package/AiAssist/services.ts +84 -0
- package/AiAssist/styles.ts +94 -0
- package/AiAssist/types.ts +12 -0
- package/CHANGELOG.md +30 -0
- package/Table/Actions/Icon.tsx +12 -1
- package/Table/Row.tsx +12 -0
- package/Table/Rows/NotFound.tsx +4 -2
- package/Table/Rows/Rows.tsx +14 -6
- package/Table/Table.tsx +13 -1
- package/Viewer/Data.tsx +8 -2
- package/Viewer/Item.tsx +23 -2
- package/Viewer/styles.ts +25 -1
- package/Viewer/types.ts +10 -1
- package/index.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
`;
|
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
|
package/Table/Actions/Icon.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
package/Table/Rows/NotFound.tsx
CHANGED
|
@@ -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>
|
package/Table/Rows/Rows.tsx
CHANGED
|
@@ -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(
|
|
50
|
+
{ rowActions(available).length > 0 && (
|
|
44
51
|
<Actions
|
|
45
52
|
id={ item.props.id }
|
|
46
53
|
url={ url }
|
|
47
54
|
urlWith={ urlWith }
|
|
48
|
-
available={
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|