forked from tsai/budibase
268 changed files with 8866 additions and 5452 deletions
@ -0,0 +1,40 @@ |
|||||
|
import PosthogProcessor from "../PosthogProcessor" |
||||
|
import { Event, IdentityType, Hosting } from "@budibase/types" |
||||
|
|
||||
|
const newIdentity = () => { |
||||
|
return { |
||||
|
id: "test", |
||||
|
type: IdentityType.USER, |
||||
|
hosting: Hosting.SELF, |
||||
|
environment: "test", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe("PosthogProcessor", () => { |
||||
|
beforeEach(() => { |
||||
|
jest.clearAllMocks() |
||||
|
}) |
||||
|
|
||||
|
describe("processEvent", () => { |
||||
|
it("processes event", () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
|
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
processor.processEvent(Event.APP_CREATED, identity, properties) |
||||
|
|
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
|
||||
|
it("honours exclusions", () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
|
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
processor.processEvent(Event.AUTH_SSO_UPDATED, identity, properties) |
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(0) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,64 @@ |
|||||
|
import { publishEvent } from "../events" |
||||
|
import { |
||||
|
Event, |
||||
|
UserGroup, |
||||
|
GroupCreatedEvent, |
||||
|
GroupDeletedEvent, |
||||
|
GroupUpdatedEvent, |
||||
|
GroupUsersAddedEvent, |
||||
|
GroupUsersDeletedEvent, |
||||
|
GroupAddedOnboardingEvent, |
||||
|
UserGroupRoles, |
||||
|
} from "@budibase/types" |
||||
|
|
||||
|
export async function created(group: UserGroup, timestamp?: number) { |
||||
|
const properties: GroupCreatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp) |
||||
|
} |
||||
|
|
||||
|
export async function updated(group: UserGroup) { |
||||
|
const properties: GroupUpdatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_UPDATED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function deleted(group: UserGroup) { |
||||
|
const properties: GroupDeletedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_DELETED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersAdded(count: number, group: UserGroup) { |
||||
|
const properties: GroupUsersAddedEvent = { |
||||
|
count, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersDeleted(emails: string[], group: UserGroup) { |
||||
|
const properties: GroupUsersDeletedEvent = { |
||||
|
count: emails.length, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function createdOnboarding(groupId: string) { |
||||
|
const properties: GroupAddedOnboardingEvent = { |
||||
|
groupId: groupId, |
||||
|
onboarding: true, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_ONBOARDING, properties) |
||||
|
} |
||||
|
|
||||
|
export async function permissionsEdited(roles: UserGroupRoles) { |
||||
|
const properties: UserGroupRoles = { |
||||
|
...roles, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties) |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
const env = require("./environment") |
||||
|
|
||||
|
exports.pinoSettings = () => ({ |
||||
|
prettyPrint: { |
||||
|
levelFirst: true, |
||||
|
}, |
||||
|
level: env.LOG_LEVEL || "error", |
||||
|
autoLogging: { |
||||
|
ignore: req => req.url.includes("/health"), |
||||
|
}, |
||||
|
}) |
||||
@ -1,95 +0,0 @@ |
|||||
const redis = require("../redis/init") |
|
||||
const { v4: uuidv4 } = require("uuid") |
|
||||
|
|
||||
// a week in seconds
|
|
||||
const EXPIRY_SECONDS = 86400 * 7 |
|
||||
|
|
||||
async function getSessionsForUser(userId) { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessions = await client.scan(userId) |
|
||||
return sessions.map(session => session.value) |
|
||||
} |
|
||||
|
|
||||
function makeSessionID(userId, sessionId) { |
|
||||
return `${userId}/${sessionId}` |
|
||||
} |
|
||||
|
|
||||
async function invalidateSessions(userId, sessionIds = null) { |
|
||||
try { |
|
||||
let sessions = [] |
|
||||
|
|
||||
// If no sessionIds, get all the sessions for the user
|
|
||||
if (!sessionIds) { |
|
||||
sessions = await getSessionsForUser(userId) |
|
||||
sessions.forEach( |
|
||||
session => |
|
||||
(session.key = makeSessionID(session.userId, session.sessionId)) |
|
||||
) |
|
||||
} else { |
|
||||
// use the passed array of sessionIds
|
|
||||
sessions = Array.isArray(sessionIds) ? sessionIds : [sessionIds] |
|
||||
sessions = sessions.map(sessionId => ({ |
|
||||
key: makeSessionID(userId, sessionId), |
|
||||
})) |
|
||||
} |
|
||||
|
|
||||
const client = await redis.getSessionClient() |
|
||||
const promises = [] |
|
||||
for (let session of sessions) { |
|
||||
promises.push(client.delete(session.key)) |
|
||||
} |
|
||||
await Promise.all(promises) |
|
||||
} catch (err) { |
|
||||
console.error(`Error invalidating sessions: ${err}`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.createASession = async (userId, session) => { |
|
||||
// invalidate all other sessions
|
|
||||
await invalidateSessions(userId) |
|
||||
|
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessionId = session.sessionId |
|
||||
if (!session.csrfToken) { |
|
||||
session.csrfToken = uuidv4() |
|
||||
} |
|
||||
session = { |
|
||||
createdAt: new Date().toISOString(), |
|
||||
lastAccessedAt: new Date().toISOString(), |
|
||||
...session, |
|
||||
userId, |
|
||||
} |
|
||||
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
|
||||
} |
|
||||
|
|
||||
exports.updateSessionTTL = async session => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const key = makeSessionID(session.userId, session.sessionId) |
|
||||
session.lastAccessedAt = new Date().toISOString() |
|
||||
await client.store(key, session, EXPIRY_SECONDS) |
|
||||
} |
|
||||
|
|
||||
exports.endSession = async (userId, sessionId) => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
await client.delete(makeSessionID(userId, sessionId)) |
|
||||
} |
|
||||
|
|
||||
exports.getSession = async (userId, sessionId) => { |
|
||||
try { |
|
||||
const client = await redis.getSessionClient() |
|
||||
return client.get(makeSessionID(userId, sessionId)) |
|
||||
} catch (err) { |
|
||||
// if can't get session don't error, just don't return anything
|
|
||||
console.error(err) |
|
||||
return null |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.getAllSessions = async () => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessions = await client.scan() |
|
||||
return sessions.map(session => session.value) |
|
||||
} |
|
||||
|
|
||||
exports.getUserSessions = getSessionsForUser |
|
||||
exports.invalidateSessions = invalidateSessions |
|
||||
@ -0,0 +1,119 @@ |
|||||
|
const redis = require("../redis/init") |
||||
|
const { v4: uuidv4 } = require("uuid") |
||||
|
const { logWarn } = require("../logging") |
||||
|
const env = require("../environment") |
||||
|
|
||||
|
interface Session { |
||||
|
key: string |
||||
|
userId: string |
||||
|
sessionId: string |
||||
|
lastAccessedAt: string |
||||
|
createdAt: string |
||||
|
csrfToken?: string |
||||
|
value: string |
||||
|
} |
||||
|
|
||||
|
type SessionKey = { key: string }[] |
||||
|
|
||||
|
// a week in seconds
|
||||
|
const EXPIRY_SECONDS = 86400 * 7 |
||||
|
|
||||
|
function makeSessionID(userId: string, sessionId: string) { |
||||
|
return `${userId}/${sessionId}` |
||||
|
} |
||||
|
|
||||
|
export async function getSessionsForUser(userId: string) { |
||||
|
if (!userId) { |
||||
|
console.trace("Cannot get sessions for undefined userId") |
||||
|
return [] |
||||
|
} |
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessions = await client.scan(userId) |
||||
|
return sessions.map((session: Session) => session.value) |
||||
|
} |
||||
|
|
||||
|
export async function invalidateSessions( |
||||
|
userId: string, |
||||
|
opts: { sessionIds?: string[]; reason?: string } = {} |
||||
|
) { |
||||
|
try { |
||||
|
const reason = opts?.reason || "unknown" |
||||
|
let sessionIds: string[] = opts.sessionIds || [] |
||||
|
let sessions: SessionKey |
||||
|
|
||||
|
// If no sessionIds, get all the sessions for the user
|
||||
|
if (sessionIds.length === 0) { |
||||
|
sessions = await getSessionsForUser(userId) |
||||
|
sessions.forEach( |
||||
|
(session: any) => |
||||
|
(session.key = makeSessionID(session.userId, session.sessionId)) |
||||
|
) |
||||
|
} else { |
||||
|
// use the passed array of sessionIds
|
||||
|
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] |
||||
|
sessions = sessionIds.map((sessionId: string) => ({ |
||||
|
key: makeSessionID(userId, sessionId), |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
if (sessions && sessions.length > 0) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const promises = [] |
||||
|
for (let session of sessions) { |
||||
|
promises.push(client.delete(session.key)) |
||||
|
} |
||||
|
if (!env.isTest()) { |
||||
|
logWarn( |
||||
|
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions |
||||
|
.map(session => session.key) |
||||
|
.join(", ")}` |
||||
|
) |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(`Error invalidating sessions: ${err}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function createASession(userId: string, session: Session) { |
||||
|
// invalidate all other sessions
|
||||
|
await invalidateSessions(userId, { reason: "creation" }) |
||||
|
|
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessionId = session.sessionId |
||||
|
if (!session.csrfToken) { |
||||
|
session.csrfToken = uuidv4() |
||||
|
} |
||||
|
session = { |
||||
|
...session, |
||||
|
createdAt: new Date().toISOString(), |
||||
|
lastAccessedAt: new Date().toISOString(), |
||||
|
userId, |
||||
|
} |
||||
|
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
export async function updateSessionTTL(session: Session) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const key = makeSessionID(session.userId, session.sessionId) |
||||
|
session.lastAccessedAt = new Date().toISOString() |
||||
|
await client.store(key, session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
export async function endSession(userId: string, sessionId: string) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
await client.delete(makeSessionID(userId, sessionId)) |
||||
|
} |
||||
|
|
||||
|
export async function getSession(userId: string, sessionId: string) { |
||||
|
if (!userId || !sessionId) { |
||||
|
throw new Error(`Invalid session details - ${userId} - ${sessionId}`) |
||||
|
} |
||||
|
const client = await redis.getSessionClient() |
||||
|
const session = await client.get(makeSessionID(userId, sessionId)) |
||||
|
if (!session) { |
||||
|
throw new Error(`Session not found - ${userId} - ${sessionId}`) |
||||
|
} |
||||
|
return session |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
import * as sessions from "../sessions" |
||||
|
|
||||
|
describe("sessions", () => { |
||||
|
describe("getSessionsForUser", () => { |
||||
|
it("returns empty when user is undefined", async () => { |
||||
|
// @ts-ignore - allow the undefined to be passed
|
||||
|
const results = await sessions.getSessionsForUser(undefined) |
||||
|
|
||||
|
expect(results).toStrictEqual([]) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
@ -1,7 +1,9 @@ |
|||||
|
const posthog = require("./posthog") |
||||
const events = require("./events") |
const events = require("./events") |
||||
const date = require("./date") |
const date = require("./date") |
||||
|
|
||||
module.exports = { |
module.exports = { |
||||
|
posthog, |
||||
date, |
date, |
||||
events, |
events, |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,7 @@ |
|||||
|
jest.mock("posthog-node", () => { |
||||
|
return jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
capture: jest.fn(), |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,228 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/inputgroup/dist/index-vars.css" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import "@spectrum-css/menu/dist/index-vars.css" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import clickOutside from "../../Actions/click_outside" |
||||
|
|
||||
|
export let inputValue |
||||
|
export let dropdownValue |
||||
|
export let id = null |
||||
|
export let inputType = "text" |
||||
|
export let placeholder = "Choose an option or type" |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let updateOnChange = true |
||||
|
export let error = null |
||||
|
export let options = [] |
||||
|
export let getOptionLabel = option => extractProperty(option, "label") |
||||
|
export let getOptionValue = option => extractProperty(option, "value") |
||||
|
|
||||
|
export let isOptionSelected = () => false |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let open = false |
||||
|
let focus = false |
||||
|
|
||||
|
$: fieldText = getFieldText(dropdownValue, options, placeholder) |
||||
|
|
||||
|
const getFieldText = (dropdownValue, options, placeholder) => { |
||||
|
// Always use placeholder if no value |
||||
|
if (dropdownValue == null || dropdownValue === "") { |
||||
|
return placeholder || "Choose an option or type" |
||||
|
} |
||||
|
|
||||
|
// Wait for options to load if there is a value but no options |
||||
|
if (!options?.length) { |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// Render the label if the selected option is found, otherwise raw value |
||||
|
const selected = options.find( |
||||
|
option => getOptionValue(option) === dropdownValue |
||||
|
) |
||||
|
return selected ? getOptionLabel(selected) : dropdownValue |
||||
|
} |
||||
|
|
||||
|
const updateValue = newValue => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
dispatch("change", newValue) |
||||
|
} |
||||
|
|
||||
|
const onFocus = () => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
focus = true |
||||
|
} |
||||
|
|
||||
|
const onBlur = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
focus = false |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const onInput = event => { |
||||
|
if (readonly || !updateOnChange) { |
||||
|
return |
||||
|
} |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const updateValueOnEnter = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
if (event.key === "Enter") { |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const onClick = () => { |
||||
|
dispatch("click") |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
open = true |
||||
|
} |
||||
|
|
||||
|
const onPick = newValue => { |
||||
|
dispatch("pick", newValue) |
||||
|
open = false |
||||
|
} |
||||
|
|
||||
|
const extractProperty = (value, property) => { |
||||
|
if (value && typeof value === "object") { |
||||
|
return value[property] |
||||
|
} |
||||
|
return value |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div |
||||
|
class="spectrum-InputGroup" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
> |
||||
|
<div |
||||
|
class="spectrum-Textfield spectrum-InputGroup-textfield" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
class:is-focused={focus} |
||||
|
> |
||||
|
{#if error} |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-icon-18-Alert" /> |
||||
|
</svg> |
||||
|
{/if} |
||||
|
|
||||
|
<input |
||||
|
{id} |
||||
|
on:click |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:input |
||||
|
on:keyup |
||||
|
on:blur={onBlur} |
||||
|
on:focus={onFocus} |
||||
|
on:input={onInput} |
||||
|
on:keyup={updateValueOnEnter} |
||||
|
value={inputValue || ""} |
||||
|
placeholder={placeholder || ""} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{inputType} |
||||
|
class="spectrum-Textfield-input spectrum-InputGroup-input" |
||||
|
/> |
||||
|
</div> |
||||
|
<div style="width: 30%"> |
||||
|
<button |
||||
|
{id} |
||||
|
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
||||
|
{disabled} |
||||
|
class:is-open={open} |
||||
|
aria-haspopup="listbox" |
||||
|
on:mousedown={onClick} |
||||
|
> |
||||
|
<span class="spectrum-Picker-label"> |
||||
|
<div> |
||||
|
{fieldText} |
||||
|
</div></span |
||||
|
> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{#if open} |
||||
|
<div |
||||
|
use:clickOutside={() => (open = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
> |
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#each options as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected(getOptionValue(option, idx))} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => onPick(getOptionValue(option, idx))} |
||||
|
> |
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
{getOptionLabel(option, idx)} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.spectrum-InputGroup { |
||||
|
min-width: 0; |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.spectrum-InputGroup-input { |
||||
|
border-right-width: 1px; |
||||
|
} |
||||
|
.spectrum-Textfield { |
||||
|
width: 100%; |
||||
|
} |
||||
|
.spectrum-Textfield-input { |
||||
|
width: 0; |
||||
|
} |
||||
|
|
||||
|
.override-borders { |
||||
|
border-top-left-radius: 0px; |
||||
|
border-bottom-left-radius: 0px; |
||||
|
} |
||||
|
.spectrum-Popover { |
||||
|
max-height: 240px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,436 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/inputgroup/dist/index-vars.css" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import "@spectrum-css/menu/dist/index-vars.css" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import clickOutside from "../../Actions/click_outside" |
||||
|
import Icon from "../../Icon/Icon.svelte" |
||||
|
import StatusLight from "../../StatusLight/StatusLight.svelte" |
||||
|
import Detail from "../../Typography/Detail.svelte" |
||||
|
import Search from "./Search.svelte" |
||||
|
|
||||
|
export let primaryLabel = "" |
||||
|
export let primaryValue = null |
||||
|
export let id = null |
||||
|
export let placeholder = "Choose an option or type" |
||||
|
export let disabled = false |
||||
|
export let updateOnChange = true |
||||
|
export let error = null |
||||
|
export let secondaryOptions = [] |
||||
|
export let primaryOptions = [] |
||||
|
export let secondaryFieldText = "" |
||||
|
export let secondaryFieldIcon = "" |
||||
|
export let secondaryFieldColour = "" |
||||
|
export let getPrimaryOptionValue = option => option |
||||
|
export let getPrimaryOptionColour = () => null |
||||
|
export let getPrimaryOptionIcon = () => null |
||||
|
export let getSecondaryOptionLabel = option => option |
||||
|
export let getSecondaryOptionValue = option => option |
||||
|
export let getSecondaryOptionColour = () => null |
||||
|
export let onSelectOption = () => {} |
||||
|
export let autoWidth = false |
||||
|
export let autocomplete = false |
||||
|
export let isOptionSelected = () => false |
||||
|
export let isPlaceholder = false |
||||
|
export let placeholderOption = null |
||||
|
export let showClearIcon = true |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let primaryOpen = false |
||||
|
let secondaryOpen = false |
||||
|
let focus = false |
||||
|
let searchTerm = null |
||||
|
|
||||
|
$: groupTitles = Object.keys(primaryOptions) |
||||
|
let iconData |
||||
|
|
||||
|
const updateSearch = e => { |
||||
|
dispatch("search", e.detail) |
||||
|
} |
||||
|
|
||||
|
const updateValue = newValue => { |
||||
|
dispatch("change", newValue) |
||||
|
} |
||||
|
|
||||
|
const onClickSecondary = () => { |
||||
|
dispatch("click") |
||||
|
secondaryOpen = true |
||||
|
} |
||||
|
|
||||
|
const onPickPrimary = newValue => { |
||||
|
dispatch("pickprimary", newValue) |
||||
|
primaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onClearPrimary = () => { |
||||
|
dispatch("pickprimary", null) |
||||
|
primaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onPickSecondary = newValue => { |
||||
|
dispatch("picksecondary", newValue) |
||||
|
secondaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onBlur = event => { |
||||
|
focus = false |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const onInput = event => { |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const updateValueOnEnter = event => { |
||||
|
if (event.key === "Enter") { |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div |
||||
|
class="spectrum-InputGroup" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
> |
||||
|
<div |
||||
|
class="spectrum-Textfield spectrum-InputGroup-textfield" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
class:is-focused={focus} |
||||
|
class:is-full-width={!secondaryOptions.length} |
||||
|
> |
||||
|
{#if iconData} |
||||
|
<svg |
||||
|
width="16px" |
||||
|
height="16px" |
||||
|
class="spectrum-Icon iconPadding" |
||||
|
style="color: {iconData?.color}" |
||||
|
focusable="false" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-icon-18-{iconData?.icon}" /> |
||||
|
</svg> |
||||
|
{/if} |
||||
|
<input |
||||
|
{id} |
||||
|
on:click={() => (primaryOpen = true)} |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:input |
||||
|
on:keyup |
||||
|
on:blur={onBlur} |
||||
|
on:input={onInput} |
||||
|
on:keyup={updateValueOnEnter} |
||||
|
value={primaryLabel || ""} |
||||
|
placeholder={placeholder || ""} |
||||
|
{disabled} |
||||
|
readonly |
||||
|
class="spectrum-Textfield-input spectrum-InputGroup-input" |
||||
|
class:labelPadding={iconData} |
||||
|
class:open={primaryOpen} |
||||
|
/> |
||||
|
{#if primaryValue && showClearIcon} |
||||
|
<button |
||||
|
on:click={() => onClearPrimary()} |
||||
|
type="reset" |
||||
|
class="spectrum-ClearButton spectrum-Search-clearButton" |
||||
|
> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Cross75" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Cross75" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{#if primaryOpen} |
||||
|
<div |
||||
|
use:clickOutside={() => (primaryOpen = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
class:auto-width={autoWidth} |
||||
|
class:is-full-width={!secondaryOptions.length} |
||||
|
> |
||||
|
{#if autocomplete} |
||||
|
<Search |
||||
|
value={searchTerm} |
||||
|
on:change={event => updateSearch(event)} |
||||
|
{disabled} |
||||
|
placeholder="Search" |
||||
|
/> |
||||
|
{/if} |
||||
|
|
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#if placeholderOption} |
||||
|
<li |
||||
|
class="spectrum-Menu-item placeholder" |
||||
|
class:is-selected={isPlaceholder} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => onSelectOption(null)} |
||||
|
> |
||||
|
<span class="spectrum-Menu-itemLabel">{placeholderOption}</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/if} |
||||
|
{#each groupTitles as title} |
||||
|
<div class="spectrum-Menu-item title"> |
||||
|
<Detail>{title}</Detail> |
||||
|
</div> |
||||
|
{#if primaryOptions} |
||||
|
{#each primaryOptions[title].data as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected( |
||||
|
getPrimaryOptionValue(option, idx) |
||||
|
)} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => |
||||
|
onPickPrimary({ |
||||
|
value: primaryOptions[title].getValue(option), |
||||
|
label: primaryOptions[title].getLabel(option), |
||||
|
})} |
||||
|
> |
||||
|
{#if primaryOptions[title].getIcon(option)} |
||||
|
<div |
||||
|
style="background: {primaryOptions[title].getColour( |
||||
|
option |
||||
|
)};" |
||||
|
class="circle" |
||||
|
> |
||||
|
<div> |
||||
|
<Icon |
||||
|
size="S" |
||||
|
name={primaryOptions[title].getIcon(option)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
{:else if getPrimaryOptionColour(option, idx)} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight |
||||
|
square |
||||
|
color={getPrimaryOptionColour(option, idx)} |
||||
|
/> |
||||
|
</span> |
||||
|
{/if} |
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
<span |
||||
|
class:spacing-group={primaryOptions[title].getIcon(option)} |
||||
|
> |
||||
|
{primaryOptions[title].getLabel(option)} |
||||
|
<span /> |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
{#if getPrimaryOptionIcon(option, idx) && getPrimaryOptionColour(option, idx)} |
||||
|
<span class="option-right"> |
||||
|
<StatusLight |
||||
|
square |
||||
|
color={getPrimaryOptionColour(option, idx)} |
||||
|
/> |
||||
|
</span> |
||||
|
{/if} |
||||
|
</span> |
||||
|
</li> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{#if secondaryOptions.length} |
||||
|
<div style="width: 30%"> |
||||
|
<button |
||||
|
{id} |
||||
|
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
||||
|
{disabled} |
||||
|
class:is-open={secondaryOpen} |
||||
|
aria-haspopup="listbox" |
||||
|
on:mousedown={onClickSecondary} |
||||
|
> |
||||
|
{#if secondaryFieldIcon} |
||||
|
<span class="option-left"> |
||||
|
<Icon name={secondaryFieldIcon} /> |
||||
|
</span> |
||||
|
{:else if secondaryFieldColour} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight square color={secondaryFieldColour} /> |
||||
|
</span> |
||||
|
{/if} |
||||
|
|
||||
|
<span class:auto-width={autoWidth} class="spectrum-Picker-label"> |
||||
|
{secondaryFieldText} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{#if secondaryOpen} |
||||
|
<div |
||||
|
use:clickOutside={() => (secondaryOpen = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
style="width: 30%" |
||||
|
> |
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#each secondaryOptions as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected( |
||||
|
getSecondaryOptionValue(option, idx) |
||||
|
)} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => |
||||
|
onPickSecondary(getSecondaryOptionValue(option, idx))} |
||||
|
> |
||||
|
{#if getSecondaryOptionColour(option, idx)} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight |
||||
|
square |
||||
|
color={getSecondaryOptionColour(option, idx)} |
||||
|
/> |
||||
|
</span> |
||||
|
{/if} |
||||
|
|
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
{getSecondaryOptionLabel(option, idx)} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.spacing-group { |
||||
|
margin-left: var(--spacing-m); |
||||
|
} |
||||
|
.spectrum-InputGroup { |
||||
|
min-width: 0; |
||||
|
width: 100%; |
||||
|
} |
||||
|
.spectrum-InputGroup :global(.spectrum-Search-input) { |
||||
|
border: none; |
||||
|
border-bottom: 1px solid var(--spectrum-global-color-gray-300); |
||||
|
border-bottom-left-radius: 0; |
||||
|
border-bottom-right-radius: 0; |
||||
|
} |
||||
|
|
||||
|
.override-borders { |
||||
|
border-top-left-radius: 0px; |
||||
|
border-bottom-left-radius: 0px; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Popover { |
||||
|
max-height: 240px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
} |
||||
|
|
||||
|
.option-left { |
||||
|
padding-right: 8px; |
||||
|
} |
||||
|
.option-right { |
||||
|
padding-left: 8px; |
||||
|
} |
||||
|
|
||||
|
.circle { |
||||
|
border-radius: 50%; |
||||
|
height: 28px; |
||||
|
color: white; |
||||
|
font-weight: bold; |
||||
|
line-height: 48px; |
||||
|
font-size: 1.2em; |
||||
|
width: 28px; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.circle > div { |
||||
|
position: absolute; |
||||
|
text-decoration: none; |
||||
|
top: 50%; |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%) translateY(-50%); |
||||
|
} |
||||
|
|
||||
|
.iconPadding { |
||||
|
position: absolute; |
||||
|
top: 50%; |
||||
|
left: 10px; |
||||
|
transform: translateY(-50%); |
||||
|
color: silver; |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
|
||||
|
.labelPadding { |
||||
|
padding-left: calc(1em + 10px + 8px); |
||||
|
} |
||||
|
|
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield { |
||||
|
width: 70%; |
||||
|
} |
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width { |
||||
|
width: 100%; |
||||
|
} |
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width input { |
||||
|
border-right-width: thin; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open { |
||||
|
width: 70%; |
||||
|
} |
||||
|
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open.is-full-width { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Search-clearButton { |
||||
|
position: absolute; |
||||
|
} |
||||
|
|
||||
|
/* Fix focus borders to show only when opened */ |
||||
|
.spectrum-Textfield-input { |
||||
|
border-color: var(--spectrum-global-color-gray-400) !important; |
||||
|
border-right-width: 1px; |
||||
|
} |
||||
|
.spectrum-Textfield-input.open { |
||||
|
border-color: var(--spectrum-global-color-blue-400) !important; |
||||
|
} |
||||
|
|
||||
|
/* Fix being able to hover and select titles */ |
||||
|
.spectrum-Menu-item.title { |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,55 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import InputDropdown from "./Core/InputDropdown.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let inputValue = null |
||||
|
export let dropdownValue = null |
||||
|
export let inputType = "text" |
||||
|
export let label = null |
||||
|
export let labelPosition = "above" |
||||
|
export let placeholder = null |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let error = null |
||||
|
export let updateOnChange = true |
||||
|
export let quiet = false |
||||
|
export let dataCy |
||||
|
export let autofocus |
||||
|
export let options = [] |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
const onPick = e => { |
||||
|
dropdownValue = e.detail |
||||
|
dispatch("pick", e.detail) |
||||
|
} |
||||
|
const onChange = e => { |
||||
|
inputValue = e.detail |
||||
|
dispatch("change", e.detail) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Field {label} {labelPosition} {error}> |
||||
|
<InputDropdown |
||||
|
{dataCy} |
||||
|
{updateOnChange} |
||||
|
{error} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{inputValue} |
||||
|
{dropdownValue} |
||||
|
{placeholder} |
||||
|
{inputType} |
||||
|
{quiet} |
||||
|
{autofocus} |
||||
|
{options} |
||||
|
on:change={onChange} |
||||
|
on:pick={onPick} |
||||
|
on:click |
||||
|
on:input |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:keyup |
||||
|
/> |
||||
|
</Field> |
||||
@ -0,0 +1,134 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import PickerDropdown from "./Core/PickerDropdown.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let primaryValue = null |
||||
|
export let secondaryValue = null |
||||
|
export let inputType = "text" |
||||
|
export let label = null |
||||
|
export let labelPosition = "above" |
||||
|
export let secondaryPlaceholder = null |
||||
|
export let autocomplete |
||||
|
export let placeholder = null |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let error = null |
||||
|
export let updateOnChange = true |
||||
|
export let getSecondaryOptionLabel = option => |
||||
|
extractProperty(option, "label") |
||||
|
export let getSecondaryOptionValue = option => |
||||
|
extractProperty(option, "value") |
||||
|
export let getSecondaryOptionColour = () => {} |
||||
|
export let getSecondaryOptionIcon = () => {} |
||||
|
export let quiet = false |
||||
|
export let dataCy |
||||
|
export let autofocus |
||||
|
export let primaryOptions = [] |
||||
|
export let secondaryOptions = [] |
||||
|
export let searchTerm |
||||
|
export let showClearIcon = true |
||||
|
|
||||
|
let primaryLabel |
||||
|
let secondaryLabel |
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
$: secondaryFieldText = getSecondaryFieldText( |
||||
|
secondaryValue, |
||||
|
secondaryOptions, |
||||
|
secondaryPlaceholder |
||||
|
) |
||||
|
$: secondaryFieldIcon = getSecondaryFieldAttribute( |
||||
|
getSecondaryOptionIcon, |
||||
|
secondaryValue, |
||||
|
secondaryOptions |
||||
|
) |
||||
|
$: secondaryFieldColour = getSecondaryFieldAttribute( |
||||
|
getSecondaryOptionColour, |
||||
|
secondaryValue, |
||||
|
secondaryOptions |
||||
|
) |
||||
|
|
||||
|
const getSecondaryFieldAttribute = (getAttribute, value, options) => { |
||||
|
// Wait for options to load if there is a value but no options |
||||
|
|
||||
|
if (!options?.length) { |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
const index = options.findIndex( |
||||
|
(option, idx) => getSecondaryOptionValue(option, idx) === value |
||||
|
) |
||||
|
|
||||
|
return index !== -1 ? getAttribute(options[index], index) : null |
||||
|
} |
||||
|
|
||||
|
const getSecondaryFieldText = (value, options, placeholder) => { |
||||
|
// Always use placeholder if no value |
||||
|
if (value == null || value === "") { |
||||
|
return placeholder || "Choose an option" |
||||
|
} |
||||
|
|
||||
|
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options) |
||||
|
} |
||||
|
|
||||
|
const onPickPrimary = e => { |
||||
|
primaryLabel = e?.detail?.label || null |
||||
|
primaryValue = e?.detail?.value || null |
||||
|
dispatch("pickprimary", e?.detail?.value || {}) |
||||
|
} |
||||
|
|
||||
|
const onPickSecondary = e => { |
||||
|
secondaryValue = e.detail |
||||
|
dispatch("picksecondary", e.detail) |
||||
|
} |
||||
|
|
||||
|
const extractProperty = (value, property) => { |
||||
|
if (value && typeof value === "object") { |
||||
|
return value[property] |
||||
|
} |
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
const updateSearchTerm = e => { |
||||
|
searchTerm = e.detail |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Field {label} {labelPosition} {error}> |
||||
|
<PickerDropdown |
||||
|
{searchTerm} |
||||
|
{autocomplete} |
||||
|
{dataCy} |
||||
|
{updateOnChange} |
||||
|
{error} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{placeholder} |
||||
|
{inputType} |
||||
|
{quiet} |
||||
|
{autofocus} |
||||
|
{primaryOptions} |
||||
|
{secondaryOptions} |
||||
|
{getSecondaryOptionLabel} |
||||
|
{getSecondaryOptionValue} |
||||
|
{getSecondaryOptionIcon} |
||||
|
{getSecondaryOptionColour} |
||||
|
{secondaryFieldText} |
||||
|
{secondaryFieldIcon} |
||||
|
{secondaryFieldColour} |
||||
|
{primaryValue} |
||||
|
{secondaryValue} |
||||
|
{primaryLabel} |
||||
|
{secondaryLabel} |
||||
|
{showClearIcon} |
||||
|
on:pickprimary={onPickPrimary} |
||||
|
on:picksecondary={onPickSecondary} |
||||
|
on:search={updateSearchTerm} |
||||
|
on:click |
||||
|
on:input |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:keyup |
||||
|
/> |
||||
|
</Field> |
||||
@ -0,0 +1,177 @@ |
|||||
|
<script> |
||||
|
//import { createEventDispatcher } from "svelte" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import clickOutside from "../Actions/click_outside" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import Icon from "../Icon/Icon.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let value |
||||
|
export let size = "M" |
||||
|
export let alignRight = false |
||||
|
|
||||
|
let open = false |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
const iconList = [ |
||||
|
{ |
||||
|
label: "Icons", |
||||
|
icons: [ |
||||
|
"Apps", |
||||
|
"Actions", |
||||
|
"ConversionFunnel", |
||||
|
"App", |
||||
|
"Briefcase", |
||||
|
"Money", |
||||
|
"ShoppingCart", |
||||
|
"Form", |
||||
|
"Help", |
||||
|
"Monitoring", |
||||
|
"Sandbox", |
||||
|
"Project", |
||||
|
"Organisations", |
||||
|
"Magnify", |
||||
|
"Launch", |
||||
|
"Car", |
||||
|
"Camera", |
||||
|
"Bug", |
||||
|
"Channel", |
||||
|
"Calculator", |
||||
|
"Calendar", |
||||
|
"GraphDonut", |
||||
|
"GraphBarHorizontal", |
||||
|
"Demographic", |
||||
|
], |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
const onChange = value => { |
||||
|
dispatch("change", value) |
||||
|
open = false |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="container"> |
||||
|
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}> |
||||
|
<div |
||||
|
class="fill" |
||||
|
style={value ? `background: ${value};` : ""} |
||||
|
class:placeholder={!value} |
||||
|
> |
||||
|
<Icon name={value || "UserGroup"} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
{#if open} |
||||
|
<div |
||||
|
use:clickOutside={() => (open = false)} |
||||
|
transition:fly={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
class:spectrum-Popover--align-right={alignRight} |
||||
|
> |
||||
|
{#each iconList as icon} |
||||
|
<div class="category"> |
||||
|
<div class="heading">{icon.label}</div> |
||||
|
<div class="icons"> |
||||
|
{#each icon.icons as icon} |
||||
|
<div |
||||
|
on:click={() => { |
||||
|
onChange(icon) |
||||
|
}} |
||||
|
> |
||||
|
<Icon name={icon} /> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.container { |
||||
|
position: relative; |
||||
|
} |
||||
|
.preview { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
position: relative; |
||||
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400); |
||||
|
} |
||||
|
.preview:hover { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.fill { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
.size--S { |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
} |
||||
|
.size--M { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
} |
||||
|
.size--L { |
||||
|
width: 48px; |
||||
|
height: 48px; |
||||
|
} |
||||
|
.spectrum-Popover { |
||||
|
width: 210px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
padding: var(--spacing-l) var(--spacing-xl); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: stretch; |
||||
|
gap: var(--spacing-xl); |
||||
|
} |
||||
|
.spectrum-Popover--align-right { |
||||
|
right: 0; |
||||
|
} |
||||
|
.icons { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; |
||||
|
gap: var(--spacing-m); |
||||
|
} |
||||
|
.heading { |
||||
|
font-size: var(--font-size-s); |
||||
|
font-weight: 600; |
||||
|
letter-spacing: 0.14px; |
||||
|
flex: 1 1 auto; |
||||
|
text-transform: uppercase; |
||||
|
grid-column: 1 / 5; |
||||
|
margin-bottom: var(--spacing-s); |
||||
|
} |
||||
|
|
||||
|
.icon { |
||||
|
height: 16px; |
||||
|
width: 16px; |
||||
|
border-radius: 100%; |
||||
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300); |
||||
|
position: relative; |
||||
|
} |
||||
|
.icon:hover { |
||||
|
cursor: pointer; |
||||
|
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300); |
||||
|
} |
||||
|
.custom { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr auto; |
||||
|
align-items: center; |
||||
|
gap: var(--spacing-m); |
||||
|
margin-right: var(--spacing-xs); |
||||
|
} |
||||
|
|
||||
|
.spectrum-wrapper { |
||||
|
background-color: transparent; |
||||
|
} |
||||
|
</style> |
||||
@ -1,53 +0,0 @@ |
|||||
<script> |
|
||||
import { View } from "svench"; |
|
||||
import DetailSummary from "./DetailSummary.svelte"; |
|
||||
</script> |
|
||||
|
|
||||
<svelte:head> |
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css" rel="stylesheet"> |
|
||||
</svelte:head> |
|
||||
|
|
||||
<style> |
|
||||
div { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: flex-start; |
|
||||
align-items: stretch; |
|
||||
gap: var(--spacing-m); |
|
||||
width: 120px; |
|
||||
} |
|
||||
</style> |
|
||||
|
|
||||
<View name="default"> |
|
||||
<div> |
|
||||
<DetailSummary name="Category 1"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
<DetailSummary name="Category 2"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
</div> |
|
||||
</View> |
|
||||
|
|
||||
<View name="thin"> |
|
||||
<div> |
|
||||
<DetailSummary thin name="Category 1"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
<DetailSummary thin name="Category 2"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
</div> |
|
||||
</View> |
|
||||
@ -0,0 +1,28 @@ |
|||||
|
<script> |
||||
|
import Detail from "../Typography/Detail.svelte" |
||||
|
|
||||
|
export let title = null |
||||
|
</script> |
||||
|
|
||||
|
<div> |
||||
|
{#if title} |
||||
|
<div class="title"> |
||||
|
<Detail>{title}</Detail> |
||||
|
</div> |
||||
|
{/if} |
||||
|
<div class="list-items"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.title { |
||||
|
margin-bottom: 6px; |
||||
|
} |
||||
|
.list-items { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: stretch; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,98 @@ |
|||||
|
<script> |
||||
|
import Body from "../Typography/Body.svelte" |
||||
|
import Icon from "../Icon/Icon.svelte" |
||||
|
import Label from "../Label/Label.svelte" |
||||
|
import Avatar from "../Avatar/Avatar.svelte" |
||||
|
|
||||
|
export let icon = null |
||||
|
export let iconBackground = null |
||||
|
export let avatar = false |
||||
|
export let title = null |
||||
|
export let subtitle = null |
||||
|
export let hoverable = false |
||||
|
|
||||
|
$: initials = avatar ? title?.[0] : null |
||||
|
</script> |
||||
|
|
||||
|
<div class="list-item" class:hoverable on:click> |
||||
|
<div class="left"> |
||||
|
{#if icon} |
||||
|
<div class="icon" style="background: {iconBackground || `transparent`};"> |
||||
|
<Icon name={icon} size="S" color={iconBackground ? "white" : null} /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{#if avatar} |
||||
|
<Avatar {initials} /> |
||||
|
{/if} |
||||
|
{#if title} |
||||
|
<Body>{title}</Body> |
||||
|
{/if} |
||||
|
{#if subtitle} |
||||
|
<Label>{subtitle}</Label> |
||||
|
{/if} |
||||
|
</div> |
||||
|
<div class="right"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.list-item { |
||||
|
padding: 0 16px; |
||||
|
height: 56px; |
||||
|
background: var(--spectrum-global-color-gray-50); |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
border: 1px solid var(--spectrum-global-color-gray-300); |
||||
|
transition: background 130ms ease-out; |
||||
|
} |
||||
|
.list-item:not(:first-child) { |
||||
|
border-top: none; |
||||
|
} |
||||
|
.list-item:first-child { |
||||
|
border-top-left-radius: 4px; |
||||
|
border-top-right-radius: 4px; |
||||
|
} |
||||
|
.list-item:last-child { |
||||
|
border-bottom-left-radius: 4px; |
||||
|
border-bottom-right-radius: 4px; |
||||
|
} |
||||
|
.hoverable:hover { |
||||
|
cursor: pointer; |
||||
|
background: var(--spectrum-global-color-gray-75); |
||||
|
} |
||||
|
.left, |
||||
|
.right { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
align-items: center; |
||||
|
gap: var(--spacing-xl); |
||||
|
} |
||||
|
.left { |
||||
|
width: 0; |
||||
|
flex: 1 1 auto; |
||||
|
} |
||||
|
.right { |
||||
|
flex: 0 0 auto; |
||||
|
} |
||||
|
.list-item :global(.spectrum-Icon), |
||||
|
.list-item :global(.spectrum-Avatar) { |
||||
|
flex: 0 0 auto; |
||||
|
} |
||||
|
.list-item :global(.spectrum-Body) { |
||||
|
color: var(--spectrum-global-color-gray-900); |
||||
|
} |
||||
|
.list-item :global(.spectrum-Body) { |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
} |
||||
|
.icon { |
||||
|
width: var(--spectrum-alias-avatar-size-400); |
||||
|
height: var(--spectrum-alias-avatar-size-400); |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
</style> |
||||
File diff suppressed because it is too large
@ -0,0 +1,114 @@ |
|||||
|
<script> |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import RowSelectorTypes from "./RowSelectorTypes.svelte" |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
export let value |
||||
|
export let bindings |
||||
|
export let block |
||||
|
export let isTestModal |
||||
|
|
||||
|
let schemaFields |
||||
|
|
||||
|
$: { |
||||
|
let fields = {} |
||||
|
|
||||
|
for (const [key, type] of Object.entries(block?.inputs?.fields)) { |
||||
|
fields = { |
||||
|
...fields, |
||||
|
[key]: { |
||||
|
type: type, |
||||
|
name: key, |
||||
|
fieldName: key, |
||||
|
constraints: { type: type }, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
if (value[key] === type) { |
||||
|
value[key] = INITIAL_VALUES[type.toUpperCase()] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
schemaFields = Object.entries(fields) |
||||
|
} |
||||
|
|
||||
|
const INITIAL_VALUES = { |
||||
|
BOOLEAN: null, |
||||
|
NUMBER: null, |
||||
|
DATETIME: null, |
||||
|
STRING: "", |
||||
|
OPTIONS: [], |
||||
|
ARRAY: [], |
||||
|
} |
||||
|
|
||||
|
const coerce = (value, type) => { |
||||
|
const re = new RegExp(/{{([^{].*?)}}/g) |
||||
|
if (re.test(value)) { |
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
if (type === "boolean") { |
||||
|
if (typeof value === "boolean") { |
||||
|
return value |
||||
|
} |
||||
|
return value === "true" |
||||
|
} |
||||
|
if (type === "number") { |
||||
|
if (typeof value === "number") { |
||||
|
return value |
||||
|
} |
||||
|
return Number(value) |
||||
|
} |
||||
|
if (type === "options") { |
||||
|
return [value] |
||||
|
} |
||||
|
if (type === "array") { |
||||
|
if (Array.isArray(value)) { |
||||
|
return value |
||||
|
} |
||||
|
return value.split(",").map(x => x.trim()) |
||||
|
} |
||||
|
|
||||
|
if (type === "link") { |
||||
|
if (Array.isArray(value)) { |
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
return [value] |
||||
|
} |
||||
|
|
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
const onChange = (e, field, type) => { |
||||
|
value[field] = coerce(e.detail, type) |
||||
|
dispatch("change", value) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
{#if schemaFields.length && isTestModal} |
||||
|
<div class="schema-fields"> |
||||
|
{#each schemaFields as [field, schema]} |
||||
|
<RowSelectorTypes |
||||
|
{isTestModal} |
||||
|
{field} |
||||
|
{schema} |
||||
|
{bindings} |
||||
|
{value} |
||||
|
{onChange} |
||||
|
/> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
.schema-fields { |
||||
|
display: grid; |
||||
|
grid-gap: var(--spacing-s); |
||||
|
margin-top: var(--spacing-s); |
||||
|
} |
||||
|
.schema-fields :global(label) { |
||||
|
text-transform: capitalize; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,24 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { roles } from "stores/backend" |
||||
|
import { RoleUtils } from "@budibase/frontend-core" |
||||
|
|
||||
|
export let value |
||||
|
export let error |
||||
|
export let placeholder = null |
||||
|
export let autoWidth = false |
||||
|
export let quiet = false |
||||
|
</script> |
||||
|
|
||||
|
<Select |
||||
|
{autoWidth} |
||||
|
{quiet} |
||||
|
bind:value |
||||
|
on:change |
||||
|
options={$roles} |
||||
|
getOptionLabel={role => role.name} |
||||
|
getOptionValue={role => role._id} |
||||
|
getOptionColour={role => RoleUtils.getRoleColour(role._id)} |
||||
|
{placeholder} |
||||
|
{error} |
||||
|
/> |
||||
@ -0,0 +1,75 @@ |
|||||
|
<script> |
||||
|
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui" |
||||
|
|
||||
|
export let searchTerm = "" |
||||
|
export let selected |
||||
|
export let filtered = [] |
||||
|
export let addAll |
||||
|
export let select |
||||
|
export let title |
||||
|
export let key |
||||
|
</script> |
||||
|
|
||||
|
<div style="padding: var(--spacing-m)"> |
||||
|
<Search placeholder="Search" bind:value={searchTerm} /> |
||||
|
<div class="header sub-header"> |
||||
|
<div> |
||||
|
<Detail |
||||
|
>{filtered.length} {title}{filtered.length === 1 ? "" : "s"}</Detail |
||||
|
> |
||||
|
</div> |
||||
|
<div> |
||||
|
<ActionButton on:click={addAll} emphasized size="S">Add all</ActionButton> |
||||
|
</div> |
||||
|
</div> |
||||
|
<Divider noMargin /> |
||||
|
<div> |
||||
|
{#each filtered as item} |
||||
|
<div |
||||
|
on:click={() => { |
||||
|
select(item._id) |
||||
|
}} |
||||
|
style="padding-bottom: var(--spacing-m)" |
||||
|
class="selection" |
||||
|
> |
||||
|
<div> |
||||
|
{item[key]} |
||||
|
</div> |
||||
|
|
||||
|
{#if selected.includes(item._id)} |
||||
|
<div> |
||||
|
<Icon |
||||
|
color="var(--spectrum-global-color-blue-600);" |
||||
|
name="Checkmark" |
||||
|
/> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.header { |
||||
|
align-items: center; |
||||
|
padding: var(--spacing-m) 0 var(--spacing-m) 0; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.selection { |
||||
|
align-items: end; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.selection > :first-child { |
||||
|
padding-top: var(--spacing-m); |
||||
|
} |
||||
|
|
||||
|
.sub-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
</style> |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue