improve field header controls
This commit is contained in:
parent
7791282e91
commit
cf4c07f5b8
11 changed files with 434 additions and 215 deletions
|
|
@ -87,6 +87,13 @@ impl Field {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, app_db: &mut AppDbClient) -> sqlx::Result<()> {
|
||||||
|
query!("delete from fields where id = $1", self.id)
|
||||||
|
.execute(app_db.get_conn())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn belonging_to_portal(portal_id: Uuid) -> BelongingToPortalQuery {
|
pub fn belonging_to_portal(portal_id: Uuid) -> BelongingToPortalQuery {
|
||||||
BelongingToPortalQuery { portal_id }
|
BelongingToPortalQuery { portal_id }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod get_data_handler;
|
||||||
mod insert_handler;
|
mod insert_handler;
|
||||||
mod portal_handler;
|
mod portal_handler;
|
||||||
mod portal_settings_handler;
|
mod portal_settings_handler;
|
||||||
|
mod remove_field_handler;
|
||||||
mod set_filter_handler;
|
mod set_filter_handler;
|
||||||
mod settings_handler;
|
mod settings_handler;
|
||||||
mod settings_invite_handler;
|
mod settings_invite_handler;
|
||||||
|
|
@ -45,6 +46,10 @@ pub(super) fn new_router() -> Router<App> {
|
||||||
"/p/{portal_id}/update-field",
|
"/p/{portal_id}/update-field",
|
||||||
post(update_field_handler::post),
|
post(update_field_handler::post),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/p/{portal_id}/remove-field",
|
||||||
|
post(remove_field_handler::post),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/p/{portal_id}/update-field-ordinality",
|
"/p/{portal_id}/update-field-ordinality",
|
||||||
post(update_field_ordinality_handler::post),
|
post(update_field_ordinality_handler::post),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
use axum::{
|
||||||
|
debug_handler,
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use interim_models::{field::Field, portal::Portal};
|
||||||
|
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{postgres::types::Oid, query};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{App, AppDbConn},
|
||||||
|
errors::{AppError, bad_request},
|
||||||
|
extractors::ValidatedForm,
|
||||||
|
navigator::{Navigator, NavigatorPage},
|
||||||
|
user::CurrentUser,
|
||||||
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathParams {
|
||||||
|
portal_id: Uuid,
|
||||||
|
rel_oid: u32,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub(super) struct FormBody {
|
||||||
|
field_id: Uuid,
|
||||||
|
|
||||||
|
/// Expects "true" for truthy, else falsy.
|
||||||
|
delete_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP POST handler for removing an existing [`Field`].
|
||||||
|
///
|
||||||
|
/// This handler expects 3 path parameters with the structure described by
|
||||||
|
/// [`PathParams`].
|
||||||
|
#[debug_handler(state = App)]
|
||||||
|
pub(super) async fn post(
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
navigator: Navigator,
|
||||||
|
Path(PathParams {
|
||||||
|
portal_id,
|
||||||
|
rel_oid,
|
||||||
|
workspace_id,
|
||||||
|
}): Path<PathParams>,
|
||||||
|
ValidatedForm(FormBody {
|
||||||
|
delete_data,
|
||||||
|
field_id,
|
||||||
|
}): ValidatedForm<FormBody>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// FIXME CSRF
|
||||||
|
|
||||||
|
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||||
|
// permission to access/alter both as needed.
|
||||||
|
|
||||||
|
// Ensure field exists and belongs to portal.
|
||||||
|
let field = Field::belonging_to_portal(portal_id)
|
||||||
|
.with_id(field_id)
|
||||||
|
.fetch_one(&mut app_db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if delete_data == "true" && field.name.starts_with('_') {
|
||||||
|
return Err(bad_request!("cannot delete data for a system column"));
|
||||||
|
}
|
||||||
|
|
||||||
|
field.delete(&mut app_db).await?;
|
||||||
|
|
||||||
|
if delete_data == "true" {
|
||||||
|
let mut workspace_client = pooler
|
||||||
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
|
.await?;
|
||||||
|
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||||
|
let rel = PgClass::with_oid(portal.class_oid)
|
||||||
|
.fetch_one(&mut workspace_client)
|
||||||
|
.await?;
|
||||||
|
query(&format!(
|
||||||
|
"alter table {ident} drop column if exists {col_esc}",
|
||||||
|
ident = rel.get_identifier(),
|
||||||
|
col_esc = escape_identifier(&field.name),
|
||||||
|
))
|
||||||
|
.execute(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(navigator
|
||||||
|
.portal_page()
|
||||||
|
.workspace_id(workspace_id)
|
||||||
|
.rel_oid(Oid(rel_oid))
|
||||||
|
.portal_id(portal_id)
|
||||||
|
.build()?
|
||||||
|
.redirect_to())
|
||||||
|
}
|
||||||
|
|
@ -174,62 +174,6 @@ button, input[type="submit"] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: can this be removed?
|
|
||||||
.button-menu {
|
|
||||||
&__toggle-button {
|
|
||||||
@include globals.button-outline;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&-icon {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
svg path {
|
|
||||||
stroke: currentColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__popover {
|
|
||||||
&:popover-open {
|
|
||||||
@include globals.popover;
|
|
||||||
width: 16rem;
|
|
||||||
// FIXME: This makes button border radius work correctly, but also hides
|
|
||||||
// the outline that appears when each button is focused, particularly
|
|
||||||
// when there is only one button present.
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Palindrome humor! Anyone? No? Okay nvm.
|
|
||||||
&__unem-nottub {
|
|
||||||
@include globals.button-clear;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.combobox {
|
|
||||||
&__popover:popover-open {
|
|
||||||
@include globals.popover;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__completion {
|
|
||||||
@include globals.reset-button;
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover, &:focus {
|
|
||||||
background: #0000001f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
|
@ -248,7 +192,11 @@ button, input[type="submit"] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog:popover-open {
|
.phono-popover:popover-open {
|
||||||
|
@include globals.popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog:popover-open, .dialog:open {
|
||||||
@include globals.rounded;
|
@include globals.rounded;
|
||||||
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,12 @@ $table-border-color: #ccc;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__headers {
|
&__headers {
|
||||||
|
align-items: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: headers;
|
grid-area: headers;
|
||||||
align-items: stretch;
|
// Ensure that there will be enough space on the right for popovers to
|
||||||
|
// render without overflowing the container.
|
||||||
|
padding-right: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
|
|
@ -73,7 +76,6 @@ $table-border-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--cursor {
|
&--cursor {
|
||||||
background: transparent;
|
|
||||||
outline: 3px solid #37f;
|
outline: 3px solid #37f;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<svelte:options
|
<svelte:options
|
||||||
customElement={{
|
customElement={{
|
||||||
|
// `shadowRoot` field must remain as the default, else named slots break.
|
||||||
props: {
|
props: {
|
||||||
button_aria_label: { attribute: "button-aria-label" },
|
button_aria_label: { attribute: "button-aria-label" },
|
||||||
button_class: { attribute: "button-class" },
|
button_class: { attribute: "button-class" },
|
||||||
|
|
@ -9,12 +10,28 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
A button with an associated popover, which can be styled for a variety of
|
||||||
|
purposes, such as menus, confirmation popups, and so on.
|
||||||
|
|
||||||
|
When used as a web component, this component is rendered with a shadow root in
|
||||||
|
order to correctly support named slots. As a result, it bundles its own
|
||||||
|
stylesheet, which is merely a copy of the "main" application stylesheet.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
- `alignment`: When set to "right", the popover aligns with the right edge of
|
||||||
|
the trigger. Otherwise, it aligns with the left.
|
||||||
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Props = {
|
type Props = {
|
||||||
alignment?: string;
|
alignment?: string;
|
||||||
button_aria_label?: string;
|
button_aria_label?: string;
|
||||||
button_class?: string;
|
button_class?: string;
|
||||||
on_toggle?(ev: ToggleEvent): void;
|
on_toggle?(ev: ToggleEvent): unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -23,6 +40,7 @@
|
||||||
button_class = "button--secondary",
|
button_class = "button--secondary",
|
||||||
on_toggle,
|
on_toggle,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let popover_element: HTMLElement | undefined = $state();
|
let popover_element: HTMLElement | undefined = $state();
|
||||||
// Hacky workaround because as of September 2025 implicit anchor association
|
// Hacky workaround because as of September 2025 implicit anchor association
|
||||||
// is still pretty broken, at least in Firefox.
|
// is still pretty broken, at least in Firefox.
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
type Props = {
|
|
||||||
label: string;
|
|
||||||
on_click(value: string): void;
|
|
||||||
options: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let { label, on_click, options }: Props = $props();
|
|
||||||
|
|
||||||
let toggle_button_element = $state<HTMLButtonElement | undefined>();
|
|
||||||
let popover_element = $state<HTMLDivElement | undefined>();
|
|
||||||
|
|
||||||
function handle_toggle_button_click() {
|
|
||||||
popover_element?.togglePopover();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style:display="inline-block">
|
|
||||||
<button
|
|
||||||
bind:this={toggle_button_element}
|
|
||||||
class="button-menu__toggle-button"
|
|
||||||
onclick={handle_toggle_button_click}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div>{label}</div>
|
|
||||||
<div class="button-menu__toggle-button-icon" aria-hidden="true">
|
|
||||||
<i class="ti ti-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div bind:this={popover_element} class="button-menu__popover" popover="auto">
|
|
||||||
{#each options as option}
|
|
||||||
<button
|
|
||||||
class="button-menu__unem-nottub"
|
|
||||||
onclick={() => {
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
toggle_button_element?.focus();
|
|
||||||
on_click(option.value);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
TODO: Can this component be removed?
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type CssClass = string | (string | false | null | undefined)[];
|
type CssClass = string | (string | false | null | undefined)[];
|
||||||
|
|
||||||
|
|
@ -21,6 +26,9 @@
|
||||||
|
|
||||||
let focused = $state(false);
|
let focused = $state(false);
|
||||||
let popover_element = $state<HTMLDivElement | undefined>();
|
let popover_element = $state<HTMLDivElement | undefined>();
|
||||||
|
// Hacky workaround because as of September 2025 implicit anchor association
|
||||||
|
// is still pretty broken, at least in Firefox.
|
||||||
|
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
|
||||||
|
|
||||||
function handle_component_focusin() {
|
function handle_component_focusin() {
|
||||||
focused = true;
|
focused = true;
|
||||||
|
|
@ -38,35 +46,30 @@
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handle_completion_click(completion: string) {
|
|
||||||
value = completion;
|
|
||||||
search_input_element?.focus();
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_search_keydown(ev: KeyboardEvent) {
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_search_input() {
|
|
||||||
popover_element?.showPopover();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Wrapping both the search input and the popover in a container element allows us
|
||||||
|
to capture
|
||||||
|
-->
|
||||||
<div
|
<div
|
||||||
class="combobox__container"
|
class="combobox__container"
|
||||||
onfocusin={handle_component_focusin}
|
onfocusin={handle_component_focusin}
|
||||||
onfocusout={handle_component_focusout}
|
onfocusout={handle_component_focusout}
|
||||||
|
style:anchor-name={anchor_name}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
bind:this={search_input_element}
|
bind:this={search_input_element}
|
||||||
bind:value={search_value}
|
bind:value={search_value}
|
||||||
class={search_input_class}
|
class={search_input_class}
|
||||||
oninput={handle_search_input}
|
oninput={() => {
|
||||||
onkeydown={handle_search_keydown}
|
popover_element?.showPopover();
|
||||||
|
}}
|
||||||
|
onkeydown={(ev) => {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
popover_element?.hidePopover();
|
||||||
|
}
|
||||||
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -74,12 +77,17 @@
|
||||||
class={popover_class ?? "combobox__popover"}
|
class={popover_class ?? "combobox__popover"}
|
||||||
popover="manual"
|
popover="manual"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
|
style:position-anchor={anchor_name}
|
||||||
>
|
>
|
||||||
{#each completions as completion}
|
{#each completions as completion}
|
||||||
<button
|
<button
|
||||||
aria-selected={value === completion}
|
aria-selected={value === completion}
|
||||||
class="combobox__completion"
|
class="combobox__completion"
|
||||||
onclick={() => handle_completion_click(completion)}
|
onclick={() => {
|
||||||
|
value = completion;
|
||||||
|
search_input_element?.focus();
|
||||||
|
popover_element?.hidePopover();
|
||||||
|
}}
|
||||||
role="option"
|
role="option"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|
@ -88,3 +96,32 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.combobox {
|
||||||
|
&__popover {
|
||||||
|
&:popover-open {
|
||||||
|
// @include globals.popover;
|
||||||
|
|
||||||
|
left: anchor(left);
|
||||||
|
position: absolute;
|
||||||
|
top: anchor(bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__completion {
|
||||||
|
// @include globals.reset-button;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background: #0000001f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!--
|
<!--
|
||||||
@component
|
@component
|
||||||
|
|
||||||
An interactive UI that sits in the header row of a table and allows the user to
|
An interactive UI that sits in the header row of a table and allows the user to
|
||||||
quickly add a new field based on an existing column or configure a field backed
|
quickly add a new field based on an existing column or configure a field backed
|
||||||
by a new column to be added to the relation in the database.
|
by a new column to be added to the relation in the database.
|
||||||
|
|
@ -15,7 +16,7 @@ submission.
|
||||||
incompatible with the current presentation configuration.-->
|
incompatible with the current presentation configuration.-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Combobox from "./combobox.svelte";
|
import { toLowerCase } from "zod";
|
||||||
import FieldDetails from "./field-details.svelte";
|
import FieldDetails from "./field-details.svelte";
|
||||||
import {
|
import {
|
||||||
all_presentation_tags,
|
all_presentation_tags,
|
||||||
|
|
@ -98,11 +99,14 @@ incompatible with the current presentation configuration.-->
|
||||||
if (tag === "Dropdown") {
|
if (tag === "Dropdown") {
|
||||||
return { t: "Dropdown", c: { allow_custom: true, options: [] } };
|
return { t: "Dropdown", c: { allow_custom: true, options: [] } };
|
||||||
}
|
}
|
||||||
|
if (tag === "Numeric") {
|
||||||
|
return { t: "Numeric", c: {} };
|
||||||
|
}
|
||||||
if (tag === "Text") {
|
if (tag === "Text") {
|
||||||
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
||||||
}
|
}
|
||||||
if (tag === "Timestamp") {
|
if (tag === "Timestamp") {
|
||||||
return { t: "Timestamp", c: {} };
|
return { t: "Timestamp", c: { format: "yyyy-MM-dd'T'HH:mm:ssXXX" } };
|
||||||
}
|
}
|
||||||
if (tag === "Uuid") {
|
if (tag === "Uuid") {
|
||||||
return { t: "Uuid", c: {} };
|
return { t: "Uuid", c: {} };
|
||||||
|
|
@ -111,91 +115,183 @@ incompatible with the current presentation configuration.-->
|
||||||
throw new Error("this should be unreachable");
|
throw new Error("this should be unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handle_presentation_input() {
|
function toggle_expanded() {
|
||||||
presentation_customized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_summary_toggle_button_click() {
|
|
||||||
expanded = !expanded;
|
expanded = !expanded;
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
|
// Delay focus call until next frame so that the element has an
|
||||||
|
// opportunity to render.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
search_input_element?.focus();
|
search_input_element?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handle_field_options_button_click() {
|
|
||||||
popover_element?.showPopover();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_name_input() {
|
function handle_name_input() {
|
||||||
name_customized = true;
|
name_customized = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form method="post" action="add-field">
|
<div class="container">
|
||||||
<div class="field-adder__container">
|
|
||||||
<div
|
<div
|
||||||
class="field-adder__header-lookalike"
|
class={["header-lookalike", expanded && "visible"]}
|
||||||
style:display={expanded ? "block" : "none"}
|
|
||||||
>
|
|
||||||
<Combobox
|
|
||||||
bind:search_value={label_value}
|
|
||||||
bind:search_input_element
|
|
||||||
bind:value={label_value}
|
|
||||||
completions={columns
|
|
||||||
.map(({ name }) => name)
|
|
||||||
.filter((name) =>
|
|
||||||
name
|
|
||||||
.toLocaleLowerCase("en-US")
|
|
||||||
.includes(label_value.toLocaleLowerCase("en-US")),
|
|
||||||
)}
|
|
||||||
search_input_class="field-adder__label-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-adder__summary-buttons">
|
|
||||||
<button
|
|
||||||
aria-label="more field options"
|
|
||||||
class="button--clear"
|
|
||||||
onclick={handle_field_options_button_click}
|
|
||||||
style:anchor-name={anchor_name}
|
style:anchor-name={anchor_name}
|
||||||
style:display={expanded ? "block" : "none"}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<i class="ti ti-dots-vertical"></i>
|
<input
|
||||||
</button>
|
bind:this={search_input_element}
|
||||||
<button
|
bind:value={label_value}
|
||||||
aria-label="toggle field adder"
|
class="field-adder__label-input"
|
||||||
class="button--clear"
|
onclick={() => {
|
||||||
onclick={handle_summary_toggle_button_click}
|
popover_element?.showPopover();
|
||||||
type="button"
|
}}
|
||||||
>
|
oninput={() => {
|
||||||
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
|
popover_element?.showPopover();
|
||||||
</button>
|
}}
|
||||||
</div>
|
onkeydown={(ev) => {
|
||||||
</div>
|
if (ev.key === "Escape") {
|
||||||
|
toggle_expanded();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<form method="post" action="add-field">
|
||||||
<div
|
<div
|
||||||
bind:this={popover_element}
|
bind:this={popover_element}
|
||||||
class="field-adder__popover"
|
class="phono-popover popover"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
style:position-anchor={anchor_name}
|
style:position-anchor={anchor_name}
|
||||||
>
|
>
|
||||||
<!--
|
<div class="completions" role="listbox">
|
||||||
The "advanced" details for creating a new column or customizing the behavior
|
{#each columns
|
||||||
of a field backed by an existing column overlap with the controls exposed when
|
.map(({ name }) => name)
|
||||||
editing the configuration of an existing field.
|
.filter((name) => name
|
||||||
-->
|
.toLocaleLowerCase("en-US")
|
||||||
|
.includes(label_value.toLocaleLowerCase("en-US")) || name
|
||||||
|
.toLocaleLowerCase("en-US")
|
||||||
|
.includes(name_value.toLocaleLowerCase("en-US"))) as completion}
|
||||||
|
<button
|
||||||
|
aria-selected={name_value === completion}
|
||||||
|
onclick={() => {
|
||||||
|
if (!name_customized) {
|
||||||
|
label_value = completion;
|
||||||
|
}
|
||||||
|
name_value = completion;
|
||||||
|
search_input_element?.focus();
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{completion}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="configs">
|
||||||
{#if presentation_value}
|
{#if presentation_value}
|
||||||
<FieldDetails
|
<FieldDetails
|
||||||
bind:name_value
|
bind:name_value
|
||||||
bind:label_value
|
bind:label_value
|
||||||
bind:presentation={presentation_value}
|
bind:presentation={presentation_value}
|
||||||
on_name_input={handle_name_input}
|
on_name_input={handle_name_input}
|
||||||
on_presentation_input={handle_presentation_input}
|
on_presentation_input={() => {
|
||||||
|
presentation_customized = true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="button--primary" type="submit">Create</button>
|
<button class="button--primary" type="submit">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-adder__summary-buttons">
|
||||||
|
<button
|
||||||
|
aria-label="toggle field adder"
|
||||||
|
class="button--clear"
|
||||||
|
onclick={toggle_expanded}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
/*
|
||||||
|
I've been annoyed by some of the rough edges around global SCSS in web
|
||||||
|
components, and independently curious about simplifying the build process by
|
||||||
|
replacing SCSS with modern vanilla CSS entirely, so trying something new here.
|
||||||
|
TBD whether it gets adopted more widely, or reverted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import "../../css_dist/main.css";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
--default-border-color: #ccc;
|
||||||
|
--viewer-th-background: #0001;
|
||||||
|
--viewer-th-border: solid 1px #ccc;
|
||||||
|
--viewer-th-font-family: "Funnel Sans";
|
||||||
|
--viewer-th-font-weight: bolder;
|
||||||
|
--viewer-th-padding-x: 8px;
|
||||||
|
--viewer-th-padding-y: 4px;
|
||||||
|
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-lookalike {
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--viewer-th-background);
|
||||||
|
border: var(--viewer-th-border);
|
||||||
|
border-top: none;
|
||||||
|
&:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
display: none;
|
||||||
|
&.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--viewer-th-font-family);
|
||||||
|
font-weight: var(--viewer-th-font-weight);
|
||||||
|
height: 100%; /* css hack to make percentage based cell heights work */
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--viewer-th-padding-y) var(--viewer-th-padding-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover:popover-open {
|
||||||
|
display: grid;
|
||||||
|
grid-template: "completions configs" 1fr / 1fr 2fr;
|
||||||
|
left: anchor(left);
|
||||||
|
position: absolute;
|
||||||
|
top: anchor(bottom);
|
||||||
|
width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completions {
|
||||||
|
border-right: solid 1px var(--default-border-color);
|
||||||
|
grid-area: completions;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&[aria-selected="true"] {
|
||||||
|
background: #0000001f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configs {
|
||||||
|
grid-area: configs;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
const original_label_value = field.field.table_label;
|
const original_label_value = field.field.table_label;
|
||||||
|
|
||||||
let type_indicator_element = $state<HTMLButtonElement | undefined>();
|
let type_indicator_element = $state<HTMLButtonElement | undefined>();
|
||||||
|
let remove_field_dialog_element = $state<HTMLDialogElement | undefined>();
|
||||||
|
let field_config_dialog_element = $state<HTMLDialogElement | undefined>();
|
||||||
let name_value = $state(field.field.name);
|
let name_value = $state(field.field.name);
|
||||||
let label_value = $state(field.field.table_label ?? "");
|
let label_value = $state(field.field.table_label ?? "");
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
<div class="field-header__menu-container">
|
<div class="field-header__menu-container">
|
||||||
<BasicDropdown
|
<BasicDropdown
|
||||||
alignment="right"
|
alignment="right"
|
||||||
|
button_aria_label="Field options"
|
||||||
button_class="field-header__type-indicator"
|
button_class="field-header__type-indicator"
|
||||||
on_toggle={(ev) => {
|
on_toggle={(ev) => {
|
||||||
if (ev.newState === "closed") {
|
if (ev.newState === "closed") {
|
||||||
|
|
@ -64,7 +67,7 @@
|
||||||
>
|
>
|
||||||
<span slot="button-contents">
|
<span slot="button-contents">
|
||||||
{#if field.field.presentation.t === "Dropdown"}
|
{#if field.field.presentation.t === "Dropdown"}
|
||||||
<i class="ti ti-pointer"></i>
|
<i class="ti ti-select"></i>
|
||||||
{:else if field.field.presentation.t === "Numeric"}
|
{:else if field.field.presentation.t === "Numeric"}
|
||||||
<i class="ti ti-decimal"></i>
|
<i class="ti ti-decimal"></i>
|
||||||
{:else if field.field.presentation.t === "Text"}
|
{:else if field.field.presentation.t === "Text"}
|
||||||
|
|
@ -75,18 +78,71 @@
|
||||||
<i class="ti ti-id"></i>
|
<i class="ti ti-id"></i>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<div slot="popover" style:padding="16px">
|
<menu slot="popover" class="basic-dropdown__menu">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
field_config_dialog_element?.showModal();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{field.field.presentation.t} settings
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
remove_field_dialog_element?.showModal();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Remove field
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
</BasicDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog bind:this={remove_field_dialog_element} class="dialog">
|
||||||
|
<form method="post" action="remove-field">
|
||||||
|
<div class="padded">
|
||||||
|
<label style:display="block">
|
||||||
|
<input type="radio" name="delete_data" value="false" checked />
|
||||||
|
Hide field without deleting data.
|
||||||
|
</label>
|
||||||
|
<label style:display="block">
|
||||||
|
<input type="radio" name="delete_data" value="true" />
|
||||||
|
Remove field and underlying data. This cannot be undone.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="padded">
|
||||||
|
<input type="hidden" name="field_id" value={field.field.id} />
|
||||||
|
<button class="button--primary" type="submit">Remove</button>
|
||||||
|
<button
|
||||||
|
class="button--secondary"
|
||||||
|
onclick={() => {
|
||||||
|
remove_field_dialog_element?.close();
|
||||||
|
}}
|
||||||
|
type="button">Cancel</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog bind:this={field_config_dialog_element} class="dialog">
|
||||||
<form method="post" action="update-field">
|
<form method="post" action="update-field">
|
||||||
|
<div class="padded">
|
||||||
<FieldDetails
|
<FieldDetails
|
||||||
bind:name_value
|
bind:name_value
|
||||||
bind:label_value
|
bind:label_value
|
||||||
name_input_disabled
|
name_input_disabled
|
||||||
presentation={field.field.presentation}
|
presentation={field.field.presentation}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<input type="hidden" name="field_id" value={field.field.id} />
|
<input type="hidden" name="field_id" value={field.field.id} />
|
||||||
|
<div class="padded">
|
||||||
<button class="button--primary" type="submit">Save</button>
|
<button class="button--primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</BasicDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -601,7 +601,7 @@
|
||||||
<div class="lens-grid">
|
<div class="lens-grid">
|
||||||
{#if lazy_data}
|
{#if lazy_data}
|
||||||
<div class="lens-table" role="grid">
|
<div class="lens-table" role="grid">
|
||||||
<div class={["lens-table__headers"]}>
|
<div class="lens-table__headers">
|
||||||
{#each lazy_data.fields as _, field_index}
|
{#each lazy_data.fields as _, field_index}
|
||||||
<FieldHeader
|
<FieldHeader
|
||||||
bind:field={lazy_data.fields[field_index]}
|
bind:field={lazy_data.fields[field_index]}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue