@firedrill-tools/notion 0.1.1
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/LICENSE +201 -0
- package/README.md +402 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounded.scenario.json +19 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/notion-bounded.drill.json +318 -0
- package/firedrill/notion-byte-budget.drill.json +116 -0
- package/firedrill/notion-mcp-aliases.drill.json +150 -0
- package/firedrill/notion-page-authoring.drill.json +254 -0
- package/firedrill/notion-rate-limited.drill.json +118 -0
- package/firedrill/notion-schema-growth.drill.json +88 -0
- package/firedrill/notion-scope-agent-only.drill.json +131 -0
- package/firedrill/notion-scope-auditor.drill.json +86 -0
- package/firedrill/notion-scope-board-bot.drill.json +128 -0
- package/firedrill/notion-scope-notes-bot.drill.json +303 -0
- package/firedrill/notion-scope-stranger.drill.json +773 -0
- package/firedrill/notion-task-triage.drill.json +277 -0
- package/firedrill/notion-trash-and-restore.drill.json +186 -0
- package/firedrill/notion-update-lost.drill.json +88 -0
- package/firedrill/notion-workspace-read.drill.json +258 -0
- package/firedrill/notion-write-unavailable.drill.json +161 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/app.js +797 -0
- package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/chrome.js +104 -0
- package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
- package/firedrill/tools/notion/app/site/database.js +648 -0
- package/firedrill/tools/notion/app/site/editors.js +320 -0
- package/firedrill/tools/notion/app/site/format-bar.js +97 -0
- package/firedrill/tools/notion/app/site/icons.js +131 -0
- package/firedrill/tools/notion/app/site/index.html +125 -0
- package/firedrill/tools/notion/app/site/page.js +826 -0
- package/firedrill/tools/notion/app/site/rich.js +159 -0
- package/firedrill/tools/notion/app/site/state.js +170 -0
- package/firedrill/tools/notion/app/site/styles.css +826 -0
- package/firedrill/tools/notion/app/site/ui.js +418 -0
- package/firedrill/tools/notion/behavior.mjs +1123 -0
- package/firedrill/tools/notion/lib/blocks.mjs +371 -0
- package/firedrill/tools/notion/lib/identity.mjs +123 -0
- package/firedrill/tools/notion/lib/ids.mjs +63 -0
- package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
- package/firedrill/tools/notion/lib/markdown.mjs +381 -0
- package/firedrill/tools/notion/lib/properties.mjs +513 -0
- package/firedrill/tools/notion/lib/query.mjs +272 -0
- package/firedrill/tools/notion/lib/render.mjs +137 -0
- package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
- package/firedrill/tools/notion/lib/size.mjs +44 -0
- package/firedrill/tools/notion/lib/state.mjs +192 -0
- package/firedrill/tools/notion/lib/wire.mjs +89 -0
- package/firedrill/tools/notion/notion.tool.json +9837 -0
- package/firedrill/update-lost.scenario.json +11 -0
- package/firedrill/world.json +7039 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +63 -0
- package/starter.json +6482 -0
- package/test/conformance.mjs +1186 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
// Database views: the product's Table and Board views over a data source, with a filter builder and sort menu
|
|
2
|
+
// that compose real data-sources.query requests, inline cell editing (pages.update), row creation
|
|
3
|
+
// (pages.create), schema changes (data-sources.update) and the side peek for rows.
|
|
4
|
+
import { closePeek, emptyState, openPeek, skeleton } from "./chrome.js";
|
|
5
|
+
import { openPropertyEditor } from "./editors.js";
|
|
6
|
+
import { icon, propertyIcon } from "./icons.js";
|
|
7
|
+
import { OPTION_COLORS, checkbox, emojiOf, objectIcon, pill, plain, propertyValue, textToRich } from "./rich.js";
|
|
8
|
+
import { canWrite, loadIndex, lookup, navigate, pageTitle, state } from "./state.js";
|
|
9
|
+
import { $, action, call, closePopovers, confirmDialog, describe, el, iconButton, key, openPopover, notSimulated, snackbar, textButton, ToolError, unsimulatedButton } from "./ui.js";
|
|
10
|
+
|
|
11
|
+
const PAGE_SIZE = 50;
|
|
12
|
+
const COMPUTED = ["created_time", "created_by", "last_edited_time", "last_edited_by"];
|
|
13
|
+
const orderedDefinitions = (definitions) => [...definitions.filter((definition) => !COMPUTED.includes(definition.type)), ...definitions.filter((definition) => COMPUTED.includes(definition.type))];
|
|
14
|
+
const CONDITIONS = {
|
|
15
|
+
text: [["contains", "Contains"], ["does_not_contain", "Does not contain"], ["equals", "Is"], ["does_not_equal", "Is not"], ["starts_with", "Starts with"], ["is_empty", "Is empty"], ["is_not_empty", "Is not empty"]],
|
|
16
|
+
number: [["equals", "="], ["does_not_equal", "≠"], ["greater_than", ">"], ["less_than", "<"], ["greater_than_or_equal_to", "≥"], ["less_than_or_equal_to", "≤"], ["is_empty", "Is empty"], ["is_not_empty", "Is not empty"]],
|
|
17
|
+
option: [["equals", "Is"], ["does_not_equal", "Is not"], ["is_empty", "Is empty"], ["is_not_empty", "Is not empty"]],
|
|
18
|
+
list: [["contains", "Contains"], ["does_not_contain", "Does not contain"], ["is_empty", "Is empty"], ["is_not_empty", "Is not empty"]],
|
|
19
|
+
checkbox: [["equals", "Is"]],
|
|
20
|
+
date: [["equals", "Is"], ["before", "Is before"], ["after", "Is after"], ["on_or_before", "Is on or before"], ["on_or_after", "Is on or after"], ["this_week", "This week"], ["past_week", "Past week"], ["past_month", "Past month"], ["next_week", "Next week"], ["next_month", "Next month"], ["is_empty", "Is empty"], ["is_not_empty", "Is not empty"]],
|
|
21
|
+
};
|
|
22
|
+
const NO_VALUE = new Set(["is_empty", "is_not_empty", "this_week", "past_week", "past_month", "past_year", "next_week", "next_month", "next_year"]);
|
|
23
|
+
|
|
24
|
+
// Per-database view settings for this viewer (filters, sorts, search); never authority over records.
|
|
25
|
+
const settings = new Map();
|
|
26
|
+
function settingsFor(id) {
|
|
27
|
+
if (!settings.has(id)) settings.set(id, { filters: [], sort: undefined, search: "" });
|
|
28
|
+
return settings.get(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function conditionKind(type) {
|
|
32
|
+
if (["title", "rich_text", "url", "email", "phone_number"].includes(type)) return "text";
|
|
33
|
+
if (type === "number") return "number";
|
|
34
|
+
if (type === "select" || type === "status") return "option";
|
|
35
|
+
if (["multi_select", "people", "relation"].includes(type)) return "list";
|
|
36
|
+
if (type === "checkbox") return "checkbox";
|
|
37
|
+
if (["date", "created_time", "last_edited_time"].includes(type)) return "date";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Notion filter object from the viewer's filter rows and search text. */
|
|
42
|
+
function buildFilter(schema, setting) {
|
|
43
|
+
const conditions = [];
|
|
44
|
+
for (const filter of setting.filters) {
|
|
45
|
+
const definition = schema[filter.property];
|
|
46
|
+
if (!definition) continue;
|
|
47
|
+
const type = definition.type;
|
|
48
|
+
let clause;
|
|
49
|
+
if (NO_VALUE.has(filter.condition)) clause = { [filter.condition]: true };
|
|
50
|
+
else if (conditionKind(type) === "checkbox") clause = { equals: filter.value === "true" };
|
|
51
|
+
else if (conditionKind(type) === "number") {
|
|
52
|
+
if (filter.value === "" || Number.isNaN(Number(filter.value))) continue;
|
|
53
|
+
clause = { [filter.condition]: Number(filter.value) };
|
|
54
|
+
} else {
|
|
55
|
+
if (!filter.value) continue;
|
|
56
|
+
clause = { [filter.condition]: filter.value };
|
|
57
|
+
}
|
|
58
|
+
conditions.push({ property: definition.name, [type]: clause });
|
|
59
|
+
}
|
|
60
|
+
if (setting.search.trim().length > 0) {
|
|
61
|
+
const title = Object.values(schema).find((definition) => definition.type === "title");
|
|
62
|
+
if (title) conditions.push({ property: title.name, title: { contains: setting.search.trim() } });
|
|
63
|
+
}
|
|
64
|
+
if (conditions.length === 0) return undefined;
|
|
65
|
+
return conditions.length === 1 ? conditions[0] : { and: conditions };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildSorts(schema, setting) {
|
|
69
|
+
if (!setting.sort) return undefined;
|
|
70
|
+
const definition = schema[setting.sort.property];
|
|
71
|
+
if (!definition) return undefined;
|
|
72
|
+
return [{ property: definition.name, direction: setting.sort.direction }];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------------------------
|
|
76
|
+
// Entry points
|
|
77
|
+
// ---------------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Full-page database view (route #/database/<id>/<view>). */
|
|
80
|
+
export function renderDatabaseView(host, databaseId, options = {}) {
|
|
81
|
+
const view = { databaseId, mode: options.view ?? "table", inline: false, database: undefined, source: undefined, rows: [], cursor: null, more: false, error: undefined, queryError: undefined, loading: true, onChanged: options.onChanged, onLoaded: options.onLoaded };
|
|
82
|
+
host.replaceChildren(el("div", { class: "page-shell" }, skeleton(8)));
|
|
83
|
+
view.refresh = () => load(host, view);
|
|
84
|
+
view.setMode = (mode) => { view.mode = mode; render(host, view); };
|
|
85
|
+
void view.refresh();
|
|
86
|
+
return view;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Inline database (child_database block with is_inline) rendered inside a page; returns the element. */
|
|
90
|
+
export function renderInlineDatabase(databaseId, options = {}) {
|
|
91
|
+
const host = el("div", { class: "inline-db-host" }, skeleton(3));
|
|
92
|
+
const view = { databaseId, mode: "table", inline: true, database: undefined, source: undefined, rows: [], cursor: null, more: false, error: undefined, queryError: undefined, loading: true, onChanged: options.onChanged };
|
|
93
|
+
view.refresh = () => load(host, view);
|
|
94
|
+
view.setMode = (mode) => { view.mode = mode; render(host, view); };
|
|
95
|
+
void view.refresh();
|
|
96
|
+
return host;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function load(host, view) {
|
|
100
|
+
try {
|
|
101
|
+
view.database = await call("databases.retrieve", { database_id: view.databaseId });
|
|
102
|
+
const sourceId = view.database.data_sources[0]?.id;
|
|
103
|
+
view.source = await call("data-sources.retrieve", { data_source_id: sourceId });
|
|
104
|
+
view.error = undefined;
|
|
105
|
+
await query(view, false);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
view.error = error;
|
|
108
|
+
}
|
|
109
|
+
view.loading = false;
|
|
110
|
+
render(host, view);
|
|
111
|
+
view.onLoaded?.(view);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function query(view, append) {
|
|
115
|
+
const setting = settingsFor(view.databaseId);
|
|
116
|
+
const request = { data_source_id: view.source.id, page_size: PAGE_SIZE };
|
|
117
|
+
const filter = buildFilter(view.source.properties, setting);
|
|
118
|
+
const sorts = buildSorts(view.source.properties, setting);
|
|
119
|
+
if (filter) request.filter = filter;
|
|
120
|
+
if (sorts) request.sorts = sorts;
|
|
121
|
+
if (append && view.cursor) request.start_cursor = view.cursor;
|
|
122
|
+
try {
|
|
123
|
+
const result = await call("data-sources.query", request);
|
|
124
|
+
view.rows = append ? [...view.rows, ...result.results] : result.results;
|
|
125
|
+
view.cursor = result.next_cursor;
|
|
126
|
+
view.more = result.has_more;
|
|
127
|
+
view.queryError = undefined;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
view.queryError = error;
|
|
130
|
+
if (!append) view.rows = [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------------------------
|
|
135
|
+
// Rendering
|
|
136
|
+
// ---------------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function render(host, view) {
|
|
139
|
+
host.replaceChildren();
|
|
140
|
+
if (view.error) {
|
|
141
|
+
const error = view.error;
|
|
142
|
+
const denied = error instanceof ToolError && (error.denied || error.is("RESTRICTED_RESOURCE"));
|
|
143
|
+
host.append(el("div", { class: "page-shell" }, el("div", { class: "page-content" }, emptyState(denied ? "You don't have access to this database" : "This content doesn't exist or you don't have access.", describe(error), [textButton("Go to Home", { onClick: () => navigate({ name: "home" }) })]))));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const shell = el("div", { class: `page-shell ${view.inline ? "inline" : "database-page"}`.trim() });
|
|
147
|
+
const content = el("div", { class: "page-content wide" });
|
|
148
|
+
content.append(databaseHeader(view, host));
|
|
149
|
+
content.append(viewToolbar(view, host));
|
|
150
|
+
const setting = settingsFor(view.databaseId);
|
|
151
|
+
if (setting.filters.length > 0 || setting.sort) content.append(filterBar(view, host));
|
|
152
|
+
if (view.queryError) content.append(el("div", { class: "query-error" }, [icon("warning"), el("span", { text: describe(view.queryError) }), textButton("Retry", { onClick: () => action(async () => { await query(view, false); render(host, view); }) })]));
|
|
153
|
+
else if (view.mode === "board") content.append(boardView(view, host));
|
|
154
|
+
else content.append(tableView(view, host));
|
|
155
|
+
shell.append(content);
|
|
156
|
+
host.append(shell);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function databaseHeader(view, host) {
|
|
160
|
+
const database = view.database;
|
|
161
|
+
const header = el("div", { class: `db-header ${view.inline ? "inline" : ""}`.trim() });
|
|
162
|
+
const title = plain(database.title) || "Untitled";
|
|
163
|
+
const emoji = emojiOf(database.icon);
|
|
164
|
+
const heading = el(view.inline ? "h2" : "h1", { class: `db-title ${view.inline ? "inline" : "page-title"}`.trim() });
|
|
165
|
+
if (emoji !== undefined) heading.append(el("span", { class: view.inline ? "db-inline-icon" : "page-emoji", text: emoji }));
|
|
166
|
+
const text = el("span", { class: "db-title-text", text: title, attrs: { "aria-label": "Database title" } });
|
|
167
|
+
if (canWrite() && !database.in_trash) {
|
|
168
|
+
text.setAttribute("contenteditable", "plaintext-only");
|
|
169
|
+
text.setAttribute("spellcheck", "false");
|
|
170
|
+
text.addEventListener("blur", () => {
|
|
171
|
+
const next = text.textContent.trim();
|
|
172
|
+
if (next === title || next.length === 0) { text.textContent = title; return; }
|
|
173
|
+
void action(async () => {
|
|
174
|
+
await call("data-sources.update", { data_source_id: view.source.id, title: textToRich(next) }, key());
|
|
175
|
+
await loadIndex();
|
|
176
|
+
await view.refresh();
|
|
177
|
+
view.onChanged?.();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
text.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); text.blur(); } });
|
|
181
|
+
}
|
|
182
|
+
heading.append(text);
|
|
183
|
+
header.append(heading);
|
|
184
|
+
const description = plain(database.description);
|
|
185
|
+
if (description) header.append(el("p", { class: "db-description", text: description }));
|
|
186
|
+
return header;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function viewToolbar(view, host) {
|
|
190
|
+
const bar = el("div", { class: "view-toolbar" });
|
|
191
|
+
const tabs = el("div", { class: "view-tabs", attrs: { role: "tablist" } });
|
|
192
|
+
for (const [mode, label, glyph] of [["table", "Table", "table"], ["board", "Board", "board"]]) {
|
|
193
|
+
const tab = el("button", { class: `view-tab ${view.mode === mode ? "active" : ""}`.trim(), attrs: { type: "button", role: "tab", "aria-selected": String(view.mode === mode) } }, [icon(glyph, "view-tab-icon"), el("span", { text: label })]);
|
|
194
|
+
tab.addEventListener("click", () => {
|
|
195
|
+
if (view.inline) view.setMode(mode);
|
|
196
|
+
else navigate({ name: "database", id: view.databaseId, view: mode });
|
|
197
|
+
});
|
|
198
|
+
tabs.append(tab);
|
|
199
|
+
}
|
|
200
|
+
const addView = el("button", { class: "view-tab add", attrs: { type: "button", title: "Add a new view", "aria-label": "Add a new view", "aria-expanded": "false" } }, [icon("plus", "view-tab-icon")]);
|
|
201
|
+
addView.addEventListener("click", () => openPopover(addView, [
|
|
202
|
+
{ header: "Add a new view" },
|
|
203
|
+
{ label: "Table", icon: "table", checked: view.mode === "table", onSelect: () => (view.inline ? view.setMode("table") : navigate({ name: "database", id: view.databaseId, view: "table" })) },
|
|
204
|
+
{ label: "Board", icon: "board", checked: view.mode === "board", onSelect: () => (view.inline ? view.setMode("board") : navigate({ name: "database", id: view.databaseId, view: "board" })) },
|
|
205
|
+
"divider",
|
|
206
|
+
{ header: "Not simulated by this Tool" },
|
|
207
|
+
...[["Timeline", "timeline"], ["Calendar", "calendar"], ["List", "list_view"], ["Gallery", "gallery"], ["Chart", "chart"], ["Feed", "feed"], ["Map", "map"]].map(([label, glyph]) => ({ label, icon: glyph, disabled: true })),
|
|
208
|
+
], { width: 260 }));
|
|
209
|
+
tabs.append(addView);
|
|
210
|
+
bar.append(tabs);
|
|
211
|
+
|
|
212
|
+
const actions = el("div", { class: "view-actions" });
|
|
213
|
+
const setting = settingsFor(view.databaseId);
|
|
214
|
+
const filterBtn = textButton("Filter", { class: `toolbar-btn ${setting.filters.length > 0 ? "active" : ""}`.trim(), onClick: () => openAddFilter(filterBtn, view, host) });
|
|
215
|
+
const sortBtn = textButton("Sort", { class: `toolbar-btn ${setting.sort ? "active" : ""}`.trim(), onClick: () => openSortMenu(sortBtn, view, host) });
|
|
216
|
+
const searchBtn = iconButton("search", "Search", { class: setting.search ? "active" : "" });
|
|
217
|
+
const searchBox = el("input", { class: "view-search", attrs: { type: "search", placeholder: "Type to search…", "aria-label": "Search in view" } });
|
|
218
|
+
searchBox.value = setting.search;
|
|
219
|
+
searchBox.hidden = setting.search.length === 0;
|
|
220
|
+
let timer;
|
|
221
|
+
searchBox.addEventListener("input", () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
timer = setTimeout(() => {
|
|
224
|
+
setting.search = searchBox.value;
|
|
225
|
+
void action(async () => { await query(view, false); render(host, view); if (setting.search) { const box = host.querySelector(".view-search"); box?.focus(); box?.setSelectionRange(box.value.length, box.value.length); } }, { exclusive: false });
|
|
226
|
+
}, 250);
|
|
227
|
+
});
|
|
228
|
+
searchBtn.addEventListener("click", () => { searchBox.hidden = false; searchBox.focus(); });
|
|
229
|
+
const more = iconButton("more", "View options", { onClick: () => openPopover(more, [
|
|
230
|
+
{ header: "View options" },
|
|
231
|
+
{ label: "Properties", icon: "prop_multi_select", description: "Add, rename or delete properties", onSelect: () => openSchemaEditor(more, view.source.id, view.source.properties, async () => { await loadIndex(); await view.refresh(); view.onChanged?.(); }) },
|
|
232
|
+
{ label: "Open as full page", icon: "open", disabled: !view.inline, onSelect: () => navigate({ name: "database", id: view.databaseId, view: view.mode }) },
|
|
233
|
+
"divider",
|
|
234
|
+
{ label: "Reload", icon: "refresh", onSelect: () => action(async () => { await view.refresh(); }) },
|
|
235
|
+
], { align: "end", width: 260 }) });
|
|
236
|
+
const create = el("button", { class: "btn primary new-btn", attrs: { type: "button" } }, [el("span", { text: "New" })]);
|
|
237
|
+
create.disabled = !canWrite("read_update_insert") || view.database.in_trash;
|
|
238
|
+
create.addEventListener("click", () => createRow(view, host));
|
|
239
|
+
const caret = el("button", { class: "new-caret", attrs: { type: "button", "aria-label": "New page options", title: "New page options", "aria-expanded": "false" } }, [icon("chevron_down")]);
|
|
240
|
+
caret.disabled = create.disabled;
|
|
241
|
+
caret.addEventListener("click", () => openPopover(caret, [
|
|
242
|
+
{ header: "Templates" },
|
|
243
|
+
{ label: "New page", icon: "page", onSelect: () => createRow(view, host) },
|
|
244
|
+
"divider",
|
|
245
|
+
{ label: "New template", icon: "plus", disabled: true, description: "Database templates are not simulated by this Tool" },
|
|
246
|
+
], { align: "end", width: 260 }));
|
|
247
|
+
const automations = unsimulatedButton("bolt", "Automations", "Database automations are not part of the API subset this Tool serves.", { align: "end" });
|
|
248
|
+
actions.append(filterBtn, sortBtn, automations, searchBtn, searchBox, more, el("span", { class: "new-split" }, [create, caret]));
|
|
249
|
+
bar.append(actions);
|
|
250
|
+
return bar;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function filterBar(view, host) {
|
|
254
|
+
const setting = settingsFor(view.databaseId);
|
|
255
|
+
const bar = el("div", { class: "filter-bar" });
|
|
256
|
+
setting.filters.forEach((filter, index) => {
|
|
257
|
+
const definition = view.source.properties[filter.property];
|
|
258
|
+
if (!definition) return;
|
|
259
|
+
const label = NO_VALUE.has(filter.condition) ? labelFor(definition.type, filter.condition) : `${labelFor(definition.type, filter.condition).toLowerCase()} ${displayValue(definition, filter.value)}`;
|
|
260
|
+
const chip = el("button", { class: "filter-chip", attrs: { type: "button", "aria-expanded": "false" } }, [icon(propertyIcon(definition.type), "chip-icon"), el("span", { class: "chip-name", text: definition.name }), el("span", { class: "chip-cond", text: label }), icon("chevron_down", "chip-chevron")]);
|
|
261
|
+
chip.addEventListener("click", () => openFilterEditor(chip, view, host, index));
|
|
262
|
+
bar.append(chip);
|
|
263
|
+
});
|
|
264
|
+
if (setting.sort) {
|
|
265
|
+
const definition = view.source.properties[setting.sort.property];
|
|
266
|
+
if (definition) {
|
|
267
|
+
const chip = el("button", { class: "filter-chip sort", attrs: { type: "button" } }, [icon("sort", "chip-icon"), el("span", { class: "chip-name", text: definition.name }), el("span", { class: "chip-cond", text: setting.sort.direction === "ascending" ? "Ascending" : "Descending" }), icon("chevron_down", "chip-chevron")]);
|
|
268
|
+
chip.addEventListener("click", () => openSortMenu(chip, view, host));
|
|
269
|
+
bar.append(chip);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const add = textButton("Add filter", { class: "ghost small", icon: "plus", onClick: () => openAddFilter(add, view, host) });
|
|
273
|
+
bar.append(add);
|
|
274
|
+
return bar;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function labelFor(type, condition) {
|
|
278
|
+
const kind = conditionKind(type);
|
|
279
|
+
const entry = (CONDITIONS[kind] ?? []).find(([id]) => id === condition);
|
|
280
|
+
return entry ? entry[1] : condition;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function displayValue(definition, value) {
|
|
284
|
+
if (definition.type === "people") return state.users.get(value)?.name ?? value;
|
|
285
|
+
if (definition.type === "relation") return state.pages.get(value)?.title ?? value;
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function openAddFilter(anchor, view, host) {
|
|
290
|
+
const properties = Object.values(view.source.properties).filter((definition) => conditionKind(definition.type) !== undefined);
|
|
291
|
+
const box = el("div", { class: "editor-options" });
|
|
292
|
+
const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Filter by…", "aria-label": "Filter by property" } });
|
|
293
|
+
const list = el("div", { class: "picker-list" });
|
|
294
|
+
const renderList = () => {
|
|
295
|
+
list.replaceChildren();
|
|
296
|
+
const needle = search.value.trim().toLowerCase();
|
|
297
|
+
for (const definition of properties.filter((candidate) => candidate.name.toLowerCase().includes(needle))) {
|
|
298
|
+
const row = el("button", { class: "picker-item", attrs: { type: "button" } }, [icon(propertyIcon(definition.type), "picker-icon"), el("span", { class: "picker-label", text: definition.name })]);
|
|
299
|
+
row.addEventListener("click", () => {
|
|
300
|
+
closePopovers();
|
|
301
|
+
const setting = settingsFor(view.databaseId);
|
|
302
|
+
const kind = conditionKind(definition.type);
|
|
303
|
+
setting.filters.push({ property: definition.name, condition: CONDITIONS[kind][0][0], value: kind === "checkbox" ? "true" : "" });
|
|
304
|
+
render(host, view);
|
|
305
|
+
const chip = host.querySelectorAll(".filter-chip:not(.sort)")[setting.filters.length - 1];
|
|
306
|
+
if (chip) openFilterEditor(chip, view, host, setting.filters.length - 1);
|
|
307
|
+
});
|
|
308
|
+
list.append(row);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
search.addEventListener("input", renderList);
|
|
312
|
+
box.append(search, list);
|
|
313
|
+
renderList();
|
|
314
|
+
return openPopover(anchor, box, { width: 260 });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function openFilterEditor(anchor, view, host, index) {
|
|
318
|
+
const setting = settingsFor(view.databaseId);
|
|
319
|
+
const filter = setting.filters[index];
|
|
320
|
+
const definition = view.source.properties[filter.property];
|
|
321
|
+
const kind = conditionKind(definition.type);
|
|
322
|
+
const box = el("div", { class: "filter-editor" });
|
|
323
|
+
const head = el("div", { class: "filter-editor-head" }, [icon(propertyIcon(definition.type), "chip-icon"), el("span", { class: "chip-name", text: definition.name })]);
|
|
324
|
+
const condition = el("select", { class: "filter-select", attrs: { "aria-label": "Condition" } });
|
|
325
|
+
for (const [id, label] of CONDITIONS[kind]) condition.append(el("option", { text: label, attrs: { value: id, selected: id === filter.condition ? "" : undefined } }));
|
|
326
|
+
head.append(condition);
|
|
327
|
+
box.append(head);
|
|
328
|
+
const valueHost = el("div", { class: "filter-value" });
|
|
329
|
+
const renderValue = () => {
|
|
330
|
+
valueHost.replaceChildren();
|
|
331
|
+
if (NO_VALUE.has(condition.value)) return;
|
|
332
|
+
if (kind === "checkbox") {
|
|
333
|
+
const select = el("select", { class: "filter-select", attrs: { "aria-label": "Value" } }, [el("option", { text: "Checked", attrs: { value: "true" } }), el("option", { text: "Unchecked", attrs: { value: "false" } })]);
|
|
334
|
+
select.value = filter.value || "true";
|
|
335
|
+
select.addEventListener("change", () => { filter.value = select.value; apply(); });
|
|
336
|
+
valueHost.append(select);
|
|
337
|
+
} else if (kind === "option") {
|
|
338
|
+
const options = definition[definition.type]?.options ?? [];
|
|
339
|
+
const select = el("select", { class: "filter-select", attrs: { "aria-label": "Value" } }, [el("option", { text: "Select an option", attrs: { value: "" } }), ...options.map((option) => el("option", { text: option.name, attrs: { value: option.name } }))]);
|
|
340
|
+
select.value = filter.value;
|
|
341
|
+
select.addEventListener("change", () => { filter.value = select.value; apply(); });
|
|
342
|
+
valueHost.append(select);
|
|
343
|
+
} else if (definition.type === "multi_select") {
|
|
344
|
+
const options = definition.multi_select?.options ?? [];
|
|
345
|
+
const select = el("select", { class: "filter-select", attrs: { "aria-label": "Value" } }, [el("option", { text: "Select an option", attrs: { value: "" } }), ...options.map((option) => el("option", { text: option.name, attrs: { value: option.name } }))]);
|
|
346
|
+
select.value = filter.value;
|
|
347
|
+
select.addEventListener("change", () => { filter.value = select.value; apply(); });
|
|
348
|
+
valueHost.append(select);
|
|
349
|
+
} else if (definition.type === "people") {
|
|
350
|
+
const people = [...state.users.values()].filter((user) => user.type === "person");
|
|
351
|
+
const select = el("select", { class: "filter-select", attrs: { "aria-label": "Person" } }, [el("option", { text: "Select a person", attrs: { value: "" } }), ...people.map((user) => el("option", { text: user.name, attrs: { value: user.id } }))]);
|
|
352
|
+
select.value = filter.value;
|
|
353
|
+
select.addEventListener("change", () => { filter.value = select.value; apply(); });
|
|
354
|
+
valueHost.append(select);
|
|
355
|
+
} else if (definition.type === "relation") {
|
|
356
|
+
const targets = [...state.pages.values()].filter((page) => page.parent?.type === "data_source_id" && page.parent.data_source_id === definition.relation?.data_source_id);
|
|
357
|
+
const select = el("select", { class: "filter-select", attrs: { "aria-label": "Page" } }, [el("option", { text: "Select a page", attrs: { value: "" } }), ...targets.map((page) => el("option", { text: page.title, attrs: { value: page.id } }))]);
|
|
358
|
+
select.value = filter.value;
|
|
359
|
+
select.addEventListener("change", () => { filter.value = select.value; apply(); });
|
|
360
|
+
valueHost.append(select);
|
|
361
|
+
} else {
|
|
362
|
+
const input = el("input", { class: "editor-input", attrs: { type: kind === "number" ? "number" : kind === "date" ? "date" : "text", placeholder: "Type a value…", "aria-label": "Value", step: "any" } });
|
|
363
|
+
input.value = filter.value;
|
|
364
|
+
let timer;
|
|
365
|
+
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { filter.value = input.value; apply(true); }, 300); });
|
|
366
|
+
input.addEventListener("keydown", (event) => { if (event.key === "Enter") { clearTimeout(timer); filter.value = input.value; apply(); } });
|
|
367
|
+
valueHost.append(input);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const apply = (keepOpen = false) => {
|
|
371
|
+
void action(async () => {
|
|
372
|
+
await query(view, false);
|
|
373
|
+
if (!keepOpen) closePopovers();
|
|
374
|
+
render(host, view);
|
|
375
|
+
}, { exclusive: false });
|
|
376
|
+
};
|
|
377
|
+
condition.addEventListener("change", () => { filter.condition = condition.value; renderValue(); apply(true); });
|
|
378
|
+
renderValue();
|
|
379
|
+
box.append(valueHost);
|
|
380
|
+
const remove = textButton("Delete filter", { class: "ghost small danger", icon: "trash", onClick: () => { setting.filters.splice(index, 1); apply(); } });
|
|
381
|
+
box.append(el("div", { class: "filter-editor-actions" }, [remove]));
|
|
382
|
+
return openPopover(anchor, box, { width: 300 });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function openSortMenu(anchor, view, host) {
|
|
386
|
+
const setting = settingsFor(view.databaseId);
|
|
387
|
+
const properties = Object.values(view.source.properties);
|
|
388
|
+
const items = [{ header: "Sort by" }];
|
|
389
|
+
for (const definition of properties) {
|
|
390
|
+
for (const direction of ["ascending", "descending"]) {
|
|
391
|
+
const checked = setting.sort?.property === definition.name && setting.sort.direction === direction;
|
|
392
|
+
items.push({ label: `${definition.name} · ${direction === "ascending" ? "Ascending" : "Descending"}`, icon: propertyIcon(definition.type), checked, onSelect: () => { setting.sort = { property: definition.name, direction }; void action(async () => { await query(view, false); render(host, view); }); } });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (setting.sort) items.push("divider", { label: "Remove sort", icon: "close", danger: true, onSelect: () => { setting.sort = undefined; void action(async () => { await query(view, false); render(host, view); }); } });
|
|
396
|
+
return openPopover(anchor, items, { width: 280 });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------------------------
|
|
400
|
+
// Table view
|
|
401
|
+
// ---------------------------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
function tableView(view, host) {
|
|
404
|
+
const maps = lookup();
|
|
405
|
+
const definitions = orderedDefinitions(Object.values(view.source.properties));
|
|
406
|
+
const title = definitions.find((definition) => definition.type === "title");
|
|
407
|
+
const columns = [title, ...definitions.filter((definition) => definition.type !== "title")].filter(Boolean);
|
|
408
|
+
const wrap = el("div", { class: "table-wrap" });
|
|
409
|
+
const table = el("table", { class: "db-table", attrs: { role: "grid", "aria-label": `${plain(view.database.title) || "Untitled"} table` } });
|
|
410
|
+
const head = el("thead");
|
|
411
|
+
const headRow = el("tr");
|
|
412
|
+
for (const definition of columns) {
|
|
413
|
+
const cell = el("th", { attrs: { scope: "col" } });
|
|
414
|
+
const button = el("button", { class: "col-head", attrs: { type: "button", "aria-expanded": "false" } }, [icon(propertyIcon(definition.type), "col-icon"), el("span", { class: "col-name", text: definition.name })]);
|
|
415
|
+
button.addEventListener("click", () => openColumnMenu(button, view, host, definition));
|
|
416
|
+
cell.append(button);
|
|
417
|
+
headRow.append(cell);
|
|
418
|
+
}
|
|
419
|
+
if (canWrite("read_update") && !view.database.in_trash) {
|
|
420
|
+
const add = el("th", { class: "col-add" }, iconButton("plus", "Add a property", { class: "small", onClick: (event) => openSchemaEditor(event.currentTarget, view.source.id, view.source.properties, async () => { await loadIndex(); await view.refresh(); view.onChanged?.(); }, { add: true }) }));
|
|
421
|
+
headRow.append(add);
|
|
422
|
+
}
|
|
423
|
+
head.append(headRow);
|
|
424
|
+
table.append(head);
|
|
425
|
+
const body = el("tbody");
|
|
426
|
+
const writable = canWrite() && !view.database.in_trash;
|
|
427
|
+
for (const row of view.rows) {
|
|
428
|
+
const tr = el("tr", { attrs: { "data-page-id": row.id } });
|
|
429
|
+
columns.forEach((definition, index) => {
|
|
430
|
+
const property = row.properties[definition.name];
|
|
431
|
+
const td = el("td", { class: `cell type-${definition.type} ${index === 0 ? "title-cell" : ""}`.trim() });
|
|
432
|
+
if (!property) { tr.append(td); return; }
|
|
433
|
+
if (index === 0) {
|
|
434
|
+
const open = el("button", { class: "row-title", attrs: { type: "button" } }, [objectIcon(row.icon, "page", "row-icon"), el("span", { class: "row-title-text", text: pageTitle(row) })]);
|
|
435
|
+
open.addEventListener("click", () => openRow(view, host, row.id));
|
|
436
|
+
const peekBtn = el("button", { class: "open-btn", attrs: { type: "button", "aria-label": "Open page" } }, [icon("open"), el("span", { text: "OPEN" })]);
|
|
437
|
+
peekBtn.addEventListener("click", () => openRow(view, host, row.id));
|
|
438
|
+
td.append(open, peekBtn);
|
|
439
|
+
} else if (definition.type === "checkbox") {
|
|
440
|
+
td.append(checkbox(property.checkbox === true, { label: definition.name, onToggle: writable ? (next) => writeCell(view, host, row.id, definition.name, { checkbox: next }) : undefined }));
|
|
441
|
+
} else {
|
|
442
|
+
const computed = ["created_time", "created_by", "last_edited_time", "last_edited_by"].includes(definition.type);
|
|
443
|
+
const cell = el("button", { class: `cell-btn ${computed || !writable ? "readonly" : ""}`.trim(), attrs: { type: "button", "aria-label": `${definition.name} of ${pageTitle(row)}`, "aria-expanded": "false" } }, propertyValue(property, maps));
|
|
444
|
+
if (computed || !writable) cell.disabled = true;
|
|
445
|
+
else cell.addEventListener("click", () => openPropertyEditor(cell, definition, property, (next) => writeCell(view, host, row.id, definition.name, next), { onNotice: (text) => snackbar(text) }));
|
|
446
|
+
td.append(cell);
|
|
447
|
+
}
|
|
448
|
+
tr.append(td);
|
|
449
|
+
});
|
|
450
|
+
if (canWrite("read_update") && !view.database.in_trash) tr.append(el("td", { class: "col-add" }));
|
|
451
|
+
body.append(tr);
|
|
452
|
+
}
|
|
453
|
+
if (view.rows.length === 0) {
|
|
454
|
+
const empty = el("tr", { class: "empty-row" });
|
|
455
|
+
const setting = settingsFor(view.databaseId);
|
|
456
|
+
empty.append(el("td", { attrs: { colspan: String(columns.length + 1) } }, el("div", { class: "table-empty", text: setting.filters.length > 0 || setting.search ? "No results" : "No pages yet" })));
|
|
457
|
+
body.append(empty);
|
|
458
|
+
}
|
|
459
|
+
table.append(body);
|
|
460
|
+
const foot = el("tfoot");
|
|
461
|
+
if (writable && canWrite("read_update_insert")) {
|
|
462
|
+
const newRow = el("tr", { class: "new-row" });
|
|
463
|
+
const cell = el("td", { attrs: { colspan: String(columns.length + 1) } });
|
|
464
|
+
const button = el("button", { class: "new-row-btn", attrs: { type: "button" } }, [icon("plus"), el("span", { text: "New" })]);
|
|
465
|
+
button.addEventListener("click", () => createRow(view, host));
|
|
466
|
+
cell.append(button);
|
|
467
|
+
newRow.append(cell);
|
|
468
|
+
foot.append(newRow);
|
|
469
|
+
}
|
|
470
|
+
const calc = el("tr", { class: "calc-row" });
|
|
471
|
+
const count = el("td", { class: "calc-cell" }, [el("span", { class: "calc-label", text: "COUNT" }), el("span", { class: "calc-value", text: view.more ? `${view.rows.length}+` : String(view.rows.length) })]);
|
|
472
|
+
calc.append(count, el("td", { attrs: { colspan: String(columns.length) } }));
|
|
473
|
+
foot.append(calc);
|
|
474
|
+
table.append(foot);
|
|
475
|
+
wrap.append(table);
|
|
476
|
+
if (view.more) wrap.append(textButton(`Load ${PAGE_SIZE} more`, { class: "load-more", onClick: () => action(async () => { await query(view, true); render(host, view); }, { exclusive: false }) }));
|
|
477
|
+
return wrap;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function writeCell(view, host, pageId, name, valueObject) {
|
|
481
|
+
return action(async () => {
|
|
482
|
+
await call("pages.update", { page_id: pageId, properties: { [name]: valueObject } }, key());
|
|
483
|
+
await query(view, false);
|
|
484
|
+
render(host, view);
|
|
485
|
+
view.onChanged?.();
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function openColumnMenu(anchor, view, host, definition) {
|
|
490
|
+
const setting = settingsFor(view.databaseId);
|
|
491
|
+
const items = [
|
|
492
|
+
{ header: definition.type.replace(/_/g, " ") },
|
|
493
|
+
{ label: "Sort ascending", icon: "sort", onSelect: () => { setting.sort = { property: definition.name, direction: "ascending" }; void action(async () => { await query(view, false); render(host, view); }); } },
|
|
494
|
+
{ label: "Sort descending", icon: "sort", onSelect: () => { setting.sort = { property: definition.name, direction: "descending" }; void action(async () => { await query(view, false); render(host, view); }); } },
|
|
495
|
+
];
|
|
496
|
+
if (conditionKind(definition.type)) items.push({ label: "Filter", icon: "filter", onSelect: () => { const kind = conditionKind(definition.type); setting.filters.push({ property: definition.name, condition: CONDITIONS[kind][0][0], value: kind === "checkbox" ? "true" : "" }); render(host, view); const chip = host.querySelectorAll(".filter-chip:not(.sort)")[setting.filters.length - 1]; if (chip) openFilterEditor(chip, view, host, setting.filters.length - 1); } });
|
|
497
|
+
if (canWrite("read_update") && !view.database.in_trash) {
|
|
498
|
+
items.push("divider", { label: "Edit property", icon: "settings", onSelect: () => openSchemaEditor(anchor, view.source.id, view.source.properties, async () => { await loadIndex(); await view.refresh(); view.onChanged?.(); }, { focus: definition.name }) });
|
|
499
|
+
if (definition.type !== "title") items.push({ label: "Delete property", icon: "trash", danger: true, onSelect: () => action(async () => {
|
|
500
|
+
if (!(await confirmDialog(`Delete the property "${definition.name}"?`, "Its values are removed from every page of this database.", "Delete"))) return;
|
|
501
|
+
await call("data-sources.update", { data_source_id: view.source.id, properties: { [definition.name]: null } }, key());
|
|
502
|
+
await loadIndex();
|
|
503
|
+
await view.refresh();
|
|
504
|
+
view.onChanged?.();
|
|
505
|
+
}) });
|
|
506
|
+
}
|
|
507
|
+
return openPopover(anchor, items, { width: 240 });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function createRow(view, host) {
|
|
511
|
+
await action(async () => {
|
|
512
|
+
const title = Object.values(view.source.properties).find((definition) => definition.type === "title");
|
|
513
|
+
const created = await call("pages.create", { parent: { data_source_id: view.source.id }, properties: { [title.name]: { title: [] } } }, key());
|
|
514
|
+
await loadIndex();
|
|
515
|
+
await query(view, false);
|
|
516
|
+
render(host, view);
|
|
517
|
+
view.onChanged?.();
|
|
518
|
+
openRow(view, host, created.id);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function openRow(view, host, pageId) {
|
|
523
|
+
const { renderPageView, pageTopbarActions } = await import("./page.js");
|
|
524
|
+
const body = el("div", { class: "peek-page" });
|
|
525
|
+
const controller = renderPageView(body, pageId, { peek: true, onChanged: async () => { await query(view, false); render(host, view); view.onChanged?.(); } });
|
|
526
|
+
openPeek(body, { onExpand: () => { closePeek(); navigate({ name: "page", id: pageId }); } });
|
|
527
|
+
const head = $("#peek .peek-head");
|
|
528
|
+
const actions = el("div", { class: "peek-actions" });
|
|
529
|
+
head.append(actions);
|
|
530
|
+
controller.onLoadedActions = () => actions.replaceChildren(...pageTopbarActions(controller, {}).slice(2));
|
|
531
|
+
const wait = setInterval(() => { if (controller.page) { clearInterval(wait); controller.onLoadedActions(); } if (!body.isConnected) clearInterval(wait); }, 150);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------------------------
|
|
535
|
+
// Board view (grouped by the first status, else select, property)
|
|
536
|
+
// ---------------------------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
function boardView(view, host) {
|
|
539
|
+
const definitions = Object.values(view.source.properties);
|
|
540
|
+
const groupBy = definitions.find((definition) => definition.type === "status") ?? definitions.find((definition) => definition.type === "select");
|
|
541
|
+
if (!groupBy) return el("div", { class: "board-empty", text: "Add a Status or Select property to group this board." });
|
|
542
|
+
const options = groupBy[groupBy.type]?.options ?? [];
|
|
543
|
+
const maps = lookup();
|
|
544
|
+
const board = el("div", { class: "board" });
|
|
545
|
+
const columns = [{ id: "__none", name: `No ${groupBy.name}`, color: "default" }, ...options];
|
|
546
|
+
const shown = orderedDefinitions(definitions).filter((definition) => definition.type !== "title" && definition.name !== groupBy.name && !COMPUTED.includes(definition.type)).slice(0, 4);
|
|
547
|
+
const writable = canWrite() && !view.database.in_trash;
|
|
548
|
+
for (const column of columns) {
|
|
549
|
+
const rows = view.rows.filter((row) => {
|
|
550
|
+
const value = row.properties[groupBy.name]?.[groupBy.type];
|
|
551
|
+
return column.id === "__none" ? !value : value?.id === column.id;
|
|
552
|
+
});
|
|
553
|
+
if (column.id === "__none" && rows.length === 0) continue;
|
|
554
|
+
const col = el("section", { class: "board-column", attrs: { "aria-label": column.name } });
|
|
555
|
+
col.append(el("header", { class: "board-head" }, [pill(column, { status: groupBy.type === "status" }), el("span", { class: "board-count", text: String(rows.length) })]));
|
|
556
|
+
for (const row of rows) {
|
|
557
|
+
const card = el("button", { class: "board-card", attrs: { type: "button" } });
|
|
558
|
+
card.append(el("div", { class: "card-title" }, [row.icon ? objectIcon(row.icon, "page", "row-icon") : null, el("span", { text: pageTitle(row) })]));
|
|
559
|
+
for (const definition of shown) {
|
|
560
|
+
const property = row.properties[definition.name];
|
|
561
|
+
if (!property) continue;
|
|
562
|
+
const raw = property[definition.type];
|
|
563
|
+
if (raw === null || raw === undefined || raw === "" || (Array.isArray(raw) && raw.length === 0) || raw === false) continue;
|
|
564
|
+
card.append(el("div", { class: "card-prop" }, propertyValue(property, maps)));
|
|
565
|
+
}
|
|
566
|
+
card.addEventListener("click", () => openRow(view, host, row.id));
|
|
567
|
+
col.append(card);
|
|
568
|
+
}
|
|
569
|
+
if (writable && canWrite("read_update_insert")) {
|
|
570
|
+
const add = el("button", { class: "board-new", attrs: { type: "button" } }, [icon("plus"), el("span", { text: "New" })]);
|
|
571
|
+
add.addEventListener("click", () => action(async () => {
|
|
572
|
+
const title = definitions.find((definition) => definition.type === "title");
|
|
573
|
+
const properties = { [title.name]: { title: [] } };
|
|
574
|
+
if (column.id !== "__none") properties[groupBy.name] = { [groupBy.type]: { id: column.id } };
|
|
575
|
+
const created = await call("pages.create", { parent: { data_source_id: view.source.id }, properties }, key());
|
|
576
|
+
await loadIndex();
|
|
577
|
+
await query(view, false);
|
|
578
|
+
render(host, view);
|
|
579
|
+
view.onChanged?.();
|
|
580
|
+
openRow(view, host, created.id);
|
|
581
|
+
}));
|
|
582
|
+
col.append(add);
|
|
583
|
+
}
|
|
584
|
+
board.append(col);
|
|
585
|
+
}
|
|
586
|
+
const wrap = el("div", { class: "board-wrap" }, board);
|
|
587
|
+
if (view.more) wrap.append(textButton(`Load ${PAGE_SIZE} more`, { class: "load-more", onClick: () => action(async () => { await query(view, true); render(host, view); }, { exclusive: false }) }));
|
|
588
|
+
return wrap;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------------------------
|
|
592
|
+
// Schema editor (data-sources.update)
|
|
593
|
+
// ---------------------------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
const PROPERTY_TYPES = [["rich_text", "Text"], ["number", "Number"], ["select", "Select"], ["multi_select", "Multi-select"], ["status", "Status"], ["date", "Date"], ["people", "Person"], ["checkbox", "Checkbox"], ["url", "URL"], ["email", "Email"], ["phone_number", "Phone"], ["created_time", "Created time"], ["created_by", "Created by"], ["last_edited_time", "Last edited time"], ["last_edited_by", "Last edited by"]];
|
|
596
|
+
|
|
597
|
+
export function openSchemaEditor(anchor, dataSourceId, schema, onDone, { add = false, focus } = {}) {
|
|
598
|
+
const box = el("div", { class: "schema-editor" });
|
|
599
|
+
const list = el("div", { class: "schema-list" });
|
|
600
|
+
const update = (properties, success) => action(async () => {
|
|
601
|
+
await call("data-sources.update", { data_source_id: dataSourceId, properties }, key());
|
|
602
|
+
closePopovers();
|
|
603
|
+
snackbar(success);
|
|
604
|
+
await onDone();
|
|
605
|
+
});
|
|
606
|
+
if (!add) {
|
|
607
|
+
box.append(el("div", { class: "picker-heading", text: "Properties" }));
|
|
608
|
+
for (const definition of Object.values(schema)) {
|
|
609
|
+
const row = el("div", { class: `schema-row ${focus === definition.name ? "focus" : ""}`.trim() });
|
|
610
|
+
const name = el("input", { class: "schema-name", attrs: { type: "text", "aria-label": `Rename ${definition.name}` } });
|
|
611
|
+
name.value = definition.name;
|
|
612
|
+
name.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); name.blur(); } });
|
|
613
|
+
name.addEventListener("blur", () => {
|
|
614
|
+
const next = name.value.trim();
|
|
615
|
+
if (next === definition.name || next.length === 0) { name.value = definition.name; return; }
|
|
616
|
+
void update({ [definition.name]: { name: next } }, `Renamed to ${next}`);
|
|
617
|
+
});
|
|
618
|
+
row.append(icon(propertyIcon(definition.type), "col-icon"), name);
|
|
619
|
+
if (definition.type !== "title") {
|
|
620
|
+
row.append(iconButton("trash", `Delete ${definition.name}`, { class: "small", onClick: () => action(async () => {
|
|
621
|
+
if (!(await confirmDialog(`Delete the property "${definition.name}"?`, "Its values are removed from every page of this database.", "Delete"))) return;
|
|
622
|
+
await update({ [definition.name]: null }, "Property deleted");
|
|
623
|
+
}) }));
|
|
624
|
+
}
|
|
625
|
+
list.append(row);
|
|
626
|
+
}
|
|
627
|
+
box.append(list);
|
|
628
|
+
}
|
|
629
|
+
box.append(el("div", { class: "picker-heading", text: "New property" }));
|
|
630
|
+
const form = el("form", { class: "schema-add" });
|
|
631
|
+
const name = el("input", { class: "editor-input", attrs: { type: "text", placeholder: "Property name", "aria-label": "New property name", required: "" } });
|
|
632
|
+
const type = el("select", { class: "filter-select", attrs: { "aria-label": "Property type" } }, PROPERTY_TYPES.map(([id, label]) => el("option", { text: label, attrs: { value: id } })));
|
|
633
|
+
const submit = el("button", { class: "btn primary", text: "Add", attrs: { type: "submit" } });
|
|
634
|
+
form.append(name, type, submit);
|
|
635
|
+
form.addEventListener("submit", (event) => {
|
|
636
|
+
event.preventDefault();
|
|
637
|
+
const label = name.value.trim();
|
|
638
|
+
if (label.length === 0) return;
|
|
639
|
+
const config = ["select", "multi_select", "status"].includes(type.value) ? { options: [] } : type.value === "number" ? { format: "number" } : {};
|
|
640
|
+
void update({ [label]: { [type.value]: config } }, `Added ${label}`);
|
|
641
|
+
});
|
|
642
|
+
box.append(form);
|
|
643
|
+
const popover = openPopover(anchor, box, { width: 320, align: "end" });
|
|
644
|
+
(add ? name : list.querySelector(".schema-row.focus input") ?? name).focus();
|
|
645
|
+
return popover;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export { OPTION_COLORS };
|