pgkiln 0.80.0 → 0.82.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/db/migrations/146_database_model_providers.sql +53 -0
- package/db/migrations/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/docs/apex-feature-parity.md +12 -10
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +13 -3
- package/docs/guide/04-pages-and-regions.md +17 -3
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +16 -1
- package/docs/guide/11-from-apex.md +7 -0
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +12 -0
- package/public/app.js +157 -9
- package/src/ai/embeddings.ts +20 -7
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +51 -21
- package/src/builder/conditions.ts +64 -0
- package/src/builder/db-users.ts +124 -118
- package/src/builder/designer.ts +9 -1
- package/src/builder/home.ts +42 -40
- package/src/builder/i18n/de.ts +464 -0
- package/src/builder/i18n/en.ts +464 -0
- package/src/builder/i18n/es.ts +464 -0
- package/src/builder/i18n/fr.ts +464 -0
- package/src/builder/i18n/it.ts +464 -0
- package/src/builder/i18n/ja.ts +464 -0
- package/src/builder/i18n/ko.ts +464 -0
- package/src/builder/i18n/nl.ts +464 -0
- package/src/builder/i18n/pt.ts +464 -0
- package/src/builder/i18n/zh-cn.ts +464 -0
- package/src/builder/i18n/zh-tw.ts +464 -0
- package/src/builder/instance.ts +27 -23
- package/src/builder/ldap.ts +50 -47
- package/src/builder/mail-queue.ts +50 -45
- package/src/builder/network-access.ts +46 -43
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +30 -5
- package/src/builder/vector-column.ts +3 -3
- package/src/builder/vector-providers.ts +16 -5
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +72 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/context.ts +4 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +50 -4
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +13 -2
- package/src/runtime/routes.ts +2 -2
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/vector.ts +3 -3
- package/src/workflow.ts +10 -2
package/src/builder/db-users.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DefaultPriv, type ExistingPriv, type RoleContext, type RoleSpec, type SchemaGrant,
|
|
9
9
|
} from '../dbroles.ts';
|
|
10
10
|
import { administrator, back, BASE, csrf, flash, region, send, shell, type Body, type Req } from './ui.ts';
|
|
11
|
+
import { tr, type BuilderTranslate } from './i18n.ts';
|
|
11
12
|
|
|
12
13
|
// (sprint 99, Database Actions DA-13) Administration → Database Users: the PostgreSQL roles of this cluster with
|
|
13
14
|
// their sign-in, attributes, expiry, memberships and what they own; create, change and drop roles. Administrators
|
|
@@ -158,50 +159,51 @@ const sel = (on: boolean) => (on ? raw(' selected') : '');
|
|
|
158
159
|
const errorList = (errors: string[]) =>
|
|
159
160
|
errors.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : '';
|
|
160
161
|
|
|
161
|
-
const DEFAULT_LABELS: [DefaultPriv, string][]
|
|
162
|
-
const EXISTING_LABELS: [ExistingPriv, string][]
|
|
162
|
+
const DEFAULT_LABELS = (tx: BuilderTranslate): [DefaultPriv, string][] => [['none', tx('dbu.priv.none')], ['read', tx('dbu.priv.read')], ['write', tx('dbu.priv.write')]];
|
|
163
|
+
const EXISTING_LABELS = (tx: BuilderTranslate): [ExistingPriv, string][] => [['', tx('dbu.existing.none')], ['read', tx('dbu.existing.read')], ['write', tx('dbu.existing.write')], ['revoke', tx('dbu.existing.revoke')]];
|
|
163
164
|
|
|
164
165
|
/** The create / edit form. */
|
|
165
166
|
function roleForm(s: Session, spec: RoleSpec, o: { isNew: boolean; before: RoleSpec | null; grantable: string[]; errors: string[] }) {
|
|
167
|
+
const tx = tr(s);
|
|
166
168
|
const options = [...new Set([...o.grantable, ...(o.before?.memberOf ?? []), ...spec.memberOf])];
|
|
167
|
-
const pwModes: [string, string][] = o.isNew ? [['keep', '
|
|
169
|
+
const pwModes: [string, string][] = o.isNew ? [['keep', tx('dbu.pw.none')], ['set', tx('dbu.pw.set')]] : [['keep', tx('dbu.pw.keep')], ['set', tx('dbu.pw.new')], ['remove', tx('dbu.pw.remove')]];
|
|
168
170
|
return html`${errorList(o.errors)}
|
|
169
171
|
<form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}
|
|
170
172
|
<input type="hidden" name="original" value="${o.isNew ? '' : spec.name}">
|
|
171
|
-
${region('
|
|
172
|
-
${o.isNew ? html`<div class="field"><label class="label" for="f_name"
|
|
173
|
+
${region(tx('dbu.role'), html`<div class="form-grid">
|
|
174
|
+
${o.isNew ? html`<div class="field"><label class="label" for="f_name">${tx('dbu.name')}</label>
|
|
173
175
|
<input id="f_name" name="name" value="${spec.name}" required maxlength="63" pattern="[A-Za-z_][A-Za-z0-9_$]*" autocomplete="off" spellcheck="false">
|
|
174
|
-
<small class="help"
|
|
175
|
-
<div class="field"><span class="label"
|
|
176
|
-
<small class="help"
|
|
177
|
-
<fieldset class="field"><legend class="label"
|
|
176
|
+
<small class="help">${tx('dbu.name_help')}</small></div>` : ''}
|
|
177
|
+
<div class="field"><span class="label">${tx('dbu.sign_in')}</span><label class="check"><input type="checkbox" name="login" value="1"${chk(spec.login)}> ${tx('dbu.can_login')}</label>
|
|
178
|
+
<small class="help">${tx('dbu.can_login_help')}</small></div>
|
|
179
|
+
<fieldset class="field"><legend class="label">${tx('dbu.password')}</legend><div class="radio-group">
|
|
178
180
|
${pwModes.map(([v, l]) => html`<label class="check"><input type="radio" name="password_mode" value="${v}"${chk(spec.password === v)}> ${l}</label>`)}
|
|
179
|
-
</div><small class="help"
|
|
180
|
-
<div class="field"><label class="label" for="f_valid_until"
|
|
181
|
+
</div><small class="help">${tx('dbu.password_help')}</small></fieldset>
|
|
182
|
+
<div class="field"><label class="label" for="f_valid_until">${tx('dbu.valid_until')}</label>
|
|
181
183
|
<input id="f_valid_until" name="valid_until" type="date" value="${spec.validUntil}">
|
|
182
|
-
<small class="help"
|
|
183
|
-
<div class="field"><label class="label" for="f_conn_limit"
|
|
184
|
+
<small class="help">${tx('dbu.valid_until_help')}</small></div>
|
|
185
|
+
<div class="field"><label class="label" for="f_conn_limit">${tx('dbu.conn_limit')}</label>
|
|
184
186
|
<input id="f_conn_limit" name="conn_limit" type="number" min="-1" max="100000" step="1" value="${Number.isFinite(spec.connLimit) && spec.connLimit >= 0 ? String(spec.connLimit) : ''}">
|
|
185
|
-
<small class="help"
|
|
186
|
-
<div class="field"><label class="label" for="f_search_path"
|
|
187
|
+
<small class="help">${tx('dbu.conn_limit_help')}</small></div>
|
|
188
|
+
<div class="field"><label class="label" for="f_search_path">${tx('dbu.search_path')}</label>
|
|
187
189
|
<input id="f_search_path" name="search_path" value="${spec.searchPath}" placeholder="sales, public" spellcheck="false" autocomplete="off">
|
|
188
|
-
<small class="help"
|
|
189
|
-
<div class="field"><label class="label" for="f_comment"
|
|
190
|
+
<small class="help">${tx('dbu.search_path_help')}</small></div>
|
|
191
|
+
<div class="field"><label class="label" for="f_comment">${tx('dbu.comment')}</label>
|
|
190
192
|
<input id="f_comment" name="comment" value="${spec.comment}" maxlength="1000"></div>
|
|
191
193
|
</div>`)}
|
|
192
|
-
${region('
|
|
193
|
-
${options.length ? html`<fieldset class="field"><legend class="sr-only"
|
|
194
|
+
${region(tx('dbu.member_of'), html`<p class="muted u-mt0">${tx('dbu.member_of_intro')}</p>
|
|
195
|
+
${options.length ? html`<fieldset class="field"><legend class="sr-only">${tx('dbu.member_of')}</legend><div class="radio-group dbu-members">
|
|
194
196
|
${options.map((m) => html`<label class="check"><input type="checkbox" name="member" value="${m}"${chk(spec.memberOf.includes(m))}> ${m}</label>`)}
|
|
195
|
-
</div></fieldset>` : html`<p class="muted"
|
|
196
|
-
${region('
|
|
197
|
-
${spec.schemas.length ? html`<div class="table-wrap"><table class="report dbu-schemas"><thead><tr><th
|
|
197
|
+
</div></fieldset>` : html`<p class="muted">${tx('dbu.no_groups')}</p>`}`)}
|
|
198
|
+
${region(tx('dbu.schemas'), html`<p class="muted u-mt0">${tx.rich('dbu.schemas_intro', { usage: html`<strong>${tx('dbu.col.usage')}</strong>`, create: html`<strong>${tx('dbu.col.create')}</strong>`, future: html`<strong>${tx('dbu.col.future')}</strong>`, existing: html`<strong>${tx('dbu.col.existing')}</strong>` })}</p>
|
|
199
|
+
${spec.schemas.length ? html`<div class="table-wrap"><table class="report dbu-schemas"><thead><tr><th>${tx('dbu.col.schema')}</th><th class="te-c">${tx('dbu.col.usage')}</th><th class="te-c">${tx('dbu.col.create')}</th><th>${tx('dbu.col.future')}</th><th>${tx('dbu.col.existing')}</th></tr></thead>
|
|
198
200
|
<tbody>${spec.schemas.map((x, i) => html`<tr><td><code>${x.schema}</code><input type="hidden" name="s.${i}.name" value="${x.schema}"></td>
|
|
199
|
-
<td class="te-c"><input type="checkbox" name="s.${i}.usage" value="1" aria-label="
|
|
200
|
-
<td class="te-c"><input type="checkbox" name="s.${i}.create" value="1" aria-label="
|
|
201
|
-
<td><select name="s.${i}.defaults" aria-label="
|
|
202
|
-
<td><select name="s.${i}.existing" aria-label="
|
|
203
|
-
</tbody></table></div>` : html`<p class="muted"
|
|
204
|
-
<div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')}
|
|
201
|
+
<td class="te-c"><input type="checkbox" name="s.${i}.usage" value="1" aria-label="${tx('dbu.usage_on', { schema: x.schema })}"${chk(x.usage)}></td>
|
|
202
|
+
<td class="te-c"><input type="checkbox" name="s.${i}.create" value="1" aria-label="${tx('dbu.create_in', { schema: x.schema })}"${chk(x.create)}></td>
|
|
203
|
+
<td><select name="s.${i}.defaults" aria-label="${tx('dbu.future_in', { schema: x.schema })}">${x.defaults === 'custom' ? html`<option value="custom" selected>${tx('dbu.custom')}</option>` : ''}${DEFAULT_LABELS(tx).map(([v, l]) => html`<option value="${v}"${sel(x.defaults === v)}>${l}</option>`)}</select></td>
|
|
204
|
+
<td><select name="s.${i}.existing" aria-label="${tx('dbu.existing_in', { schema: x.schema })}">${EXISTING_LABELS(tx).map(([v, l]) => html`<option value="${v}"${sel((x.existing ?? '') === v)}>${l}</option>`)}</select></td></tr>`)}
|
|
205
|
+
</tbody></table></div>` : html`<p class="muted">${tx('dbu.no_schemas')}</p>`}`)}
|
|
206
|
+
<div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} ${tx('dbu.review')}</button><a class="btn" href="${URL_BASE}">${tx('dbu.cancel')}</a></div>
|
|
205
207
|
</form>`;
|
|
206
208
|
}
|
|
207
209
|
|
|
@@ -220,64 +222,66 @@ const hiddenSpec = (spec: RoleSpec, isNew: boolean) => html`
|
|
|
220
222
|
|
|
221
223
|
/** The review step: the SQL (the password masked), the password fields when one is set, Apply. */
|
|
222
224
|
function reviewForm(s: Session, spec: RoleSpec, stmts: string[], o: { isNew: boolean; errors: string[]; output?: Raw | '' }) {
|
|
225
|
+
const tx = tr(s);
|
|
223
226
|
return html`${errorList(o.errors)}${o.output ?? ''}
|
|
224
|
-
${region('
|
|
225
|
-
? html`<p class="muted u-mt0"
|
|
226
|
-
<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="
|
|
227
|
-
: html`<p class="muted u-mt0"
|
|
227
|
+
${region(tx('dbu.sql'), stmts.length
|
|
228
|
+
? html`<p class="muted u-mt0">${tx('dbu.sql_intro')}${spec.password === 'set' ? ` ${tx('dbu.sql_masked')}` : ''}</p>
|
|
229
|
+
<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="${tx('dbu.generated_sql')}">${maskSql(stmts)}</pre>`
|
|
230
|
+
: html`<p class="muted u-mt0">${tx('dbu.nothing_sql')}</p>`)}
|
|
228
231
|
<form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}${hiddenSpec(spec, o.isNew)}
|
|
229
232
|
<input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
|
|
230
|
-
${spec.password === 'set' ? region('
|
|
231
|
-
<div class="field"><label class="label" for="f_password"
|
|
232
|
-
<div class="field"><label class="label" for="f_confirm"
|
|
233
|
-
</div><small class="help"
|
|
234
|
-
<div class="buttons">${stmts.length ? html`<button class="btn btn-hot" name="mode" value="apply">${icon('check')}
|
|
235
|
-
<button class="btn" name="mode" value="edit" formnovalidate
|
|
233
|
+
${spec.password === 'set' ? region(tx('dbu.password'), html`<div class="form-grid">
|
|
234
|
+
<div class="field"><label class="label" for="f_password">${tx('dbu.password')}</label><input id="f_password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
|
|
235
|
+
<div class="field"><label class="label" for="f_confirm">${tx('dbu.confirm_password')}</label><input id="f_confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
|
|
236
|
+
</div><small class="help">${tx('dbu.password_note')}</small>`) : ''}
|
|
237
|
+
<div class="buttons">${stmts.length ? html`<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('dbu.apply')}</button>` : ''}
|
|
238
|
+
<button class="btn" name="mode" value="edit" formnovalidate>${tx('dbu.back_form')}</button></div>
|
|
236
239
|
</form>`;
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
/** A role's attributes as tags. */
|
|
240
|
-
const attrTags = (r: RoleRow) => html`${r.pgkiln ? html`<span class="tag tag-info">pgkiln</span> ` : ''}${r.used_by.length ? html`<span class="tag" title="${r.used_by.join(', ')}"
|
|
241
|
-
r.superuser ? html`<span class="tag tag-error"
|
|
242
|
-
r.createrole ? html`<span class="tag tag-warning"
|
|
243
|
+
const attrTags = (tx: BuilderTranslate, r: RoleRow) => html`${r.pgkiln ? html`<span class="tag tag-info">pgkiln</span> ` : ''}${r.used_by.length ? html`<span class="tag" title="${r.used_by.join(', ')}">${tx('dbu.tag.in_use')}</span> ` : ''}${
|
|
244
|
+
r.superuser ? html`<span class="tag tag-error">${tx('dbu.tag.superuser')}</span> ` : ''}${r.bypassrls ? html`<span class="tag tag-warning">${tx('dbu.tag.bypassrls')}</span> ` : ''}${
|
|
245
|
+
r.createrole ? html`<span class="tag tag-warning">${tx('dbu.tag.createrole')}</span> ` : ''}${r.createdb ? html`<span class="tag">${tx('dbu.tag.createdb')}</span> ` : ''}${r.replication ? html`<span class="tag">${tx('dbu.tag.replication')}</span>` : ''}`;
|
|
243
246
|
|
|
244
|
-
const expiry = (r: RoleRow) => r.valid_until === null ? html`<span class="muted"
|
|
245
|
-
r.days_left !== null && r.days_left < 0 ? html` <span class="tag tag-error"
|
|
247
|
+
const expiry = (tx: BuilderTranslate, r: RoleRow) => r.valid_until === null ? html`<span class="muted">${tx('dbu.never')}</span>` : html`${r.valid_until}${
|
|
248
|
+
r.days_left !== null && r.days_left < 0 ? html` <span class="tag tag-error">${tx('dbu.expired')}</span>` : r.days_left !== null && r.days_left <= 30 ? html` <span class="tag tag-warning">${tx(r.days_left === 1 ? 'dbu.in_days_one' : 'dbu.in_days', { n: r.days_left })}</span>` : ''}`;
|
|
246
249
|
|
|
247
|
-
const FILTERS: [string,
|
|
248
|
-
['', '
|
|
249
|
-
['login', '
|
|
250
|
-
['expiring', '
|
|
251
|
-
['bypassrls', '
|
|
252
|
-
['owners', '
|
|
253
|
-
['pgkiln', 'pgkiln
|
|
254
|
-
];
|
|
250
|
+
const FILTERS: [string, 'dbu.f.all', (r: RoleRow) => boolean][] = ([
|
|
251
|
+
['', 'dbu.f.all', () => true],
|
|
252
|
+
['login', 'dbu.f.login', (r) => r.login],
|
|
253
|
+
['expiring', 'dbu.f.expiring', (r) => r.login && r.days_left !== null && r.days_left >= 0 && r.days_left <= 30],
|
|
254
|
+
['bypassrls', 'dbu.f.bypassrls', (r) => r.bypassrls],
|
|
255
|
+
['owners', 'dbu.f.owners', (r) => !r.login && r.owned > 0],
|
|
256
|
+
['pgkiln', 'dbu.f.pgkiln', (r) => r.pgkiln],
|
|
257
|
+
] as [string, string, (r: RoleRow) => boolean][]) as [string, 'dbu.f.all', (r: RoleRow) => boolean][];
|
|
255
258
|
|
|
256
259
|
async function listPage(req: Req, reply: FastifyReply, s: Session) {
|
|
257
260
|
const roles = await listRoles();
|
|
258
261
|
const f = FILTERS.find(([k]) => k === (req.query.f ?? '')) ?? FILTERS[0];
|
|
259
262
|
const q = String(req.query.q ?? '').trim().toLowerCase().slice(0, 100);
|
|
260
263
|
const shown = roles.filter((r) => f[2](r) && (!q || r.name.toLowerCase().includes(q)));
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
264
|
+
const tx = tr(s);
|
|
265
|
+
const main = html`<div class="ide-head-title u-mb1"><div><h1>${tx('dbu.title')}</h1><span class="muted small">${tx('dbu.list_intro')}</span></div>
|
|
266
|
+
<div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} ${tx('dbu.create_user')}</a></div></div>
|
|
267
|
+
<nav class="stat-grid u-mb1 dbu-stats" aria-label="${tx('dbu.filters')}">${FILTERS.map(([k, label, fn]) => html`<a class="stat" href="${URL_BASE}${k ? `?f=${k}` : ''}"${k === f[0] ? raw(' aria-current="page"') : ''}><b>${String(roles.filter(fn).length)}</b><span>${tx(label)}</span></a>`)}</nav>
|
|
268
|
+
${region(tx(f[1]), html`<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search">${f[0] ? html`<input type="hidden" name="f" value="${f[0]}">` : ''}
|
|
269
|
+
<label class="sr-only" for="dbu-q">${tx('dbu.search_roles')}</label><input id="dbu-q" name="q" type="search" value="${q}" placeholder="${tx('dbu.search_roles')}" autocomplete="off"> <button class="btn btn-sm">${icon('search')} ${tx('dbu.search')}</button></form>
|
|
270
|
+
<div class="table-wrap"><table class="report report-reflow dbu-list"><thead><tr><th>${tx('dbu.role')}</th><th>${tx('dbu.sign_in')}</th><th>${tx('dbu.col.attributes')}</th><th class="num">${tx('dbu.col.connections')}</th><th>${tx('dbu.valid_until')}</th><th>${tx('dbu.member_of')}</th><th>${tx('dbu.col.owns')}</th></tr></thead>
|
|
267
271
|
<tbody>${shown.length ? shown.map((r) => html`<tr>
|
|
268
|
-
<td data-label="
|
|
269
|
-
<td data-label="
|
|
270
|
-
<td data-label="
|
|
271
|
-
<td data-label="
|
|
272
|
-
<td data-label="
|
|
273
|
-
<td data-label="
|
|
274
|
-
<td data-label="
|
|
275
|
-
: html`<tr><td colspan="7" class="empty"
|
|
276
|
-
<p class="muted small"
|
|
277
|
-
return send(reply, s, shell(s,
|
|
272
|
+
<td data-label="${tx('dbu.role')}" class="u-break"><a href="${roleUrl(r.name)}"><strong>${r.name}</strong></a>${r.comment ? html`<br><span class="muted small">${r.comment}</span>` : ''}</td>
|
|
273
|
+
<td data-label="${tx('dbu.sign_in')}">${r.login ? tx('dbu.yes') : html`<span class="muted">${tx('dbu.no')}</span>`}</td>
|
|
274
|
+
<td data-label="${tx('dbu.col.attributes')}">${attrTags(tx, r)}</td>
|
|
275
|
+
<td data-label="${tx('dbu.col.connections')}" class="num">${r.conn_limit < 0 ? html`<span class="muted">${tx('dbu.no_limit')}</span>` : String(r.conn_limit)}</td>
|
|
276
|
+
<td data-label="${tx('dbu.valid_until')}">${expiry(tx, r)}</td>
|
|
277
|
+
<td data-label="${tx('dbu.member_of')}" class="u-break">${r.member_of.join(', ')}</td>
|
|
278
|
+
<td data-label="${tx('dbu.col.owns')}" class="u-break">${r.schemas.length ? html`${tx('dbu.owns_schemas', { list: r.schemas.join(', ') })}; ` : ''}${r.owned ? tx(r.owned === 1 ? 'dbu.n_objects_one' : 'dbu.n_objects', { n: r.owned }) : r.schemas.length ? '' : html`<span class="muted">${tx('dbu.nothing')}</span>`}${r.owned_elsewhere ? html` <span class="muted">${tx('dbu.elsewhere', { n: r.owned_elsewhere })}</span>` : ''}</td></tr>`)
|
|
279
|
+
: html`<tr><td colspan="7" class="empty">${tx('dbu.no_roles')}</td></tr>`}</tbody></table></div>
|
|
280
|
+
<p class="muted small">${tx.rich('dbu.list_note', { pgkiln: html`<span class="tag tag-info">pgkiln</span>`, in_use: html`<span class="tag">${tx('dbu.tag.in_use')}</span>` })}</p>`)}`;
|
|
281
|
+
return send(reply, s, shell(s, tx('dbu.title'), crumbs(tx), main, 'users'));
|
|
278
282
|
}
|
|
279
283
|
|
|
280
|
-
const crumbs = (...more: [string, string?][]): [string, string?][] => [['
|
|
284
|
+
const crumbs = (tx: BuilderTranslate, ...more: [string, string?][]): [string, string?][] => [[tx('admin.utilities'), `${BASE}/utilities`], [tx('dbu.title'), more.length ? URL_BASE : undefined], ...more];
|
|
281
285
|
|
|
282
286
|
/** A role's details: owned objects, members, users of it. */
|
|
283
287
|
async function details(r: RoleRow) {
|
|
@@ -290,73 +294,73 @@ async function details(r: RoleRow) {
|
|
|
290
294
|
return { owned, members };
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
function detailRegion(r: RoleRow, d: { owned: string[]; members: string[] }) {
|
|
294
|
-
return region('
|
|
295
|
-
<dt
|
|
296
|
-
<dt
|
|
297
|
-
<dt
|
|
298
|
-
<dt
|
|
299
|
-
<dt
|
|
300
|
-
${r.used_by.length ? html`<dt
|
|
301
|
-
<dt
|
|
297
|
+
function detailRegion(tx: BuilderTranslate, r: RoleRow, d: { owned: string[]; members: string[] }) {
|
|
298
|
+
return region(tx('dbu.details'), html`<dl class="dbu-facts">
|
|
299
|
+
<dt>${tx('dbu.col.attributes')}</dt><dd>${r.login ? tx('dbu.f.login') : tx('dbu.no_login')} ${attrTags(tx, r)}</dd>
|
|
300
|
+
<dt>${tx('dbu.valid_until')}</dt><dd>${expiry(tx, r)}</dd>
|
|
301
|
+
<dt>${tx('dbu.conn_limit')}</dt><dd>${r.conn_limit < 0 ? tx('dbu.no_limit') : String(r.conn_limit)}</dd>
|
|
302
|
+
<dt>${tx('dbu.member_of')}</dt><dd class="u-break">${r.member_of.join(', ') || html`<span class="muted">${tx('dbu.none')}</span>`}</dd>
|
|
303
|
+
<dt>${tx('dbu.members')}</dt><dd class="u-break">${d.members.join(', ') || html`<span class="muted">${tx('dbu.none')}</span>`}</dd>
|
|
304
|
+
${r.used_by.length ? html`<dt>${tx('dbu.used_by')}</dt><dd class="u-break">${r.used_by.join(', ')}</dd>` : ''}
|
|
305
|
+
<dt>${tx('dbu.col.owns')}</dt><dd class="u-break">${d.owned.length ? html`${r.owned > d.owned.length ? `${tx('dbu.owned_first', { n: r.owned, first: d.owned.length })} ` : ''}${d.owned.join(', ')}` : html`<span class="muted">${tx('dbu.nothing_here')}</span>`}${r.owned_elsewhere ? html` <span class="tag tag-warning">${tx('dbu.owned_other', { n: r.owned_elsewhere })}</span>` : ''}</dd>
|
|
302
306
|
</dl>`);
|
|
303
307
|
}
|
|
304
308
|
|
|
305
309
|
/** Can this role be changed here? A message when not. */
|
|
306
|
-
const protectedMessage = (r: RoleRow) => r.pgkiln
|
|
307
|
-
? `${r.name} is one of pgkiln's own roles (its owner or runtime connection, a pgkiln_ role or the bootstrap superuser): pgkiln needs it as it is, so it isn't changed or dropped here.`
|
|
308
|
-
: '';
|
|
310
|
+
const protectedMessage = (tx: BuilderTranslate, r: RoleRow) => r.pgkiln ? tx('dbu.protected', { name: r.name }) : '';
|
|
309
311
|
|
|
310
312
|
async function rolePage(reply: FastifyReply, s: Session, name: string | null, o: { spec?: RoleSpec; errors?: string[] } = {}) {
|
|
313
|
+
const tx = tr(s);
|
|
311
314
|
const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
|
|
312
315
|
if (name === null) {
|
|
313
316
|
const spec = o.spec ?? { ...emptyRole(), login: true, schemas: schemas.map((schema) => ({ schema, usage: false, create: false, defaults: 'none' as DefaultPriv })) };
|
|
314
|
-
const main = html`<h1 class="u-mb1"
|
|
315
|
-
return send(reply, s, shell(s, '
|
|
317
|
+
const main = html`<h1 class="u-mb1">${tx('dbu.create_user')}</h1>${roleForm(s, spec, { isNew: true, before: null, grantable, errors: o.errors ?? [] })}`;
|
|
318
|
+
return send(reply, s, shell(s, tx('dbu.create_user'), crumbs(tx, [tx('dbu.crumb.create')]), main, 'users'));
|
|
316
319
|
}
|
|
317
320
|
const r = await oneRole(name);
|
|
318
321
|
if (!r) return reply.code(404).send('Not found');
|
|
319
322
|
const d = await details(r);
|
|
320
|
-
const msg = protectedMessage(r);
|
|
323
|
+
const msg = protectedMessage(tx, r);
|
|
321
324
|
const before = msg ? null : await readSpec(r, schemas, ctx.creator);
|
|
322
|
-
const main = html`<div class="ide-head-title u-mb1"><div><h1 class="u-break"
|
|
323
|
-
${msg || r.used_by.length ? '' : html`<div class="buttons ide-head-actions"><a class="btn btn-danger" href="${roleUrl(r.name)}/drop">${icon('trash')}
|
|
325
|
+
const main = html`<div class="ide-head-title u-mb1"><div><h1 class="u-break">${tx('dbu.role_title', { name: r.name })}</h1>${r.comment ? html`<span class="muted small">${r.comment}</span>` : ''}</div>
|
|
326
|
+
${msg || r.used_by.length ? '' : html`<div class="buttons ide-head-actions"><a class="btn btn-danger" href="${roleUrl(r.name)}/drop">${icon('trash')} ${tx('dbu.drop_role_link')}</a></div>`}</div>
|
|
324
327
|
${msg ? html`<div class="alert alert-info" role="status">${msg}</div>` : ''}
|
|
325
|
-
${!msg && r.used_by.length ? html`<div class="alert alert-info" role="status">${
|
|
326
|
-
${detailRegion(r, d)}
|
|
328
|
+
${!msg && r.used_by.length ? html`<div class="alert alert-info" role="status">${tx(r.used_by.length === 1 ? 'dbu.used_change_only_one' : 'dbu.used_change_only', { users: r.used_by.join(', ') })}</div>` : ''}
|
|
329
|
+
${detailRegion(tx, r, d)}
|
|
327
330
|
${before ? roleForm(s, o.spec ?? before, { isNew: false, before, grantable, errors: o.errors ?? [] }) : ''}`;
|
|
328
|
-
return send(reply, s, shell(s,
|
|
331
|
+
return send(reply, s, shell(s, tx('dbu.role_title', { name: r.name }), crumbs(tx, [r.name]), main, 'users'));
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
async function save(req: Req, reply: FastifyReply, s: Session) {
|
|
332
335
|
const b = req.body ?? {};
|
|
336
|
+
const tx = tr(s);
|
|
333
337
|
const original = one(b.original);
|
|
334
338
|
const isNew = !original;
|
|
335
339
|
const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
|
|
336
340
|
const r = isNew ? null : await oneRole(original);
|
|
337
341
|
if (!isNew && !r) return reply.code(404).send('Not found');
|
|
338
342
|
if (r && r.pgkiln) {
|
|
339
|
-
const main = html`<h1 class="u-mb1"
|
|
340
|
-
return send(reply.code(403), s, shell(s,
|
|
343
|
+
const main = html`<h1 class="u-mb1">${tx('dbu.role_title', { name: r.name })}</h1><div class="alert alert-error" role="alert">${protectedMessage(tx, r)}</div>`;
|
|
344
|
+
return send(reply.code(403), s, shell(s, tx('dbu.role_title', { name: r.name }), crumbs(tx, [r.name]), main, 'users'));
|
|
341
345
|
}
|
|
342
346
|
const before = r ? await readSpec(r, schemas, ctx.creator) : null;
|
|
343
347
|
const spec = specFromBody(b, isNew, original);
|
|
344
348
|
const mode = one(b.mode);
|
|
345
349
|
if (mode === 'edit') return rolePage(reply, s, isNew ? null : original, { spec });
|
|
346
350
|
const errors = checkRole(spec, { isNew, grantable, schemas, before });
|
|
347
|
-
if (isNew && !errors.length && (await owner.one('select 1 from pg_roles where lower(rolname) = lower($1)', [spec.name]))) errors.push(
|
|
351
|
+
if (isNew && !errors.length && (await owner.one('select 1 from pg_roles where lower(rolname) = lower($1)', [spec.name]))) errors.push(tx('dbu.exists', { name: spec.name }));
|
|
348
352
|
if (errors.length) return rolePage(reply, s, isNew ? null : original, { spec, errors });
|
|
349
353
|
const stmts = roleSql(before, spec, ctx);
|
|
350
|
-
const title = isNew ?
|
|
354
|
+
const title = isNew ? tx('dbu.create_title', { name: spec.name }) : tx('dbu.change_title', { name: spec.name });
|
|
351
355
|
const page = (errs: string[], output: Raw | '' = '') => {
|
|
352
356
|
const main = html`<h1 class="u-mb1 u-break">${title}</h1>${reviewForm(s, spec, stmts, { isNew, errors: errs, output })}`;
|
|
353
|
-
const trail: [string, string?][] = isNew ? [['
|
|
354
|
-
return send(reply.code(errs.length || output ? 422 : 200), s, shell(s, title, crumbs(...trail), main, 'users'));
|
|
357
|
+
const trail: [string, string?][] = isNew ? [[tx('dbu.crumb.create'), `${URL_BASE}/new`], [tx('dbu.crumb.review')]] : [[spec.name, roleUrl(spec.name)], [tx('dbu.crumb.review')]];
|
|
358
|
+
return send(reply.code(errs.length || output ? 422 : 200), s, shell(s, title, crumbs(tx, ...trail), main, 'users'));
|
|
355
359
|
};
|
|
356
360
|
if (mode !== 'apply') return page([]);
|
|
357
361
|
// Apply runs exactly what the review showed: a change in the form or the catalog since then shows the review again
|
|
358
|
-
if (one(b.reviewed) !== reviewDigest(stmts)) return page(['
|
|
359
|
-
if (!stmts.length) return page(['
|
|
362
|
+
if (one(b.reviewed) !== reviewDigest(stmts)) return page([tx('dbu.changed_since')]);
|
|
363
|
+
if (!stmts.length) return page([tx('dbu.nothing_change')]);
|
|
360
364
|
const password = one(b.password);
|
|
361
365
|
if (spec.password === 'set') {
|
|
362
366
|
const bad = checkPassword(password, one(b.confirm));
|
|
@@ -379,9 +383,9 @@ async function save(req: Req, reply: FastifyReply, s: Session) {
|
|
|
379
383
|
}
|
|
380
384
|
await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
|
|
381
385
|
if (failed) {
|
|
382
|
-
return page([], html`<div class="alert alert-error" role="alert"><p
|
|
386
|
+
return page([], html`<div class="alert alert-error" role="alert"><p>${tx.rich('dbu.rolled_back', { nothing: html`<strong>${tx('dbu.nothing_changed')}</strong>` })}</p><p>${failed}</p>${at >= 0 ? html`<pre class="source">${maskSql([stmts[at]])}</pre>` : ''}</div>`);
|
|
383
387
|
}
|
|
384
|
-
flash(s,
|
|
388
|
+
flash(s, tx(isNew ? (stmts.length === 1 ? 'dbu.created_one' : 'dbu.created') : (stmts.length === 1 ? 'dbu.changed_one' : 'dbu.changed'), { name: spec.name, n: stmts.length }));
|
|
385
389
|
return back(reply, s, isNew ? URL_BASE : roleUrl(spec.name));
|
|
386
390
|
}
|
|
387
391
|
|
|
@@ -392,43 +396,45 @@ const reassignTargets = async (name: string) =>
|
|
|
392
396
|
async function dropPage(req: Req, reply: FastifyReply, s: Session, name: string, o: { to?: string; errors?: string[]; output?: Raw | '' } = {}) {
|
|
393
397
|
const r = await oneRole(name);
|
|
394
398
|
if (!r) return reply.code(404).send('Not found');
|
|
395
|
-
const
|
|
399
|
+
const tx = tr(s);
|
|
400
|
+
const refusal = r.pgkiln ? protectedMessage(tx, r) : r.used_by.length ? tx(r.used_by.length === 1 ? 'dbu.drop_refused_one' : 'dbu.drop_refused', { users: r.used_by.join(', '), name: r.name }) : '';
|
|
396
401
|
const d = await details(r);
|
|
397
402
|
const targets = await reassignTargets(name);
|
|
398
403
|
const to = o.to !== undefined && (o.to === '' || targets.includes(o.to)) ? o.to : r.owned ? targets[0] ?? '' : '';
|
|
399
404
|
const stmts = dropSql(r.name, to || null);
|
|
400
|
-
const main = html`<h1 class="u-mb1 u-break"
|
|
405
|
+
const main = html`<h1 class="u-mb1 u-break">${tx('dbu.drop_title', { name: r.name })}</h1>
|
|
401
406
|
${refusal ? html`<div class="alert alert-error" role="alert">${refusal}</div>` : ''}${errorList(o.errors ?? [])}${o.output ?? ''}
|
|
402
|
-
${detailRegion(r, d)}
|
|
407
|
+
${detailRegion(tx, r, d)}
|
|
403
408
|
${refusal ? '' : html`
|
|
404
|
-
${r.owned || r.owned_elsewhere ? html`<div class="alert alert-error" role="alert"><p><strong>${
|
|
405
|
-
|
|
406
|
-
${region('
|
|
407
|
-
<div class="field"><label class="label" for="f_to"
|
|
408
|
-
${targets.map((t) => html`<option value="${t}"${sel(t === to)}
|
|
409
|
-
<option value=""${sel(to === '')}>${r.owned ?
|
|
410
|
-
<small class="help"
|
|
411
|
-
<div class="buttons"><button class="btn">${icon('eye')}
|
|
412
|
-
${region('
|
|
409
|
+
${r.owned || r.owned_elsewhere ? html`<div class="alert alert-error" role="alert"><p><strong>${tx(r.owned === 1 ? 'dbu.owns_here_one' : 'dbu.owns_here', { name: r.name, n: r.owned })}${r.owned_elsewhere ? ` ${tx('dbu.owns_elsewhere', { n: r.owned_elsewhere })}` : ''}.</strong>
|
|
410
|
+
${tx('dbu.reassign_note')}${r.owned_elsewhere ? ` ${tx('dbu.elsewhere_note')}` : ''}</p></div>` : ''}
|
|
411
|
+
${region(tx('dbu.owned_objects'), html`<form method="get" action="${roleUrl(r.name)}/drop" class="form-grid">
|
|
412
|
+
<div class="field"><label class="label" for="f_to">${tx('dbu.objects_it_owns')}</label><select id="f_to" name="to">
|
|
413
|
+
${targets.map((t) => html`<option value="${t}"${sel(t === to)}>${tx('dbu.reassign_to', { name: t })}</option>`)}
|
|
414
|
+
<option value=""${sel(to === '')}>${r.owned ? tx('dbu.drop_owned') : tx('dbu.owns_nothing')}</option></select>
|
|
415
|
+
<small class="help">${tx('dbu.drop_owned_help')}</small></div>
|
|
416
|
+
<div class="buttons"><button class="btn">${icon('eye')} ${tx('dbu.review')}</button></div></form>`)}
|
|
417
|
+
${region(tx('dbu.sql'), html`<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="${tx('dbu.generated_sql')}">${maskSql(stmts)}</pre>
|
|
413
418
|
<form method="post" action="${URL_BASE}/drop" class="dbu-form">${csrf(s)}
|
|
414
419
|
<input type="hidden" name="name" value="${r.name}"><input type="hidden" name="to" value="${to}"><input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
|
|
415
|
-
${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required>
|
|
416
|
-
<div class="buttons"><button class="btn btn-danger" data-confirm="
|
|
417
|
-
return send(reply.code(refusal ? 403 : o.errors?.length || o.output ? 422 : 200), s, shell(s,
|
|
420
|
+
${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required> ${tx(r.owned === 1 ? 'dbu.drop_objects_one' : 'dbu.drop_objects', { n: r.owned, name: r.name })}</label>` : ''}
|
|
421
|
+
<div class="buttons"><button class="btn btn-danger" data-confirm="${tx('dbu.drop_confirm', { name: r.name })}">${icon('trash')} ${tx('dbu.drop_btn')}</button><a class="btn" href="${roleUrl(r.name)}">${tx('dbu.cancel')}</a></div></form>`)}`}`;
|
|
422
|
+
return send(reply.code(refusal ? 403 : o.errors?.length || o.output ? 422 : 200), s, shell(s, tx('dbu.drop_title', { name: r.name }), crumbs(tx, [r.name, roleUrl(r.name)], [tx('dbu.crumb.drop')]), main, 'users'));
|
|
418
423
|
}
|
|
419
424
|
|
|
420
425
|
async function drop(req: Req, reply: FastifyReply, s: Session) {
|
|
421
426
|
const b = req.body ?? {};
|
|
427
|
+
const tx = tr(s);
|
|
422
428
|
const name = one(b.name);
|
|
423
429
|
const to = one(b.to);
|
|
424
430
|
const r = await oneRole(name);
|
|
425
431
|
if (!r) return reply.code(404).send('Not found');
|
|
426
432
|
if (r.pgkiln || r.used_by.length) return dropPage(req, reply, s, name, { to });
|
|
427
433
|
const targets = await reassignTargets(name);
|
|
428
|
-
if (to && !targets.includes(to)) return dropPage(req, reply, s, name, { errors: ['
|
|
434
|
+
if (to && !targets.includes(to)) return dropPage(req, reply, s, name, { errors: [tx('dbu.choose_target')] });
|
|
429
435
|
const stmts = dropSql(r.name, to || null);
|
|
430
|
-
if (one(b.reviewed) !== reviewDigest(stmts)) return dropPage(req, reply, s, name, { to, errors: ['
|
|
431
|
-
if (!to && r.owned && !b.drop_objects) return dropPage(req, reply, s, name, { to, errors: [
|
|
436
|
+
if (one(b.reviewed) !== reviewDigest(stmts)) return dropPage(req, reply, s, name, { to, errors: [tx('dbu.changed_since_drop')] });
|
|
437
|
+
if (!to && r.owned && !b.drop_objects) return dropPage(req, reply, s, name, { to, errors: [tx('dbu.tick_box', { name: r.name })] });
|
|
432
438
|
let failed = '';
|
|
433
439
|
try {
|
|
434
440
|
await owner.tx(async (c) => {
|
|
@@ -439,8 +445,8 @@ async function drop(req: Req, reply: FastifyReply, s: Session) {
|
|
|
439
445
|
failed = (e as Error).message;
|
|
440
446
|
}
|
|
441
447
|
await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
|
|
442
|
-
if (failed) return dropPage(req, reply, s, name, { to, output: html`<div class="alert alert-error" role="alert"><p
|
|
443
|
-
flash(s,
|
|
448
|
+
if (failed) return dropPage(req, reply, s, name, { to, output: html`<div class="alert alert-error" role="alert"><p>${tx.rich('dbu.rolled_back', { nothing: html`<strong>${tx('dbu.nothing_changed')}</strong>` })}</p><p>${failed}</p></div>` });
|
|
449
|
+
flash(s, tx('dbu.dropped', { name: r.name }));
|
|
444
450
|
return back(reply, s, URL_BASE);
|
|
445
451
|
}
|
|
446
452
|
|
package/src/builder/designer.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { conditionWords, serverCondition } from './conditions.ts';
|
|
1
2
|
import { pageIssuesNote } from './team.ts';
|
|
2
3
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
3
4
|
import { owner } from '../db.ts';
|
|
@@ -107,7 +108,14 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
107
108
|
const post = `${BASE}/pages/${p.id}`;
|
|
108
109
|
const isSel = (key: string) => sel === key || (key === 'page' && !sel && !newKind);
|
|
109
110
|
const cur = (key: string) => (isSel(key) ? raw(' aria-current="true"') : '');
|
|
110
|
-
const badges = (r: any) => html`${r.authz ? html`<span class="pd-tag" title="${tx('pd.badge.authz', { name: r.authz })}">${icon('shield')}<span class="sr-only">${tx('pd.badge.authz', { name: r.authz })}</span></span>` : ''}${r
|
|
111
|
+
const badges = (r: any) => html`${r.authz ? html`<span class="pd-tag" title="${tx('pd.badge.authz', { name: r.authz })}">${icon('shield')}<span class="sr-only">${tx('pd.badge.authz', { name: r.authz })}</span></span>` : ''}${condBadge(r)}${r.build_option ? html`<span class="pd-tag" title="${tx('pd.badge.build_option', { name: r.build_option })}">${icon('settings')}<span class="sr-only">${tx('pd.badge.build_option', { name: r.build_option })}</span></span>` : ''}`;
|
|
112
|
+
// (154) the server-side condition in words ("When the request is SAVE, CREATE"); other conditions: "Has a condition"
|
|
113
|
+
const condBadge = (r: any) => {
|
|
114
|
+
const sc = serverCondition(r);
|
|
115
|
+
if (!sc && !r.readonly_condition && !r.condition_type) return '';
|
|
116
|
+
const words = sc ? conditionWords(tx, sc) : tx('pd.badge.condition');
|
|
117
|
+
return html`<span class="pd-tag" title="${words}" data-condition="${sc?.condition_type ?? ''}">${icon('filter')}<span class="sr-only">${words}</span></span>`;
|
|
118
|
+
};
|
|
111
119
|
const regionTitle = (r: any) => r.title ?? `(${r.type})`;
|
|
112
120
|
|
|
113
121
|
// ------------------------------------------------------------ left: component tree
|