@g1cloud/bluesea 5.0.0-beta.26 → 5.0.0-beta.28
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/README.md +21 -0
- package/bin/install-claude-skill.mjs +74 -0
- package/css/bluesea.css +61 -7
- package/dist/{BSAlertModal-CCdaoT-g.js → BSAlertModal-BpbJuAe1.js} +1 -1
- package/dist/{BSGridColumnSettingModal-CMJqpWzY.js → BSGridColumnSettingModal-8MqhRWkU.js} +1 -1
- package/dist/{BSRichTextMaximizedModal-Byrr_L8I.js → BSRichTextMaximizedModal-C86Skc5v.js} +1 -1
- package/dist/{BSYesNoModal-BljzNd5_.js → BSYesNoModal-CHbktVAj.js} +1 -1
- package/dist/{BSYoutubeInputModal-BZR0jJvt.js → BSYoutubeInputModal-JKnr4hGE.js} +1 -1
- package/dist/{ImageInsertModal-wdRGMEHH.js → ImageInsertModal-DQwkQJ8b.js} +2 -2
- package/dist/{ImageProperties.vue_vue_type_script_setup_true_lang-DNqql2HK.js → ImageProperties.vue_vue_type_script_setup_true_lang-BsMcsXdh.js} +1 -1
- package/dist/{ImagePropertiesModal-BumfiYFu.js → ImagePropertiesModal-X7blKqTy.js} +2 -2
- package/dist/{LinkPropertiesModal-C-cq00aG.js → LinkPropertiesModal-DGiiTivW.js} +1 -1
- package/dist/{TableInsertModal-DWy7cSQz.js → TableInsertModal-CupFfnOG.js} +1 -1
- package/dist/{TablePropertiesModal-BDir2XM5.js → TablePropertiesModal-CfK9i7Q5.js} +1 -1
- package/dist/{VideoInsertModal-s4eT3Ofx.js → VideoInsertModal-BwRRgibx.js} +2 -2
- package/dist/{VideoProperties.vue_vue_type_script_setup_true_lang-B2SQASHh.js → VideoProperties.vue_vue_type_script_setup_true_lang-zEMpmzTZ.js} +1 -1
- package/dist/{VideoPropertiesModal-zHc0vcQs.js → VideoPropertiesModal-Dn6AzhPy.js} +2 -2
- package/dist/{YoutubeInsertModal-CdcIzmFl.js → YoutubeInsertModal-DCn5bhN5.js} +2 -2
- package/dist/{YoutubeProperties.vue_vue_type_script_setup_true_lang-CWqGTFe5.js → YoutubeProperties.vue_vue_type_script_setup_true_lang-B-YVlp4Y.js} +1 -1
- package/dist/{YoutubePropertiesModal-CAd6dZ6E.js → YoutubePropertiesModal-Dg-n8cTv.js} +2 -2
- package/dist/bluesea.css +53 -7
- package/dist/bluesea.js +1 -1
- package/dist/bluesea.umd.cjs +623 -454
- package/dist/component/input/BSImageUpload.vue.d.ts +4 -0
- package/dist/component/input/BSMediaPreview.vue.d.ts +2 -0
- package/dist/component/input/BSMediaPreviewOverlay.vue.d.ts +44 -0
- package/dist/component/input/BSMultiImageUpload.vue.d.ts +2 -0
- package/dist/component/input/BSPositionedImageUpload.vue.d.ts +2 -0
- package/dist/{index-pO-xtezx.js → index-e3O4IL4V.js} +557 -388
- package/dist/text/i18n.d.ts +2 -1
- package/package.json +6 -1
- package/skills/bluesea-ui/SKILL.md +312 -0
- package/skills/bluesea-ui/references/components.md +189 -0
- package/skills/bluesea-ui/references/grid.md +159 -0
- package/skills/bluesea-ui/references/i18n.md +126 -0
- package/skills/bluesea-ui/references/validation.md +176 -0
- package/text/bluesea_text_en.json +248 -964
- package/text/bluesea_text_fr.json +248 -964
- package/text/bluesea_text_ja.json +248 -964
- package/text/bluesea_text_ko.json +248 -964
- package/text/bluesea_text_zh.json +248 -964
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# BSGrid deep-dive
|
|
2
|
+
|
|
3
|
+
BSGrid is the highest-surface-area component in Bluesea. This reference covers the parts you won't guess from prop names: the `PageGridHandler` factory, lookup/filter wiring, inline editing, column preferences, extensions, and Excel export. For quick starts use the `BSGridGuide.vue` demo and the snippet in the main SKILL.md.
|
|
4
|
+
|
|
5
|
+
## When to use which wiring
|
|
6
|
+
|
|
7
|
+
| Scenario | Use |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Client-side, fixed data, no paging | `<BSGrid :columns :data />` directly. |
|
|
10
|
+
| Server-side paging + sorting | `createPageGridHandler(option)` and bind its `grid`, `gridEventListener`, `control`, `controlEventListener`, `lookup`, `lookupEventListener` to `BSGrid`, `BSGridControl`, `BSGridLookup`. |
|
|
11
|
+
| Inline add/remove/edit | `createPageGridHandler({ editable: true, ... })` or pass `:editing-rows` + `#<prop>.edit` slots manually. |
|
|
12
|
+
|
|
13
|
+
## `createPageGridHandler` option
|
|
14
|
+
|
|
15
|
+
From `packages/bluesea/src/component/grid/GridModel.ts`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
createPageGridHandler<T>({
|
|
19
|
+
gridId: string, // used by gridPreferenceStore; omit to opt out of persistence
|
|
20
|
+
editable: boolean,
|
|
21
|
+
getRowKey: (row: T) => string,
|
|
22
|
+
newRowCreator: () => T | undefined, // when editable
|
|
23
|
+
addRowToLast: boolean, // default false — new rows go to top
|
|
24
|
+
removeRowHandler: (rows: Set<T>) => boolean, // return true if you handle deletion yourself
|
|
25
|
+
isRowEditable: (row, editingRows) => boolean,
|
|
26
|
+
isRowSelectable: (row) => boolean,
|
|
27
|
+
getGridData: (param: SearchParam) => PaginatedList<T> | Promise<PaginatedList<T>>,
|
|
28
|
+
limit: number, // default 100
|
|
29
|
+
limitItems: number[], // default [100, 300, 500]
|
|
30
|
+
defaultFilter: Filter[],
|
|
31
|
+
defaultSorts: Sort[],
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`SearchParam` carries `offset`, `limit`, `sorts`, `defaultFilter`, `lookupFilter`, `gridFilter`. `PaginatedList<T>` is `{ offset, totalCount, data: T[] }`. Return these from your API.
|
|
36
|
+
|
|
37
|
+
Call `handler.loadGridData()` once after setup to populate. Afterwards, Bluesea refreshes on sort/limit/offset/filter changes automatically via `gridEventListener` / `controlEventListener` / `lookupEventListener`.
|
|
38
|
+
|
|
39
|
+
## Column definition
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
type Column<T> = {
|
|
43
|
+
propertyId: string // key into row data
|
|
44
|
+
templateId?: string // slot name override (defaults to propertyId)
|
|
45
|
+
caption: MultiLangText
|
|
46
|
+
cellType?: 'TEXT' | 'NUMBER' | 'DATE' | 'PERCENTAGE' | 'BOOL' | 'MULTI_LANG_STRING' | 'NAME' | 'TEL' | 'ADDRESS' | 'MONEY'
|
|
47
|
+
dateFormat?: string | 'DAY' | 'MINUTE' | 'SECOND'
|
|
48
|
+
width?: number
|
|
49
|
+
sortable?: boolean
|
|
50
|
+
sortPropertyId?: string // if sort key ≠ display key
|
|
51
|
+
cellStyleClass?: string
|
|
52
|
+
cellStyleCss?: string
|
|
53
|
+
headerCellStyleClass?: string
|
|
54
|
+
headerCellStyleCss?: string
|
|
55
|
+
tooltipProvider?: (row: T) => MultiLangText | undefined
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`cellType` gives you free formatting. `MULTI_LANG_STRING` picks the current data locale; `MONEY` uses the configured `moneySerializer`. Override any cell by defining a `#<propertyId>` slot.
|
|
60
|
+
|
|
61
|
+
## Slots
|
|
62
|
+
|
|
63
|
+
| Slot | Purpose |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `#<propertyId>="{ row }"` | Display cell |
|
|
66
|
+
| `#<propertyId>.edit="{ row }"` | Editor cell (when row is in `editingRows`) |
|
|
67
|
+
| `#<propertyId>.filter="{ ... }"` | Header filter cell |
|
|
68
|
+
| `#emptyMessage` | Shown when `data.length === 0` |
|
|
69
|
+
|
|
70
|
+
Slot names come from `propertyId` (or `templateId` when provided). You can mix — only override the columns you need.
|
|
71
|
+
|
|
72
|
+
## Inline editing lifecycle
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
user clicks edit icon
|
|
76
|
+
→ gridEventListener.changeEditingRow(row, true)
|
|
77
|
+
→ handler adds row to editingRows
|
|
78
|
+
→ #propertyId.edit slot renders for that row, with a BSTextInput/BSNumberInput etc.
|
|
79
|
+
→ SavePoint on the row tracks modified state
|
|
80
|
+
user clicks save/cancel
|
|
81
|
+
→ you validate, call editingRows.removeRow(row) + savePoint.set() or rollback()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`EditingRows<T>` supports `addRow`, `removeRow`, `getModifiedRows`, `getRows`. You usually pass an instance into `BSGrid :editing-rows="..."`.
|
|
85
|
+
|
|
86
|
+
## Filter (BSGridLookup)
|
|
87
|
+
|
|
88
|
+
`BSGridLookup` renders above the grid as the "search bar". Its config shape:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
type GridLookupConfig = {
|
|
92
|
+
textFilter?: {
|
|
93
|
+
filterItems: Array<{
|
|
94
|
+
propertyId: string
|
|
95
|
+
caption: MultiLangText
|
|
96
|
+
prefix?: boolean // wrap keyword with leading % (default true)
|
|
97
|
+
suffix?: boolean // trailing % (default true)
|
|
98
|
+
filterCreator?: TextFilterCreator // custom filter builder
|
|
99
|
+
filterType?: 'STRING' | 'NUMBER'
|
|
100
|
+
}>
|
|
101
|
+
}
|
|
102
|
+
dateFilter?: {
|
|
103
|
+
filterItems: Array<{
|
|
104
|
+
propertyId: string
|
|
105
|
+
caption: MultiLangText
|
|
106
|
+
timeZone?: TimeZone
|
|
107
|
+
dateFormat?: string
|
|
108
|
+
popupDateFormat?: string
|
|
109
|
+
filterWidth?: string
|
|
110
|
+
}>
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For an "embedded Name" column that should search across `name.name1..4`, use the built-in `nameFilterCreator(maxIndex)`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { nameFilterCreator } from '@g1cloud/bluesea'
|
|
119
|
+
filterItems: [{ propertyId: 'memberName', caption: '회원명', filterCreator: nameFilterCreator() }]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## GridFilter (header-level per-column filter)
|
|
123
|
+
|
|
124
|
+
Separate from Lookup — this is the popup that appears from column headers. Enable with `<BSTextFilter>` / `<BSDateRangeFilter>` / `<BSDateRangeFilters>` in the `#<propertyId>.filter` slot. They emit filters that feed back into `searchParam.gridFilter`.
|
|
125
|
+
|
|
126
|
+
## Column preferences
|
|
127
|
+
|
|
128
|
+
If you pass `gridId` and install a `gridPreferenceStore` in `configureBluesea`, Bluesea persists column widths, order, hidden flags, and the last `dateFilter` per gridId. `LocalStorageGridPreferenceStore` is the canonical implementation. Implement the `GridPreferenceStore` interface to back it with something else (cloud user prefs, etc.).
|
|
129
|
+
|
|
130
|
+
## Extensions
|
|
131
|
+
|
|
132
|
+
`GridExtension` lets external packages inject cell renderers, row actions, toolbar buttons. The canonical example is `gridExcelDownloadExtension` (from `@/component/grid/extension/gridExcelDownloadExtension`) which adds an Excel-export button to `BSGridControl`. Import and pass via the `extensions` prop.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { BSGrid, BSGridControl, gridExcelDownloadExtension } from '@g1cloud/bluesea'
|
|
136
|
+
|
|
137
|
+
const extensions = [gridExcelDownloadExtension({
|
|
138
|
+
fileName: 'users.xlsx',
|
|
139
|
+
getRows: async () => (await api.users.searchAll(searchParam)).data,
|
|
140
|
+
})]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Fixed columns
|
|
144
|
+
|
|
145
|
+
`fixedColumnCount: number` on `GridBinding` freezes the first N columns during horizontal scroll. The user can also drag a divider to change it live — bind `settingChanged` on `gridEventListener` to persist.
|
|
146
|
+
|
|
147
|
+
## Row display/select policy
|
|
148
|
+
|
|
149
|
+
- `rowDisplayPolicy: (row) => boolean` — hide specific rows client-side
|
|
150
|
+
- `rowSelectPolicy: (row) => boolean` — disable checkbox for specific rows
|
|
151
|
+
- `rowEditPolicy: (row, editingRows) => boolean` — per-row editability (also `option.isRowEditable` on the handler)
|
|
152
|
+
|
|
153
|
+
## Common mistakes
|
|
154
|
+
|
|
155
|
+
- Forgetting to call `await gridHandler.loadGridData()` after setup — grid stays empty.
|
|
156
|
+
- Passing `defaultSorts` and expecting them to show as active sort chevrons; defaults are **appended** to user sorts in the query, not shown in the header.
|
|
157
|
+
- Using `propertyId: 'a.b'` for nested data — valid, but cells look up with dot-notation. Be sure `b` exists.
|
|
158
|
+
- Forgetting that `editingRows` must be the same reactive instance across parent/child; the handler already creates one, so use `gridHandler.grid.editingRows`.
|
|
159
|
+
- Overriding `#<prop>` slot and forgetting `.edit` variant — result: edit cells fall back to the default text display.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# i18n and MultiLangText
|
|
2
|
+
|
|
3
|
+
Bluesea treats every user-visible text prop as potentially multi-lingual. Understanding `MultiLangText` and the `i18n` helper up front prevents 90% of "caption is showing `{ key: 'xxx' }`" mistakes.
|
|
4
|
+
|
|
5
|
+
## The `MultiLangText` union
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
type LocaleName = string // e.g. 'ko', 'en', 'ja', 'ko-KR'
|
|
9
|
+
|
|
10
|
+
type MultiLangString = Record<LocaleName, string> // { ko: '저장', en: 'Save' }
|
|
11
|
+
type MultiLangMessage = { key: string; args?: unknown[]; locale?: LocaleName }
|
|
12
|
+
|
|
13
|
+
type MultiLangText =
|
|
14
|
+
| string
|
|
15
|
+
| MultiLangString
|
|
16
|
+
| MultiLangMessage
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Bluesea uses these rules to render:
|
|
20
|
+
|
|
21
|
+
1. If it's a plain string, use it as-is.
|
|
22
|
+
2. If it's an object with `key`, look the key up in the `I18NTexts` registry for the current locale and substitute `args`.
|
|
23
|
+
3. Otherwise, treat it as per-locale map and pick `currentLocale` (with fallbacks).
|
|
24
|
+
|
|
25
|
+
You detect `MultiLangMessage` by the presence of a `key` property; `isMultiLangMessage(text)` does this check.
|
|
26
|
+
|
|
27
|
+
## Registering texts
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { i18n } from '@g1cloud/bluesea'
|
|
31
|
+
|
|
32
|
+
i18n.addTexts('ko', [
|
|
33
|
+
{ key: 'btn.save', text: '저장' },
|
|
34
|
+
{ key: 'err.required', text: '필수 입력 항목입니다.' },
|
|
35
|
+
{ key: 'msg.saved', text: '{0} 개가 저장되었습니다.' }, // {0} {1} for args
|
|
36
|
+
{ key: 'help.html', text: '<strong>주의</strong>', html: true },
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
i18n.addTexts('en', [
|
|
40
|
+
{ key: 'btn.save', text: 'Save' },
|
|
41
|
+
{ key: 'err.required', text: 'This field is required.' },
|
|
42
|
+
{ key: 'msg.saved', text: '{0} items saved.' },
|
|
43
|
+
])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Compact JSON format
|
|
47
|
+
|
|
48
|
+
For large catalogues, `addTexts` also accepts a compact object form so the JSON file stays small. Same call — the runtime detects the shape via `Array.isArray`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
i18n.addTexts('ko', {
|
|
52
|
+
'btn.save': '저장', // plain text
|
|
53
|
+
'help.html': ['<strong>주의</strong>', 1], // array → html: true
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- value is a **string** → equivalent to `{ key, text, html: false }`
|
|
58
|
+
- value is `[text, 1]` (any truthy second element) → equivalent to `{ key, text, html: true }`
|
|
59
|
+
|
|
60
|
+
The shipped `@g1cloud/bluesea/text/bluesea_text_*.json` files use this compact format. Both formats can be mixed for the same locale; later calls overwrite earlier keys.
|
|
61
|
+
|
|
62
|
+
For real apps, store texts in `texts_ko.json` / `texts_en.json` and run the message generator (`pnpm generate-message` in bluesea-demo). The generator produces type-safe key constants.
|
|
63
|
+
|
|
64
|
+
Locale fallback chain: `currentLocale` → parent locale (`ko-KR` → `ko`) → `defaultLocale`. If no match, the key itself is returned — that is what "unresolved" text looks like in the UI.
|
|
65
|
+
|
|
66
|
+
## Looking up at runtime
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { t, interpretMultiLangText } from '@g1cloud/bluesea'
|
|
70
|
+
|
|
71
|
+
const msg = t({ key: 'msg.saved', args: [3] }) // → '3 items saved.' (in en)
|
|
72
|
+
|
|
73
|
+
// Generic interpreter that accepts any MultiLangText
|
|
74
|
+
const label = interpretMultiLangText({ ko: '저장', en: 'Save' })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## The `v-t` directive
|
|
78
|
+
|
|
79
|
+
Register once (`app.directive('t', vT)`), then use it anywhere you'd hard-code text. It sets `textContent` by default; pass a modifier to target a different attribute.
|
|
80
|
+
|
|
81
|
+
```vue
|
|
82
|
+
<span v-t="{ key: 'btn.save' }" />
|
|
83
|
+
<input v-t.placeholder="{ key: 'ph.name' }" />
|
|
84
|
+
<img v-t.alt="{ key: 'alt.logo' }" />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The directive is reactive — switching `blueseaConfig.currentLocale` updates all bound elements.
|
|
88
|
+
|
|
89
|
+
## Switching locale
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { blueseaConfig } from '@g1cloud/bluesea'
|
|
93
|
+
blueseaConfig.setCurrentLocale('en') // UI locale
|
|
94
|
+
blueseaConfig.setCurrentDataLocale('en') // BSMultiLang* pickers
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
There is also `<BSLocaleSelect>` for a ready-made dropdown in the header.
|
|
98
|
+
|
|
99
|
+
## Data locale vs UI locale
|
|
100
|
+
|
|
101
|
+
Bluesea separates two concerns:
|
|
102
|
+
|
|
103
|
+
- `currentLocale` / `locales` — which language the **chrome** renders in (labels, buttons, errors).
|
|
104
|
+
- `currentDataLocale` / `dataLocales` — which language the **data** is authored in, used by `BSMultiLang*` inputs (e.g. editing product name in `ko`, `en`, `ja`).
|
|
105
|
+
|
|
106
|
+
You can run the UI in Korean while editing English-only content, or vice versa. Most apps set both the same for end users but expose a separate data-locale toggle in admin tools.
|
|
107
|
+
|
|
108
|
+
## `MultiLangString` in data models
|
|
109
|
+
|
|
110
|
+
When a DB field is multilingual, model it as `MultiLangString` and render it with `cellType: 'MULTI_LANG_STRING'` in grids or `<BSMultiLangTextInput>` in forms. Bluesea picks the `currentDataLocale` with the same fallback chain.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
type Product = {
|
|
114
|
+
id: string
|
|
115
|
+
name: MultiLangString // { ko: '양말', en: 'Socks', ja: '靴下' }
|
|
116
|
+
description: MultiLangString
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Common mistakes
|
|
121
|
+
|
|
122
|
+
- Hard-coding Korean strings in a library component and wondering why they don't switch locale — wrap them as `{ key: '...' }` and register for each locale.
|
|
123
|
+
- Using `interpretMultiLangText` inside a `computed` and expecting it not to re-run when locale changes — it does, because `blueseaConfig` is reactive.
|
|
124
|
+
- Passing a key that doesn't exist — the key itself leaks into the UI. Check your text files or run the message generator.
|
|
125
|
+
- Forgetting to register `v-t` globally and then using `v-t="..."` — the directive silently does nothing.
|
|
126
|
+
- Storing `{ key: '...' }` in the database. Keys are UI concerns; database data should be `MultiLangString` values.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Validation in Bluesea
|
|
2
|
+
|
|
3
|
+
Bluesea's validation is deliberately unusual. Instead of wiring a validator per field, inputs *register themselves* on their DOM element. A `FormValidator` then walks the DOM tree inside a root element, finds every registered `FieldValidator`, runs them, and collects errors. This means you rarely touch `FieldValidator` directly — just give each input a `name` and scope a `FormValidator` to the form root.
|
|
4
|
+
|
|
5
|
+
## Types at a glance
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
type ValidationPhase = 'input' | 'change' | 'blur' | 'form'
|
|
9
|
+
|
|
10
|
+
type ValidationError = {
|
|
11
|
+
code: string
|
|
12
|
+
message: MultiLangText
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type FieldValidationRule<T> = (
|
|
16
|
+
value: T,
|
|
17
|
+
phase: ValidationPhase,
|
|
18
|
+
fieldContext?: FieldContext<any>,
|
|
19
|
+
) => Promise<ValidationError[] | undefined> | ValidationError[] | undefined
|
|
20
|
+
|
|
21
|
+
type FormValidationError = ValidationError & { name?: string }
|
|
22
|
+
|
|
23
|
+
type FormValidationRule = (phase?: ValidationPhase) => Promise<FormValidationError[] | undefined>
|
|
24
|
+
|
|
25
|
+
class ValidationFailedError {
|
|
26
|
+
constructor(public errors: FormValidationError[]) {}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Standard form flow
|
|
31
|
+
|
|
32
|
+
```vue
|
|
33
|
+
<template>
|
|
34
|
+
<div ref="formEl">
|
|
35
|
+
<BSTextInput v-model="form.name" name="name" required :max-length="50" />
|
|
36
|
+
<BSTextInput v-model="form.email" name="email" required reg-exp="^[^@]+@[^@]+$" />
|
|
37
|
+
<BSNumberInput v-model="form.age" name="age" :min-value="0" :max-value="150" />
|
|
38
|
+
<BSButton caption="저장" @click="save" />
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { ref } from 'vue'
|
|
44
|
+
import { formValidator, isValidationFailedError, showNotification } from '@g1cloud/bluesea'
|
|
45
|
+
|
|
46
|
+
const formEl = ref<HTMLElement>()
|
|
47
|
+
const form = ref({ name: '', email: '', age: 0 })
|
|
48
|
+
|
|
49
|
+
const validator = formValidator({
|
|
50
|
+
element: formEl,
|
|
51
|
+
rules: [
|
|
52
|
+
// form-level cross-field rule
|
|
53
|
+
async () => form.value.age < 18 && form.value.name.includes('kid')
|
|
54
|
+
? [{ code: 'ageMismatch', message: { key: 'err.ageMismatch' } }]
|
|
55
|
+
: undefined,
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function save() {
|
|
60
|
+
try {
|
|
61
|
+
await validator.validate() // throws on failure
|
|
62
|
+
await api.save(form.value)
|
|
63
|
+
showNotification({ key: 'msg.saved' })
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (isValidationFailedError(e)) {
|
|
66
|
+
showNotification({ key: 'err.validationFailed' }, 'error')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
throw e
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`validate()` throws `ValidationFailedError` with the full error list. `validationResult()` returns the errors without throwing, which is handy for custom UI (e.g. show a summary banner).
|
|
76
|
+
|
|
77
|
+
## Built-in field rules per input
|
|
78
|
+
|
|
79
|
+
Built-in rules are configured via props on each input. Each has a companion `validationMessage*` prop to override the default message.
|
|
80
|
+
|
|
81
|
+
| Rule | Inputs | Props |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| required | All input components | `required`, `validationMessageRequired` |
|
|
84
|
+
| length | `BSTextInput`, `BSTextArea` | `minLength`, `maxLength`, `validationMessageMinLength`, `validationMessageMaxLength`, `validationMessageBetweenLength` |
|
|
85
|
+
| regexp | `BSTextInput` | `regExp`, `validationMessageRegExp` |
|
|
86
|
+
| numeric range | `BSNumberInput`, `BSPriceInput`, `BSPercentInput` | `minValue`, `maxValue`, `validationMessageMinValue`, `validationMessageMaxValue`, `validationMessageBetweenValue` |
|
|
87
|
+
| date order | `BSDateRange` | auto; override `validationMessageDateOrder` |
|
|
88
|
+
| date bounds | `BSDateInput`, `BSDateRange` | `minValue`, `maxValue` |
|
|
89
|
+
| file size / count | `BSFileUpload`, `BS*ImageUpload` | `maxFileSize`, `maxFileCount` |
|
|
90
|
+
|
|
91
|
+
Default error messages are i18n keys like `bs.error.validation.required`, `bs.error.validation.lengthMin`, so translations come "for free" if you registered Bluesea's text files.
|
|
92
|
+
|
|
93
|
+
## Custom field rules (`extraValidationRules`)
|
|
94
|
+
|
|
95
|
+
When built-ins aren't enough, pass `:extra-validation-rules` — an array of functions. Each returns `ValidationError[]` (or empty / undefined for "ok"). Phase control lets you defer expensive checks.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import type { FieldValidationRule } from '@g1cloud/bluesea'
|
|
99
|
+
|
|
100
|
+
const uniqueEmail: FieldValidationRule<string> = async (value, phase) => {
|
|
101
|
+
if (!value || phase === 'input') return // run on blur / form only
|
|
102
|
+
const exists = await api.users.exists(value)
|
|
103
|
+
return exists ? [{ code: 'duplicate', message: { key: 'err.emailTaken' } }] : undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// in template
|
|
107
|
+
<BSTextInput v-model="form.email" name="email" required
|
|
108
|
+
:extra-validation-rules="[uniqueEmail]" />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Rules of thumb:
|
|
112
|
+
- Use `phase === 'form'` to skip expensive work on keystrokes.
|
|
113
|
+
- Return `undefined` for "pass"; return an empty array also passes, but `undefined` is clearer.
|
|
114
|
+
- Throw *inside* rules sparingly — the validator does not convert thrown errors into nice messages.
|
|
115
|
+
|
|
116
|
+
## Form-level rules
|
|
117
|
+
|
|
118
|
+
Cross-field rules go in `formValidator({ rules: [...] })`. They receive the phase but no value; you read your reactive state directly.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
formValidator({
|
|
122
|
+
element: formEl,
|
|
123
|
+
rules: [
|
|
124
|
+
async () => form.value.password !== form.value.passwordConfirm
|
|
125
|
+
? [{ name: 'passwordConfirm', code: 'mismatch', message: { key: 'err.pwMismatch' } }]
|
|
126
|
+
: undefined,
|
|
127
|
+
],
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The `name` on the error aligns the failure with a specific input so `validator.getFieldValidator(name)` can jump to it.
|
|
132
|
+
|
|
133
|
+
## Talking to individual FieldValidators
|
|
134
|
+
|
|
135
|
+
Rarely needed, but when you want to trigger validation imperatively:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { validateField, validateFields } from '@g1cloud/bluesea'
|
|
139
|
+
|
|
140
|
+
await validateField('email') // by name
|
|
141
|
+
await validateFields(['email', 'age'])
|
|
142
|
+
await validator.getFieldValidator('email')?.validate('blur')
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Clearing errors (e.g. on `cancel`):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
validator.clear()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Async rules and the "disabled" gotcha
|
|
152
|
+
|
|
153
|
+
If a field is `disabled`, its validator short-circuits to valid. Pass `:force-validate-when-disabled="true"` only when you really need validation despite the disabled state (rare — usually disabled means "field is derived / not user-editable").
|
|
154
|
+
|
|
155
|
+
For conditionally-disabled rules, prefer returning `undefined` early rather than toggling `disabled`, because `disabled` also skips the built-in rules.
|
|
156
|
+
|
|
157
|
+
## FieldContext
|
|
158
|
+
|
|
159
|
+
A `FieldContext<T>` (from `@/model/FieldContext`) is injected when a field is rendered inside a grid editor. It gives the rule access to `row`, sibling values, and the editingRows collection. Use it when one cell's validity depends on another cell in the same row.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const priceMustExceedCost: FieldValidationRule<number> = (price, _phase, ctx) => {
|
|
163
|
+
const row = ctx?.row as { cost?: number } | undefined
|
|
164
|
+
return (row && price !== undefined && row.cost !== undefined && price < row.cost)
|
|
165
|
+
? [{ code: 'priceLtCost', message: { key: 'err.priceLtCost' } }]
|
|
166
|
+
: undefined
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Common mistakes
|
|
171
|
+
|
|
172
|
+
- Forgetting `name` on an input — the validator still runs, but `error.name` is empty, so "jump to error" UX breaks.
|
|
173
|
+
- Wrapping the `ref` target with `v-if` that unmounts on error — the error clears because the validator is unregistered. Prefer `:disabled` or `:view-mode` to hide without unmounting.
|
|
174
|
+
- Calling `validator.validate()` from a plain synchronous handler — it's async. `await` it.
|
|
175
|
+
- Using `throw new Error(...)` in a rule. Return `[{ code, message }]` instead.
|
|
176
|
+
- Re-creating `formValidator` on every render; create it once in `<script setup>` at module scope.
|