pgkiln 0.40.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.
- package/db/migrations/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/03-builder.md +103 -19
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/09-reference.md +6 -2
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +19 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +219 -33
- package/public/app.js +206 -0
- package/public/builder.css +31 -3
- package/public/builder.js +31 -0
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +4 -32
- package/src/builder/mail.ts +39 -0
- package/src/builder/newapp.ts +182 -5
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +36 -34
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +15 -0
- package/src/i18n/cs.ts +15 -0
- package/src/i18n/da.ts +15 -0
- package/src/i18n/de.ts +15 -0
- package/src/i18n/el.ts +15 -0
- package/src/i18n/es.ts +15 -0
- package/src/i18n/fi.ts +15 -0
- package/src/i18n/fr.ts +15 -0
- package/src/i18n/he.ts +15 -0
- package/src/i18n/it.ts +15 -0
- package/src/i18n/ja.ts +15 -0
- package/src/i18n/ko.ts +15 -0
- package/src/i18n/nb.ts +15 -0
- package/src/i18n/pl.ts +15 -0
- package/src/i18n/pt.ts +15 -0
- package/src/i18n/ru.ts +15 -0
- package/src/i18n/sv.ts +15 -0
- package/src/i18n/tr.ts +15 -0
- package/src/i18n/uk.ts +15 -0
- package/src/i18n/zh.ts +15 -0
- package/src/i18n.ts +30 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +304 -15
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +7 -4
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/locale.ts +2 -2
- package/src/runtime/regions.ts +28 -2
- package/src/runtime/render.ts +18 -4
- package/src/runtime/report.ts +15 -9
- package/src/runtime/routes.ts +39 -0
- package/src/runtime/styles.ts +5 -2
- package/src/websources.ts +11 -3
- package/src/workspace-export.ts +240 -0
|
@@ -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;
|