pgkiln 0.41.0 → 0.42.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.
Files changed (53) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/docs/apex-feature-parity.md +5 -5
  4. package/docs/guide/03-builder.md +66 -18
  5. package/docs/guide/09-reference.md +6 -2
  6. package/docs/guide/11-from-apex.md +1 -1
  7. package/docs/guide/12-development.md +1 -1
  8. package/docs/guide/14-globalization.md +7 -3
  9. package/docs/guide/17-mobile.md +1 -1
  10. package/package.json +1 -1
  11. package/public/app.css +175 -33
  12. package/public/app.js +80 -0
  13. package/public/builder.css +30 -2
  14. package/public/builder.js +31 -0
  15. package/src/builder/appfromfile.ts +5 -4
  16. package/src/builder/appsheets.ts +23 -9
  17. package/src/builder/appwizard.ts +215 -70
  18. package/src/builder/blueprints.ts +2 -1
  19. package/src/builder/components.ts +1 -1
  20. package/src/builder/home.ts +2 -32
  21. package/src/builder/newapp.ts +182 -5
  22. package/src/builder/routes.ts +4 -28
  23. package/src/i18n/ar.ts +1 -0
  24. package/src/i18n/cs.ts +1 -0
  25. package/src/i18n/da.ts +1 -0
  26. package/src/i18n/de.ts +1 -0
  27. package/src/i18n/el.ts +1 -0
  28. package/src/i18n/es.ts +1 -0
  29. package/src/i18n/fi.ts +1 -0
  30. package/src/i18n/fr.ts +1 -0
  31. package/src/i18n/he.ts +1 -0
  32. package/src/i18n/it.ts +1 -0
  33. package/src/i18n/ja.ts +1 -0
  34. package/src/i18n/ko.ts +1 -0
  35. package/src/i18n/nb.ts +1 -0
  36. package/src/i18n/pl.ts +1 -0
  37. package/src/i18n/pt.ts +1 -0
  38. package/src/i18n/ru.ts +1 -0
  39. package/src/i18n/sv.ts +1 -0
  40. package/src/i18n/tr.ts +1 -0
  41. package/src/i18n/uk.ts +1 -0
  42. package/src/i18n/zh.ts +1 -0
  43. package/src/i18n.ts +2 -0
  44. package/src/icons.ts +2 -1
  45. package/src/layout.ts +2 -2
  46. package/src/runtime/charts.ts +8 -3
  47. package/src/runtime/devbar.ts +35 -14
  48. package/src/runtime/engine.ts +7 -4
  49. package/src/runtime/locale.ts +2 -2
  50. package/src/runtime/regions.ts +25 -2
  51. package/src/runtime/render.ts +18 -4
  52. package/src/runtime/report.ts +15 -9
  53. package/src/runtime/styles.ts +5 -2
@@ -0,0 +1,911 @@
1
+ -- =====================================================================
2
+ -- 097: generated applications read like APEX's (sprint 71)
3
+ --
4
+ -- - meta.wizard_label(name): human labels. snake_case and camelCase are
5
+ -- split, run-together words are segmented with a small word list
6
+ -- (hiredate → Hire Date, deptno → Department Number) and common
7
+ -- abbreviations are expanded (emp → Employee, sal → Salary, qty →
8
+ -- Quantity, crm → CRM).
9
+ -- - meta.wizard_table_label(table, plural) and
10
+ -- meta.wizard_column_label(table, column): a short COMMENT ON wins; a
11
+ -- foreign key column drops its _id/no suffix (deptno → Department), a
12
+ -- column that repeats the table's first letter drops it (emp.ename →
13
+ -- Name). Listing pages and navigation use the plural (Employees), forms
14
+ -- and dialog titles the singular (Employee).
15
+ -- - meta.wizard_files(table): a bytea column and the columns that go with
16
+ -- it (<col>_name, <col>_mime, <col>_size, <col>_updated, or filename and
17
+ -- mime_type next to the only bytea column). Forms get one file item bound
18
+ -- to all of them; reports leave them out.
19
+ -- - meta.wizard_report(table, columns): a report query that joins each
20
+ -- foreign key to its parent and shows the parent's display column (the
21
+ -- key itself stays, hidden), with headings and number format masks.
22
+ -- - meta.wizard_report_form(...): the "report and form" pages of the
23
+ -- wizards (meta.generate_page 'report_form'), an edit icon per row.
24
+ -- meta.generate_crud is unchanged (scripts that call it keep their pages).
25
+ -- - meta.wizard_form, meta.wizard_defaults and meta.generate_page use the
26
+ -- labels; charts label rows without a value "(none)".
27
+ -- =====================================================================
28
+
29
+ -- ---------------------------------------------------------------- words
30
+ -- abbreviations and the words they stand for (lowercase key → label)
31
+ create function meta.wizard_abbreviations() returns jsonb
32
+ language sql immutable as $$
33
+ select '{
34
+ "emp": "Employee", "empl": "Employee", "dept": "Department", "sal": "Salary", "comm": "Commission",
35
+ "mgr": "Manager", "no": "Number", "num": "Number", "nr": "Number", "qty": "Quantity", "desc": "Description",
36
+ "descr": "Description", "pct": "Percent", "perc": "Percent", "amt": "Amount", "addr": "Address",
37
+ "tel": "Telephone", "cust": "Customer", "prod": "Product", "cat": "Category", "org": "Organization",
38
+ "loc": "Location", "dt": "Date", "ts": "Timestamp", "cnt": "Count", "img": "Image", "msg": "Message",
39
+ "seq": "Sequence", "ref": "Reference", "req": "Request", "acct": "Account", "avg": "Average",
40
+ "yr": "Year", "dob": "Date of Birth", "id": "ID", "ids": "IDs", "url": "URL", "uri": "URI", "api": "API",
41
+ "crm": "CRM", "sku": "SKU", "vat": "VAT", "ip": "IP", "uuid": "UUID", "html": "HTML", "pdf": "PDF",
42
+ "sms": "SMS", "iban": "IBAN", "bic": "BIC", "hr": "HR", "faq": "FAQ", "kpi": "KPI", "eta": "ETA",
43
+ "iso": "ISO", "utc": "UTC", "gps": "GPS", "lat": "Latitude", "lng": "Longitude", "lon": "Longitude",
44
+ "hiredate": "Hire Date", "username": "Username", "email": "Email"
45
+ }'::jsonb
46
+ $$;
47
+
48
+ -- words that may be run together in a column name (hire + date)
49
+ create function meta.wizard_is_word(p_word text) returns boolean
50
+ language sql immutable as $$
51
+ select p_word = any ('{
52
+ name,first,last,middle,full,user,created,updated,modified,deleted,changed,at,by,on,start,end,time,birth,code,
53
+ type,status,total,price,unit,order,line,item,phone,mail,address,city,zip,postal,country,state,region,amount,
54
+ count,rate,level,group,role,title,note,notes,key,value,number,date,day,month,year,week,hour,size,weight,height,
55
+ width,length,color,flag,is,has,active,valid,from,to,due,paid,sent,received,open,closed,parent,child,owner,
56
+ manager,employee,department,customer,product,category,invoice,payment,account,project,task,job,salary,
57
+ commission,location,description,comment,quantity,percent,image,photo,file,mime,content,short,long,login,
58
+ password,hash,sort,display,label,version,until,effective,approved,reviewed,score,rating,grade,stock,cost,
59
+ budget,tax,discount,sales,hire,leave,request,review,event,visit,contact,policy,audit,log,station,reading,
60
+ work,home,mobile,fax,web,site,page,link,source,target,max,min,fee,net,gross,currency,lang,language,text,
61
+ body,subject,message,author,editor,client,vendor,supplier,team,member,person,company,street,house,expiry,
62
+ expires,valid,last,next,prev,previous,lead,deal,stage,priority,severity,step,plan,actual,estimate,percent
63
+ }'::text[])
64
+ $$;
65
+
66
+ -- one lowercase word as a label: an abbreviation, a known word, or known words run together
67
+ create function meta.wizard_word(p_word text) returns text
68
+ language plpgsql immutable as $$
69
+ declare
70
+ v_abbr jsonb := meta.wizard_abbreviations();
71
+ n int := length(p_word);
72
+ v_best text[];
73
+ v_cnt int[];
74
+ v_piece text;
75
+ begin
76
+ if p_word is null or p_word = '' then
77
+ return p_word;
78
+ end if;
79
+ if v_abbr ? p_word then
80
+ return v_abbr ->> p_word;
81
+ end if;
82
+ if p_word ~ '^[0-9]+$' or meta.wizard_is_word(p_word) or n > 40 then
83
+ return initcap(p_word);
84
+ end if;
85
+ -- the fewest known pieces (two letters or more) that make up the word
86
+ v_best := array_fill(null::text, array[n + 1], array[0]);
87
+ v_cnt := array_fill(null::int, array[n + 1], array[0]);
88
+ v_best[0] := '';
89
+ v_cnt[0] := 0;
90
+ for i in 2..n loop
91
+ for j in 0..i - 2 loop
92
+ continue when v_cnt[j] is null;
93
+ v_piece := substr(p_word, j + 1, i - j);
94
+ if (v_abbr ? v_piece or meta.wizard_is_word(v_piece)) and (v_cnt[i] is null or v_cnt[j] + 1 < v_cnt[i]) then
95
+ v_cnt[i] := v_cnt[j] + 1;
96
+ v_best[i] := v_best[j] || case when j > 0 then ' ' else '' end || coalesce(v_abbr ->> v_piece, initcap(v_piece));
97
+ end if;
98
+ end loop;
99
+ end loop;
100
+ if v_cnt[n] >= 2 then
101
+ return v_best[n];
102
+ end if;
103
+ return initcap(p_word);
104
+ end
105
+ $$;
106
+
107
+ -- a label from a name: order_date → Order Date, hiredate → Hire Date, unitPrice → Unit Price, emp → Employee
108
+ create or replace function meta.wizard_label(p_name text) returns text
109
+ language sql immutable as $$
110
+ select string_agg(meta.wizard_word(w), ' ' order by n)
111
+ from regexp_split_to_table(
112
+ lower(regexp_replace(regexp_replace(regexp_replace(btrim(p_name), '([a-z0-9])([A-Z])', '\1_\2', 'g'),
113
+ '([A-Z]+)([A-Z][a-z])', '\1_\2', 'g'),
114
+ '([A-Za-z])([0-9])', '\1_\2', 'g')),
115
+ '[^a-z0-9]+') with ordinality x(w, n)
116
+ where w <> ''
117
+ $$;
118
+
119
+ -- words that have no plural
120
+ create function meta.wizard_uncountable(p_word text) returns boolean
121
+ language sql immutable as $$
122
+ select lower(p_word) = any ('{staff,data,metadata,information,info,equipment,news,feedback,software,hardware,media,
123
+ series,species,personnel,sheep,fish,money,research,advice,furniture,luggage,traffic}'::text[])
124
+ $$;
125
+
126
+ -- the plural of a label's last word: Employee → Employees, Policy → Policies, Status → Statuses
127
+ create function meta.wizard_plural(p_label text) returns text
128
+ language plpgsql immutable as $$
129
+ declare
130
+ v_last text := substring(p_label from '([A-Za-z]+)$');
131
+ v_head text;
132
+ l text;
133
+ begin
134
+ if v_last is null or meta.wizard_uncountable(v_last) or v_last ~ '^[A-Z]+$' then
135
+ return p_label;
136
+ end if;
137
+ v_head := left(p_label, length(p_label) - length(v_last));
138
+ l := lower(v_last);
139
+ if l = 'person' then
140
+ return v_head || left(v_last, 1) || 'eople';
141
+ elsif l = 'child' then
142
+ return v_head || v_last || 'ren';
143
+ elsif l ~ '[^aeiou]y$' then
144
+ return v_head || left(v_last, -1) || 'ies';
145
+ elsif l ~ '(ss|us|x|z|ch|sh)$' then
146
+ return v_head || v_last || 'es';
147
+ elsif l ~ 's$' then
148
+ -- a plural already
149
+ return p_label;
150
+ end if;
151
+ return v_head || v_last || 's';
152
+ end
153
+ $$;
154
+
155
+ -- the singular of a label's last word: Employees → Employee, Categories → Category
156
+ create function meta.wizard_singular(p_label text) returns text
157
+ language plpgsql immutable as $$
158
+ declare
159
+ v_last text := substring(p_label from '([A-Za-z]+)$');
160
+ v_head text;
161
+ l text;
162
+ begin
163
+ if v_last is null or meta.wizard_uncountable(v_last) or v_last ~ '^[A-Z]+$' then
164
+ return p_label;
165
+ end if;
166
+ v_head := left(p_label, length(p_label) - length(v_last));
167
+ l := lower(v_last);
168
+ if l = 'people' then
169
+ return v_head || left(v_last, 1) || 'erson';
170
+ elsif l = 'children' then
171
+ return v_head || left(v_last, -3);
172
+ elsif l ~ '[^aeiou]ies$' then
173
+ return v_head || left(v_last, -3) || 'y';
174
+ elsif l ~ '(ss|us|is)$' then
175
+ return p_label;
176
+ elsif l ~ '(sses|xes|zes|ches|shes|uses)$' then
177
+ return v_head || left(v_last, -2);
178
+ elsif l ~ 's$' then
179
+ return v_head || left(v_last, -1);
180
+ end if;
181
+ return p_label;
182
+ end
183
+ $$;
184
+
185
+ -- a comment short enough to be a label (one line), else null
186
+ create function meta.wizard_short(p_text text, p_max int) returns text
187
+ language sql immutable as $$
188
+ select case when btrim(p_text) <> '' and length(btrim(p_text)) <= p_max and btrim(p_text) !~ '[\r\n]' and btrim(p_text) !~ '\.$'
189
+ then btrim(p_text) end
190
+ $$;
191
+
192
+ -- a table's label: its short comment, or its name as words (a view's _v suffix left out), plural or singular
193
+ create function meta.wizard_table_label(p_table regclass, p_plural boolean default true) returns text
194
+ language plpgsql stable as $$
195
+ declare
196
+ v_comment text := meta.wizard_short(obj_description(p_table, 'pg_class'), 40);
197
+ v_rel text := (select relname from pg_class where oid = p_table);
198
+ v_label text;
199
+ begin
200
+ if v_comment is not null then
201
+ return v_comment;
202
+ end if;
203
+ v_label := meta.wizard_label(coalesce(nullif(regexp_replace(v_rel, '(_v|_vw|_view)$|^(v|vw)_', '', 'i'), ''), v_rel));
204
+ return case when p_plural then meta.wizard_plural(meta.wizard_singular(v_label)) else meta.wizard_singular(v_label) end;
205
+ end
206
+ $$;
207
+
208
+ -- a column's label: its short comment; a foreign key without its _id/no suffix; emp.ename → Name
209
+ create function meta.wizard_column_label(p_table regclass, p_col text) returns text
210
+ language plpgsql stable as $$
211
+ declare
212
+ v_comment text;
213
+ v_rel text := lower((select relname from pg_class where oid = p_table));
214
+ v_fk boolean;
215
+ v_pk boolean;
216
+ v_base text := p_col;
217
+ v_rest text;
218
+ l text := lower(p_col);
219
+ begin
220
+ select meta.wizard_short(col_description(p_table, a.attnum), 60) into v_comment
221
+ from pg_attribute a where a.attrelid = p_table and a.attname = p_col and not a.attisdropped;
222
+ if v_comment is not null then
223
+ return v_comment;
224
+ end if;
225
+ select c.fk_table is not null, c.is_pk into v_fk, v_pk from meta.wizard_catalog(p_table) c where c.column_name = p_col;
226
+ if v_fk and not v_pk then
227
+ v_rest := regexp_replace(p_col, '_?(id|no|num|nr|code|key|fk)$', '', 'i');
228
+ if length(v_rest) >= 2 then
229
+ v_base := v_rest;
230
+ end if;
231
+ elsif length(l) >= 4 and left(l, 1) = left(v_rel, 1) and not (meta.wizard_abbreviations() ? l) and not meta.wizard_is_word(l)
232
+ and meta.wizard_is_word(substr(l, 2)) then
233
+ -- a column named after the table's first letter: ename, dname
234
+ v_base := substr(p_col, 2);
235
+ end if;
236
+ return meta.wizard_label(v_base);
237
+ end
238
+ $$;
239
+
240
+ -- ---------------------------------------------------------------- files
241
+ -- the bytea columns of a table and the columns that hold their file name, mime type, size and change time
242
+ create function meta.wizard_files(p_table regclass)
243
+ returns table (column_name text, name_column text, mime_column text, size_column text, updated_column text)
244
+ language sql stable as $$
245
+ with c as (select * from meta.wizard_catalog(p_table)),
246
+ b as (select c.column_name from c where c.kind = 'binary'),
247
+ one as (select count(*) = 1 as one from b)
248
+ select b.column_name,
249
+ (select x.column_name from c x
250
+ where x.kind = 'text' and (x.column_name = any (array[b.column_name || '_name', b.column_name || '_filename', b.column_name || '_file_name', b.column_name || 'name'])
251
+ or ((select one from one) and x.column_name in ('filename', 'file_name')))
252
+ order by x.ordinal limit 1),
253
+ (select x.column_name from c x
254
+ where x.kind = 'text' and (x.column_name = any (array[b.column_name || '_mime', b.column_name || '_mimetype', b.column_name || '_mime_type', b.column_name || '_content_type', b.column_name || '_type'])
255
+ or ((select one from one) and x.column_name in ('mime_type', 'mimetype', 'content_type')))
256
+ order by x.ordinal limit 1),
257
+ (select x.column_name from c x
258
+ where x.kind = 'number' and (x.column_name = any (array[b.column_name || '_size', b.column_name || '_length', b.column_name || '_bytes'])
259
+ or ((select one from one) and x.column_name in ('file_size', 'filesize')))
260
+ order by x.ordinal limit 1),
261
+ (select x.column_name from c x
262
+ where x.kind in ('date', 'timestamp') and x.column_name = any (array[b.column_name || '_updated', b.column_name || '_updated_at', b.column_name || '_last_updated', b.column_name || '_lastupd', b.column_name || '_changed_at'])
263
+ order by x.ordinal limit 1)
264
+ from b
265
+ $$;
266
+
267
+ -- the columns that go with a file column (left out of reports and forms: the file item fills them)
268
+ create function meta.wizard_sidecars(p_table regclass) returns text[]
269
+ language sql stable as $$
270
+ select coalesce(array_agg(x) filter (where x is not null), '{}')
271
+ from meta.wizard_files(p_table) f, unnest(array[f.name_column, f.mime_column, f.size_column, f.updated_column]) x
272
+ $$;
273
+
274
+ -- ---------------------------------------------------------------- formats
275
+ -- a number column's format mask: group separators, the scale's decimals (keys get none)
276
+ create function meta.wizard_mask(p_type_sql text, p_kind text, p_key boolean) returns text
277
+ language sql immutable as $$
278
+ select case
279
+ when p_kind <> 'number' or p_key then null
280
+ when p_type_sql ~ '^numeric\(\d+,[1-9]\d*\)$'
281
+ then '999G999G999G990D' || repeat('0', least(substring(p_type_sql from ',(\d+)\)')::int, 6))
282
+ when p_type_sql in ('smallint', 'integer', 'bigint') or p_type_sql ~ '^numeric\(\d+(,0)?\)$' then '999G999G999G990'
283
+ end
284
+ $$;
285
+
286
+ -- ---------------------------------------------------------------- reports
287
+ -- a report on a table: each foreign key shows its parent's display column (the key itself is hidden),
288
+ -- file columns are left out; {"source", "hidden", "headings", "formats"}
289
+ create function meta.wizard_report(p_table regclass, p_columns text[] default null) returns jsonb
290
+ language plpgsql stable as $$
291
+ declare
292
+ v_skip text[] := meta.wizard_sidecars(p_table);
293
+ v_taken text[] := (select array_agg(column_name) from meta.wizard_catalog(p_table));
294
+ v_cols text[] := '{}';
295
+ v_joins text := '';
296
+ v_hidden text[] := '{}';
297
+ v_head jsonb := '{}';
298
+ v_fmt jsonb := '{}';
299
+ v_n int := 0;
300
+ v_name text;
301
+ v_mask text;
302
+ c record;
303
+ begin
304
+ for c in select * from meta.wizard_catalog(p_table) order by ordinal loop
305
+ continue when c.kind in ('binary', 'geometry') or c.column_name = any (v_skip);
306
+ continue when p_columns is not null and not c.is_pk and not (c.column_name = any (p_columns));
307
+ if c.fk_display is not null and c.fk_display <> c.fk_column and not c.is_pk then
308
+ v_n := v_n + 1;
309
+ v_name := c.column_name || '_display';
310
+ while v_name = any (v_taken) loop
311
+ v_name := v_name || '_';
312
+ end loop;
313
+ v_taken := v_taken || v_name;
314
+ v_joins := v_joins || format(E'\n left join %s p%s on p%s.%I = t.%I', meta.wizard_qname(c.fk_table), v_n, v_n, c.fk_column, c.column_name);
315
+ v_cols := v_cols || format('t.%I', c.column_name) || format('p%s.%I as %I', v_n, c.fk_display, v_name);
316
+ v_hidden := v_hidden || c.column_name;
317
+ v_head := v_head || jsonb_build_object(v_name, meta.wizard_column_label(p_table, c.column_name));
318
+ else
319
+ v_cols := v_cols || format('t.%I', c.column_name);
320
+ v_head := v_head || jsonb_build_object(c.column_name, meta.wizard_column_label(p_table, c.column_name));
321
+ v_mask := meta.wizard_mask(c.type_sql, c.kind, c.is_pk or c.fk_table is not null);
322
+ if v_mask is not null then
323
+ v_fmt := v_fmt || jsonb_build_object(c.column_name, v_mask);
324
+ end if;
325
+ end if;
326
+ end loop;
327
+ return jsonb_build_object(
328
+ 'source', format(E'select %s\n from %s t%s', array_to_string(v_cols, E',\n '), meta.wizard_qname(p_table), v_joins),
329
+ 'hidden', to_jsonb(v_hidden), 'headings', v_head, 'formats', v_fmt);
330
+ end
331
+ $$;
332
+
333
+ -- ---------------------------------------------------------------- defaults
334
+ create or replace function meta.wizard_defaults(p_kind text, p_table regclass) returns jsonb
335
+ language plpgsql stable as $$
336
+ declare
337
+ v_pk text := meta.wizard_pk(p_table);
338
+ v_display text := meta.wizard_display(p_table);
339
+ v_skip text[] := meta.wizard_sidecars(p_table);
340
+ v_fk text;
341
+ v_first text;
342
+ v_out jsonb;
343
+ v_start text;
344
+ v_detail record;
345
+ begin
346
+ -- the first foreign key column (not the key itself)
347
+ select column_name into v_fk from meta.wizard_catalog(p_table)
348
+ where fk_table is not null and not is_pk order by (fk_table = p_table), ordinal limit 1;
349
+ v_out := jsonb_build_object('label', meta.wizard_table_label(p_table, p_kind <> 'form'), 'nav', p_kind <> 'form',
350
+ 'icon', case p_kind when 'form' then 'file' when 'cards' then 'layers' when 'calendar' then 'calendar'
351
+ when 'chart' then 'chart' when 'map' then 'map' when 'facets' then 'filter'
352
+ when 'grid' then 'grid' when 'master_detail' then 'grid' else 'table' end);
353
+ case p_kind
354
+ when 'report_form', 'grid' then
355
+ null;
356
+ when 'form' then
357
+ v_out := v_out || jsonb_build_object('mode', 'normal', 'return_page', null,
358
+ 'columns', (select jsonb_agg(column_name order by ordinal) from meta.wizard_catalog(p_table)
359
+ where not generated and not (column_name = any (v_skip))));
360
+ when 'cards' then
361
+ v_first := coalesce(v_display, v_pk);
362
+ v_out := v_out || jsonb_build_object('title', v_first,
363
+ 'subtitle', coalesce(v_fk, (select column_name from meta.wizard_catalog(p_table)
364
+ where kind = 'text' and not is_pk and column_name is distinct from v_first
365
+ and not (column_name = any (v_skip)) order by ordinal limit 1)),
366
+ 'body', (select column_name from meta.wizard_catalog(p_table)
367
+ where kind = 'text' and not is_pk and fk_table is null and column_name is distinct from v_first
368
+ and column_name is distinct from v_fk and not (column_name = any (v_skip))
369
+ and column_name is distinct from (select column_name from meta.wizard_catalog(p_table)
370
+ where kind = 'text' and not is_pk and column_name is distinct from v_first
371
+ and not (column_name = any (v_skip)) order by ordinal limit 1)
372
+ order by ordinal limit 1),
373
+ 'badge', null, 'form_page', null);
374
+ when 'calendar' then
375
+ select column_name into v_start from meta.wizard_catalog(p_table)
376
+ where kind in ('date', 'timestamp') and not (column_name = any (v_skip))
377
+ order by (column_name ~ '(start|begin|from|^date|_date$|_at$|_on$)') desc,
378
+ (column_name ~ '(created|updated|modified|changed)') asc, ordinal
379
+ limit 1;
380
+ v_out := v_out || jsonb_build_object('start', v_start,
381
+ 'end', (select column_name from meta.wizard_catalog(p_table)
382
+ where kind in ('date', 'timestamp') and column_name is distinct from v_start
383
+ and column_name ~ '(end|until|finish|stop|^to_|_to$)'
384
+ order by ordinal limit 1),
385
+ 'title', coalesce((select column_name from meta.wizard_catalog(p_table)
386
+ where kind = 'text' and not is_pk and column_name ~ '(name|title|label|subject|summary)'
387
+ and not (column_name = any (v_skip))
388
+ order by ordinal limit 1), v_fk, v_display, v_pk),
389
+ 'drag', false, 'form_page', null);
390
+ when 'chart' then
391
+ v_out := v_out || jsonb_build_object('chart', 'bar',
392
+ 'label_column', coalesce(v_fk,
393
+ (select column_name from meta.wizard_catalog(p_table) where kind = 'text' and not is_pk and not is_unique
394
+ and not (column_name = any (v_skip)) order by ordinal limit 1),
395
+ (select column_name from meta.wizard_catalog(p_table) where kind in ('text', 'boolean', 'date') and not is_pk
396
+ and not (column_name = any (v_skip)) order by ordinal limit 1),
397
+ v_pk),
398
+ 'function', 'count', 'value_column', null);
399
+ when 'map' then
400
+ v_out := v_out || jsonb_build_object(
401
+ 'location', (select column_name from meta.wizard_catalog(p_table)
402
+ where kind in ('geometry', 'point') or (kind = 'text' and column_name like '%location%')
403
+ order by (kind = 'geometry') desc, (kind = 'point') desc, ordinal limit 1),
404
+ 'lat', (select column_name from meta.wizard_catalog(p_table)
405
+ where kind = 'number' and column_name ~ '^(lat|latitude)$|_lat$|_latitude$|^lat_' order by ordinal limit 1),
406
+ 'lng', (select column_name from meta.wizard_catalog(p_table)
407
+ where kind = 'number' and column_name ~ '^(lng|lon|long|longitude)$|_(lng|lon|long|longitude)$|^(lng|lon)_' order by ordinal limit 1),
408
+ 'title', coalesce(v_display, v_pk),
409
+ 'body', (select column_name from meta.wizard_catalog(p_table)
410
+ where kind = 'text' and not is_pk and column_name is distinct from v_display
411
+ and column_name not like '%location%' and not (column_name = any (v_skip)) order by ordinal limit 1),
412
+ 'report', true, 'form_page', null);
413
+ -- a pair of coordinates wins over a text location
414
+ if v_out ->> 'lat' is not null and v_out ->> 'lng' is not null
415
+ and not exists (select 1 from meta.wizard_catalog(p_table) where column_name = v_out ->> 'location' and kind in ('geometry', 'point')) then
416
+ v_out := v_out || '{"location": null}';
417
+ end if;
418
+ when 'facets' then
419
+ v_out := v_out || jsonb_build_object('search', true, 'form_page', null,
420
+ 'columns', (select jsonb_agg(column_name order by ordinal) from meta.wizard_catalog(p_table)
421
+ where kind not in ('binary', 'geometry') and not (column_name = any (v_skip))),
422
+ 'facets', coalesce((select jsonb_agg(column_name order by rank, ordinal) from (
423
+ select column_name, ordinal,
424
+ case when fk_table is not null then 1 when kind = 'boolean' then 2 when kind = 'text' then 3
425
+ when kind in ('date', 'timestamp') then 4 else 5 end as rank
426
+ from meta.wizard_catalog(p_table)
427
+ where not is_pk and not is_unique and column_name is distinct from v_display and not (column_name = any (v_skip))
428
+ and (fk_table is not null
429
+ or kind = 'boolean'
430
+ -- text with few values (statistics), or, without statistics, not a free text by its name
431
+ or (kind = 'text' and (distinct_values between 1 and 50
432
+ or (distinct_values is null and column_name !~ '(note|descr|comment|remark|body|text|address|mail|url|phone|summary|password|hash|token)')))
433
+ or kind in ('date', 'timestamp')
434
+ or (kind = 'number' and column_name !~ '(^|_)(id|no|lat|lng|lon|latitude|longitude)$'))
435
+ order by rank, ordinal limit 6) f), '[]'));
436
+ when 'master_detail' then
437
+ -- the first table with a foreign key to this table's key
438
+ select con.conrelid::regclass as detail, a.attname::text as col into v_detail
439
+ from pg_constraint con
440
+ join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
441
+ where con.confrelid = p_table and con.contype = 'f' and con.conrelid <> p_table
442
+ and array_length(con.conkey, 1) = 1
443
+ and con.confkey[1] = (select ordinal from meta.wizard_catalog(p_table) where is_pk)
444
+ order by con.conrelid::regclass::text, con.conname
445
+ limit 1;
446
+ v_out := v_out || jsonb_build_object('detail', meta.wizard_qname(v_detail.detail), 'detail_column', v_detail.col,
447
+ 'label', meta.wizard_table_label(p_table) || coalesce(' and ' || meta.wizard_table_label(v_detail.detail), ''));
448
+ else
449
+ raise exception 'unknown page type "%"', p_kind;
450
+ end case;
451
+ return v_out;
452
+ end
453
+ $$;
454
+
455
+ -- ---------------------------------------------------------------- forms
456
+ -- a form page for one row of a table (the form of "report and form", or the form only page)
457
+ create or replace function meta.wizard_form(
458
+ p_app meta.app, p_table regclass, p_page int, p_label text, p_return int, p_modal boolean, p_columns text[] default null
459
+ ) returns int
460
+ language plpgsql as $$
461
+ declare
462
+ v_pk text := meta.wizard_pk(p_table);
463
+ v_skip text[] := meta.wizard_sidecars(p_table);
464
+ v_pk_item text;
465
+ v_page int;
466
+ v_region int;
467
+ v_seq int := 10;
468
+ v_type text;
469
+ v_lov text;
470
+ v_cfg jsonb;
471
+ v_help text;
472
+ f record;
473
+ c record;
474
+ begin
475
+ if v_pk is null then
476
+ raise exception 'a form needs a table with a single-column primary key (% has none)', p_table;
477
+ end if;
478
+ v_pk_item := meta.wizard_item(p_page, v_pk);
479
+ v_page := meta.wizard_new_page(p_app, p_page, case when p_modal then p_label || ' Form' else p_label end,
480
+ coalesce(p_return, p_app.home_page), case when p_modal then 'modal' else 'normal' end);
481
+ update meta.page set title = p_label where id = v_page;
482
+ insert into meta.region (page_id, seq, title, type, table_name, pk_column, pk_item, template)
483
+ values (v_page, 10, p_label, 'form', meta.wizard_qname(p_table), v_pk, v_pk_item, case when p_modal then 'plain' else 'standard' end)
484
+ returning id into v_region;
485
+
486
+ for c in select * from meta.wizard_catalog(p_table) order by ordinal loop
487
+ continue when c.generated and not c.is_pk;
488
+ -- the file item fills these
489
+ continue when c.column_name = any (v_skip);
490
+ -- left out by the developer, unless the row can't be saved without it
491
+ continue when p_columns is not null and not c.is_pk and not (c.column_name = any (p_columns))
492
+ and not (c.not_null and not c.has_default);
493
+ v_lov := null;
494
+ v_cfg := '{}';
495
+ v_type := case
496
+ when c.is_pk then 'hidden'
497
+ when c.fk_table is not null then 'select'
498
+ when c.kind = 'binary' then 'file'
499
+ when c.kind = 'number' then 'number'
500
+ when c.kind = 'date' then 'date'
501
+ when c.kind = 'timestamp' then 'datetime'
502
+ when c.kind = 'boolean' then 'switch'
503
+ when c.kind = 'point' then 'text'
504
+ else 'text'
505
+ end;
506
+ if v_type = 'select' then
507
+ v_lov := format('select %I as d, %I as r from %s order by 1', coalesce(c.fk_display, c.fk_column), c.fk_column, meta.wizard_qname(c.fk_table));
508
+ elsif v_type = 'file' then
509
+ select * into f from meta.wizard_files(p_table) x where x.column_name = c.column_name;
510
+ v_cfg := jsonb_strip_nulls(jsonb_build_object('filename_column', f.name_column, 'mime_column', f.mime_column,
511
+ 'size_column', f.size_column, 'updated_column', f.updated_column,
512
+ 'accept', case when c.column_name ~* '(photo|image|picture|avatar|logo|thumbnail|portrait)' then 'image/*' end));
513
+ elsif v_type = 'number' then
514
+ v_cfg := jsonb_strip_nulls(jsonb_build_object('format_mask', meta.wizard_mask(c.type_sql, c.kind, false)));
515
+ end if;
516
+ -- a longer column comment is the item's help text
517
+ v_help := (select btrim(col_description(p_table, a.attnum)) from pg_attribute a where a.attrelid = p_table and a.attname = c.column_name);
518
+ if meta.wizard_short(v_help, 60) is not null or v_help = '' then
519
+ v_help := null;
520
+ end if;
521
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, source_column, required, config, help)
522
+ values (v_page, v_region, v_seq, meta.wizard_item(p_page, c.column_name), meta.wizard_column_label(p_table, c.column_name),
523
+ v_type, v_lov, c.column_name, c.not_null and not c.has_default and not c.is_pk and v_type <> 'switch', v_cfg, v_help);
524
+ v_seq := v_seq + 10;
525
+ end loop;
526
+
527
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, condition, hot, confirm) values
528
+ (v_page, v_region, 10, 'CANCEL', 'Cancel', 'redirect', coalesce(p_return, p_app.home_page, p_page), null, false, null),
529
+ (v_page, v_region, 20, 'DELETE', 'Delete', 'submit', coalesce(p_return, p_app.home_page, p_page),
530
+ format(':%s is not null', v_pk_item), false, 'Delete this record?'),
531
+ (v_page, v_region, 30, 'SAVE', 'Apply Changes', 'submit', coalesce(p_return, p_app.home_page, p_page),
532
+ format(':%s is not null', v_pk_item), true, null),
533
+ (v_page, v_region, 40, 'CREATE', 'Create', 'submit', coalesce(p_return, p_app.home_page, p_page),
534
+ format(':%s is null', v_pk_item), true, null);
535
+ insert into meta.process (page_id, seq, name, type, region_id)
536
+ values (v_page, 10, 'Process form ' || p_label, 'form_dml', v_region);
537
+ return v_page;
538
+ end
539
+ $$;
540
+
541
+ -- an interactive report of a table with an edit icon per row, its modal form and a Create button
542
+ create function meta.wizard_report_form(
543
+ p_app meta.app, p_table regclass, p_page int, p_form int, p_label text, p_form_label text
544
+ ) returns int
545
+ language plpgsql as $$
546
+ declare
547
+ v_pk text := meta.wizard_pk(p_table);
548
+ v_rep jsonb := meta.wizard_report(p_table);
549
+ v_page int;
550
+ v_region int;
551
+ begin
552
+ if v_pk is null then
553
+ raise exception 'a report and form needs a table with a single-column primary key (% has none)', p_table;
554
+ end if;
555
+ if p_form is null then
556
+ raise exception 'a report and form needs a form page number';
557
+ end if;
558
+ v_page := meta.wizard_new_page(p_app, p_page, p_label, p_app.home_page);
559
+ insert into meta.region (page_id, seq, title, type, source, config)
560
+ values (v_page, 10, p_label, 'report', v_rep ->> 'source',
561
+ jsonb_build_object('link', jsonb_build_object('column', v_pk, 'page', p_form, 'icon', true,
562
+ 'items', jsonb_build_object(meta.wizard_item(p_form, v_pk), '#' || v_pk || '#')),
563
+ 'headings', v_rep -> 'headings')
564
+ || case when jsonb_array_length(v_rep -> 'hidden') > 0 then jsonb_build_object('hidden', v_rep -> 'hidden') else '{}' end
565
+ || case when v_rep -> 'formats' <> '{}' then jsonb_build_object('formats', v_rep -> 'formats') else '{}' end)
566
+ returning id into v_region;
567
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot)
568
+ values (v_page, v_region, 10, 'CREATE', 'Create', 'redirect', p_form, true);
569
+ perform meta.wizard_form(p_app, p_table, p_form, p_form_label, p_page, true, null);
570
+ return v_page;
571
+ end
572
+ $$;
573
+
574
+ -- ---------------------------------------------------------------- generator
575
+ create or replace function meta.generate_page(
576
+ p_app text,
577
+ p_kind text,
578
+ p_table regclass,
579
+ p_page int,
580
+ p_options jsonb default '{}'
581
+ ) returns int
582
+ language plpgsql as $$
583
+ declare
584
+ v_app meta.app;
585
+ o jsonb;
586
+ v_label text;
587
+ v_flabel text;
588
+ v_icon text;
589
+ v_pk text := meta.wizard_pk(p_table);
590
+ v_page int;
591
+ v_region int;
592
+ v_report int;
593
+ v_form int;
594
+ v_fpage int;
595
+ v_link jsonb;
596
+ v_cols text;
597
+ v_heads jsonb := '{}';
598
+ v_src text;
599
+ v_cfg jsonb;
600
+ v_a text;
601
+ v_b text;
602
+ v_c text;
603
+ v_d text;
604
+ v_kind text;
605
+ v_detail regclass;
606
+ v_dpk text;
607
+ v_item text;
608
+ v_facets jsonb := '[]';
609
+ v_geo text;
610
+ v_key text;
611
+ v_skip text[] := meta.wizard_sidecars(p_table);
612
+ c record;
613
+ begin
614
+ select * into v_app from meta.app where alias = p_app;
615
+ if v_app.id is null then
616
+ raise exception 'application "%" does not exist', p_app;
617
+ end if;
618
+ if p_kind not in ('report_form', 'grid', 'form', 'cards', 'calendar', 'chart', 'map', 'facets', 'master_detail') then
619
+ raise exception 'unknown page type "%"', p_kind;
620
+ end if;
621
+ -- the application's own data: not pgkiln's or the system's tables
622
+ if (select n.nspname ~ '^pg_' or n.nspname in ('information_schema', 'meta')
623
+ from pg_class rel join pg_namespace n on n.oid = rel.relnamespace where rel.oid = p_table) then
624
+ raise exception 'pages can''t be generated on %', p_table;
625
+ end if;
626
+ if (select relkind from pg_class where oid = p_table) not in ('r', 'p', 'v', 'm') then
627
+ raise exception '% is not a table or view', p_table;
628
+ end if;
629
+ if p_options is null or jsonb_typeof(p_options) <> 'object' then
630
+ p_options := '{}';
631
+ end if;
632
+ -- an option given as JSON null or '' means "none"; a missing one takes the default
633
+ o := meta.wizard_defaults(p_kind, p_table) || p_options;
634
+ v_label := coalesce(nullif(btrim(o ->> 'label'), ''), meta.wizard_table_label(p_table, p_kind <> 'form'));
635
+ -- (097) a form page of a listing is titled with the singular: Employees → Employee
636
+ v_flabel := coalesce(nullif(btrim(o ->> 'form_label'), ''), meta.wizard_table_label(p_table, false));
637
+ v_icon := nullif(o ->> 'icon', '');
638
+ v_fpage := nullif(o ->> 'form_page', '')::int;
639
+ if v_fpage = p_page then
640
+ raise exception 'the form page needs a page number of its own';
641
+ end if;
642
+
643
+ if p_kind = 'report_form' then
644
+ v_page := meta.wizard_report_form(v_app, p_table, p_page, v_fpage, v_label, v_flabel);
645
+ elsif p_kind = 'grid' then
646
+ perform meta.generate_grid(p_app, p_table, p_page, v_label, coalesce(v_icon, 'grid'));
647
+ return (select id from meta.page where app_id = v_app.id and page_no = p_page);
648
+ elsif p_kind = 'form' then
649
+ v_page := meta.wizard_form(v_app, p_table, p_page, v_label, nullif(o ->> 'return_page', '')::int, o ->> 'mode' = 'modal',
650
+ case when jsonb_typeof(o -> 'columns') = 'array'
651
+ then (select array_agg(meta.wizard_col(p_table, x, 'form column')) from jsonb_array_elements_text(o -> 'columns') x) end);
652
+ elsif p_kind = 'master_detail' then
653
+ if v_pk is null then
654
+ raise exception 'the master table % needs a single-column primary key', p_table;
655
+ end if;
656
+ if nullif(o ->> 'detail', '') is null then
657
+ raise exception 'no detail table: % has no table with a foreign key to it', p_table;
658
+ end if;
659
+ v_detail := (o ->> 'detail')::regclass;
660
+ v_dpk := meta.wizard_pk(v_detail);
661
+ if v_dpk is null then
662
+ raise exception 'the detail table % needs a single-column primary key', v_detail;
663
+ end if;
664
+ v_a := meta.wizard_col(v_detail, o ->> 'detail_column', 'detail column');
665
+ if v_a is null then
666
+ raise exception 'choose the detail column that refers to the master row';
667
+ end if;
668
+ v_item := meta.wizard_item(p_page, v_pk);
669
+ v_page := meta.wizard_new_page(v_app, p_page, v_label, v_app.home_page);
670
+ insert into meta.region (page_id, seq, title, type, columns, source, table_name, pk_column, config)
671
+ values (v_page, 10, meta.wizard_table_label(p_table), 'grid', 12,
672
+ format(E'select %s\n from %s', (select string_agg(quote_ident(column_name), ', ' order by ordinal)
673
+ from meta.wizard_catalog(p_table) where kind <> 'binary' and not (column_name = any (v_skip))),
674
+ meta.wizard_qname(p_table)),
675
+ meta.wizard_qname(p_table), v_pk,
676
+ jsonb_build_object('page_size', 10, 'columns', meta.wizard_grid_columns(p_table),
677
+ 'select_row', jsonb_build_object('column', v_pk, 'item', v_item)))
678
+ returning id into v_region;
679
+ insert into meta.item (page_id, region_id, seq, name, label, type)
680
+ values (v_page, v_region, 10, v_item, meta.wizard_column_label(p_table, v_pk), 'hidden');
681
+ insert into meta.region (page_id, seq, title, type, columns, source, table_name, pk_column, config)
682
+ values (v_page, 20, meta.wizard_table_label(v_detail), 'grid', 12,
683
+ format(E'select %s\n from %s\n where %I = :%s::%s',
684
+ (select string_agg(quote_ident(column_name), ', ' order by ordinal)
685
+ from meta.wizard_catalog(v_detail) where kind <> 'binary' and column_name <> v_a
686
+ and not (column_name = any (meta.wizard_sidecars(v_detail)))),
687
+ meta.wizard_qname(v_detail), v_a, v_item, (select type_sql from meta.wizard_catalog(p_table) where column_name = v_pk)),
688
+ meta.wizard_qname(v_detail), v_dpk,
689
+ jsonb_build_object('page_size', 10, 'columns', meta.wizard_grid_columns(v_detail, v_a),
690
+ 'master', jsonb_build_object('item', v_item, 'column', v_a)))
691
+ returning id into v_report;
692
+ insert into meta.process (page_id, seq, name, type, region_id) values
693
+ (v_page, 10, 'Save ' || meta.wizard_table_label(p_table), 'grid_dml', v_region),
694
+ (v_page, 20, 'Save ' || meta.wizard_table_label(v_detail), 'grid_dml', v_report);
695
+ else
696
+ -- the listing kinds, with an optional modal form page for a row
697
+ if v_fpage is not null and v_pk is null then
698
+ raise exception 'a form page needs a table with a single-column primary key (% has none)', p_table;
699
+ end if;
700
+ v_page := meta.wizard_new_page(v_app, p_page, v_label, v_app.home_page);
701
+ v_key := case when v_pk is not null then format('t.%I', v_pk) end;
702
+ if v_fpage is not null then
703
+ v_link := jsonb_build_object('page', v_fpage, 'items', jsonb_build_object(meta.wizard_item(v_fpage, v_pk), '#' || v_pk || '#'));
704
+ end if;
705
+
706
+ if p_kind = 'cards' then
707
+ v_a := meta.wizard_col(p_table, o ->> 'title', 'title column');
708
+ v_b := meta.wizard_col(p_table, o ->> 'subtitle', 'subtitle column');
709
+ v_c := meta.wizard_col(p_table, o ->> 'body', 'body column');
710
+ v_d := meta.wizard_col(p_table, o ->> 'badge', 'badge column');
711
+ if v_a is null then
712
+ raise exception 'choose the column for the card titles';
713
+ end if;
714
+ v_src := concat_ws(E',\n ',
715
+ meta.wizard_expr(p_table, v_a) || ' as title',
716
+ meta.wizard_expr(p_table, v_b) || ' as subtitle',
717
+ meta.wizard_expr(p_table, v_c) || ' as body',
718
+ meta.wizard_expr(p_table, v_d) || ' as badge',
719
+ v_key);
720
+ insert into meta.region (page_id, seq, title, type, source, config)
721
+ values (v_page, 10, v_label, 'cards', format(E'select %s\n from %s t\n order by 1', v_src, meta.wizard_qname(p_table)),
722
+ jsonb_strip_nulls(jsonb_build_object('link', v_link)))
723
+ returning id into v_region;
724
+
725
+ elsif p_kind = 'calendar' then
726
+ v_a := meta.wizard_col(p_table, o ->> 'start', 'start column');
727
+ v_b := meta.wizard_col(p_table, o ->> 'end', 'end column');
728
+ v_c := meta.wizard_col(p_table, o ->> 'title', 'title column');
729
+ if v_a is null or (select kind from meta.wizard_catalog(p_table) where column_name = v_a) not in ('date', 'timestamp') then
730
+ raise exception 'a calendar needs a date or timestamp start column';
731
+ end if;
732
+ if v_b is not null and (select kind from meta.wizard_catalog(p_table) where column_name = v_b) not in ('date', 'timestamp') then
733
+ raise exception 'the end column must be a date or timestamp';
734
+ end if;
735
+ v_src := concat_ws(E',\n ',
736
+ format('t.%I as start_date', v_a),
737
+ case when v_b is not null then format('t.%I as end_date', v_b) end,
738
+ coalesce(meta.wizard_expr(p_table, v_c), quote_literal(v_label)) || ' as title',
739
+ v_key);
740
+ v_cfg := jsonb_strip_nulls(jsonb_build_object('link', v_link, 'key', v_pk));
741
+ if v_fpage is not null then
742
+ v_cfg := v_cfg || jsonb_build_object('create', jsonb_build_object('page', v_fpage, 'items',
743
+ jsonb_strip_nulls(jsonb_build_object(meta.wizard_item(v_fpage, v_a), '#start#')
744
+ || case when v_b is not null then jsonb_build_object(meta.wizard_item(v_fpage, v_b), '#end#') else '{}' end)));
745
+ end if;
746
+ if coalesce((o ->> 'drag')::boolean, false) then
747
+ if v_pk is null then
748
+ raise exception 'drag and drop needs a table with a single-column primary key (% has none)', p_table;
749
+ end if;
750
+ v_cfg := v_cfg || jsonb_build_object('move', format('update %s set %I = :NEW_START::%s%s where %I = :EVENT_ID::%s',
751
+ meta.wizard_qname(p_table), v_a, (select type_sql from meta.wizard_catalog(p_table) where column_name = v_a),
752
+ case when v_b is not null then format(', %I = :NEW_END::%s', v_b, (select type_sql from meta.wizard_catalog(p_table) where column_name = v_b)) else '' end,
753
+ v_pk, (select type_sql from meta.wizard_catalog(p_table) where column_name = v_pk)));
754
+ end if;
755
+ insert into meta.region (page_id, seq, title, type, source, config)
756
+ values (v_page, 10, v_label, 'calendar', format(E'select %s\n from %s t', v_src, meta.wizard_qname(p_table)), v_cfg)
757
+ returning id into v_region;
758
+
759
+ elsif p_kind = 'chart' then
760
+ v_a := meta.wizard_col(p_table, o ->> 'label_column', 'label column');
761
+ v_b := meta.wizard_col(p_table, o ->> 'value_column', 'value column');
762
+ v_kind := coalesce(nullif(o ->> 'chart', ''), 'bar');
763
+ v_c := lower(coalesce(nullif(o ->> 'function', ''), 'count'));
764
+ if v_a is null then
765
+ raise exception 'choose the label column of the chart';
766
+ end if;
767
+ if v_kind not in ('bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel') then
768
+ raise exception 'unknown chart type "%"', v_kind;
769
+ end if;
770
+ if v_c not in ('count', 'sum', 'avg', 'min', 'max') then
771
+ raise exception 'unknown function "%"', v_c;
772
+ end if;
773
+ if v_c <> 'count' and (v_b is null or (select kind from meta.wizard_catalog(p_table) where column_name = v_b) <> 'number') then
774
+ raise exception 'the function % needs a number column', v_c;
775
+ end if;
776
+ v_d := case when v_c = 'count' and v_b is null then 'Count'
777
+ else initcap(case v_c when 'avg' then 'average' when 'min' then 'minimum' when 'max' then 'maximum' else v_c end)
778
+ || ' of ' || meta.wizard_column_label(p_table, v_b) end;
779
+ -- the label as a derived column, so a foreign key's display value can be grouped;
780
+ -- (097) rows without a value are labelled "(none)" instead of an empty bar
781
+ v_src := meta.wizard_expr(p_table, v_a);
782
+ if exists (select 1 from meta.wizard_catalog(p_table) where column_name = v_a and (kind = 'text' or fk_table is not null) and not (not_null and fk_table is null)) then
783
+ v_src := format('coalesce((%s)::text, %L)', v_src, '(none)');
784
+ end if;
785
+ insert into meta.region (page_id, seq, title, type, source, config)
786
+ values (v_page, 10, v_label, 'chart',
787
+ format(E'select t.%I,\n %s as %I\n from (select %s as %I%s from %s t) t\n group by 1\n order by %s',
788
+ meta.wizard_column_label(p_table, v_a),
789
+ case when v_b is null then 'count(*)' else format('%s(t.%I)', v_c, v_b) end, v_d,
790
+ v_src, meta.wizard_column_label(p_table, v_a),
791
+ case when v_b is null then '' else format(', t.%I', v_b) end,
792
+ meta.wizard_qname(p_table), case when v_kind in ('bar', 'donut', 'pie', 'funnel') then '2 desc' else '1' end),
793
+ jsonb_build_object('kind', v_kind) || case when v_kind in ('bar', 'donut', 'pie', 'funnel') then '{"top": 10}'::jsonb else '{}' end)
794
+ returning id into v_region;
795
+
796
+ elsif p_kind = 'map' then
797
+ v_a := meta.wizard_col(p_table, o ->> 'lat', 'latitude column');
798
+ v_b := meta.wizard_col(p_table, o ->> 'lng', 'longitude column');
799
+ v_c := meta.wizard_col(p_table, o ->> 'location', 'location column');
800
+ if v_c is not null then
801
+ v_kind := (select kind from meta.wizard_catalog(p_table) where column_name = v_c);
802
+ v_src := case v_kind
803
+ when 'geometry' then format('st_y(st_pointonsurface(t.%1$I::geometry)) as lat, st_x(st_pointonsurface(t.%1$I::geometry)) as lng', v_c)
804
+ when 'point' then format('t.%1$I[1] as lat, t.%1$I[0] as lng', v_c)
805
+ when 'text' then format('t.%I as location', v_c)
806
+ else null end;
807
+ -- lines and areas are drawn as shapes too
808
+ if v_kind = 'geometry' then
809
+ v_geo := format('case when geometrytype(t.%1$I::geometry) <> ''POINT'' then st_asgeojson(t.%1$I::geometry) end as geojson', v_c);
810
+ end if;
811
+ if v_src is null then
812
+ raise exception 'the location column must hold "latitude,longitude" text, a point or a PostGIS geometry';
813
+ end if;
814
+ v_d := format('t.%I is not null', v_c);
815
+ elsif v_a is not null and v_b is not null then
816
+ if (select count(*) from meta.wizard_catalog(p_table) where column_name in (v_a, v_b) and kind = 'number') <> 2 then
817
+ raise exception 'latitude and longitude must be number columns';
818
+ end if;
819
+ v_src := format('t.%I as lat, t.%I as lng', v_a, v_b);
820
+ v_d := format('t.%I is not null and t.%I is not null', v_a, v_b);
821
+ else
822
+ raise exception 'a map needs latitude and longitude columns, or a location, point or geometry column';
823
+ end if;
824
+ v_cols := concat_ws(E',\n ', v_src, v_geo,
825
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'title', 'title column')) || ' as title',
826
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'body', 'body column')) || ' as body',
827
+ v_key);
828
+ insert into meta.region (page_id, seq, title, type, source, config)
829
+ values (v_page, 10, v_label, 'map', format(E'select %s\n from %s t\n where %s', v_cols, meta.wizard_qname(p_table), v_d),
830
+ jsonb_strip_nulls(jsonb_build_object('link', v_link)))
831
+ returning id into v_region;
832
+ if coalesce((o ->> 'report')::boolean, true) then
833
+ -- a report of the same rows, filtered by the map area ("Show this area in the list")
834
+ insert into meta.region (page_id, seq, title, type, source, config)
835
+ values (v_page, 20, v_label || ' list', 'report',
836
+ format(E'select %s,\n %s\n from %s t',
837
+ (select string_agg(meta.wizard_expr(p_table, column_name) || ' as ' || quote_ident(column_name), ', ' order by ordinal)
838
+ from meta.wizard_catalog(p_table)
839
+ where kind not in ('binary', 'geometry', 'point') and column_name not in ('lat', 'lng', 'location')
840
+ and not (column_name = any (v_skip))),
841
+ v_src, meta.wizard_qname(p_table)),
842
+ jsonb_strip_nulls(jsonb_build_object('link', v_link || jsonb_build_object('column', v_pk),
843
+ 'headings', (select jsonb_object_agg(column_name, meta.wizard_column_label(p_table, column_name)) from meta.wizard_catalog(p_table)))))
844
+ returning id into v_report;
845
+ update meta.region set config = config || jsonb_build_object('report', v_report) where id = v_region;
846
+ end if;
847
+
848
+ elsif p_kind = 'facets' then
849
+ perform meta.wizard_col(p_table, x, 'report column') from jsonb_array_elements_text(coalesce(o -> 'columns', '[]')) x;
850
+ perform meta.wizard_col(p_table, x, 'facet column') from jsonb_array_elements_text(coalesce(o -> 'facets', '[]')) x;
851
+ -- the report: the chosen columns, and the parent's display column next to each foreign key
852
+ v_cols := null;
853
+ for c in
854
+ select w.* from meta.wizard_catalog(p_table) w
855
+ where w.column_name in (select x from jsonb_array_elements_text(coalesce(o -> 'columns', '[]')) x)
856
+ or w.is_pk
857
+ or w.column_name in (select x from jsonb_array_elements_text(coalesce(o -> 'facets', '[]')) x)
858
+ order by w.ordinal
859
+ loop
860
+ continue when c.kind in ('binary', 'geometry');
861
+ v_cols := concat_ws(E',\n ', v_cols, format('t.%I', c.column_name));
862
+ v_heads := v_heads || jsonb_build_object(c.column_name, meta.wizard_column_label(p_table, c.column_name));
863
+ if c.fk_display is not null and c.fk_display <> c.fk_column then
864
+ v_cols := concat_ws(E',\n ', v_cols, meta.wizard_expr(p_table, c.column_name) || ' as ' || quote_ident(c.column_name || '_' || c.fk_display));
865
+ v_heads := v_heads || jsonb_build_object(c.column_name || '_' || c.fk_display, meta.wizard_column_label(p_table, c.column_name));
866
+ end if;
867
+ end loop;
868
+ if v_cols is null then
869
+ raise exception 'choose at least one report column';
870
+ end if;
871
+ for c in
872
+ select w.* from jsonb_array_elements_text(coalesce(o -> 'facets', '[]')) with ordinality x(col, n)
873
+ join meta.wizard_catalog(p_table) w on w.column_name = x.col
874
+ order by x.n
875
+ loop
876
+ continue when c.kind in ('binary', 'geometry', 'point', 'other');
877
+ v_facets := v_facets || jsonb_build_array(jsonb_strip_nulls(jsonb_build_object(
878
+ 'column', case when c.fk_display is not null and c.fk_display <> c.fk_column then c.column_name || '_' || c.fk_display else c.column_name end,
879
+ 'label', meta.wizard_column_label(p_table, c.column_name),
880
+ 'type', case when c.kind in ('number', 'date', 'timestamp') and c.fk_table is null then 'range' end,
881
+ 'custom', case when c.kind in ('number', 'date', 'timestamp') and c.fk_table is null then true end)));
882
+ end loop;
883
+ insert into meta.region (page_id, seq, title, type, columns, source, config)
884
+ values (v_page, 20, v_label, 'report', 9, format(E'select %s\n from %s t', v_cols, meta.wizard_qname(p_table)),
885
+ jsonb_strip_nulls(jsonb_build_object('link', v_link || jsonb_build_object('column', v_pk), 'headings', v_heads)))
886
+ returning id into v_region;
887
+ insert into meta.region (page_id, seq, title, type, columns, template, config)
888
+ values (v_page, 10, 'Filters', 'facets', 3, 'collapsible',
889
+ jsonb_build_object('report', v_region, 'search', coalesce((o ->> 'search')::boolean, true), 'facets', v_facets));
890
+ end if;
891
+
892
+ if v_fpage is not null then
893
+ perform meta.wizard_form(v_app, p_table, v_fpage, v_flabel, p_page, true, null);
894
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot)
895
+ values (v_page, v_region, 10, 'CREATE', 'Create', 'redirect', v_fpage, true);
896
+ end if;
897
+ end if;
898
+
899
+ if coalesce((o ->> 'nav')::boolean, false) then
900
+ perform meta.wizard_nav(v_app, v_label, v_icon, p_page);
901
+ end if;
902
+ return v_page;
903
+ end
904
+ $$;
905
+
906
+ revoke all on function meta.wizard_abbreviations(), meta.wizard_is_word(text), meta.wizard_word(text), meta.wizard_uncountable(text),
907
+ meta.wizard_plural(text), meta.wizard_singular(text), meta.wizard_short(text, int), meta.wizard_table_label(regclass, boolean),
908
+ meta.wizard_column_label(regclass, text), meta.wizard_files(regclass), meta.wizard_sidecars(regclass),
909
+ meta.wizard_mask(text, text, boolean), meta.wizard_report(regclass, text[]),
910
+ meta.wizard_report_form(meta.app, regclass, int, int, text, text)
911
+ from public;