move ui and api to seperate packages and add npm actions for them

This commit is contained in:
Gregor Vostrak
2024-08-21 14:28:31 +02:00
parent b7c9aa6f28
commit 635954f81d
185 changed files with 2755 additions and 712 deletions

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import {
formatCents,
getOrganizationCurrencySymbol,
} from '@/packages/ui/src/utils/money';
import { ref, watch } from 'vue';
import { useFocus } from '@vueuse/core';
const props = defineProps<{
name: string;
focus?: boolean;
currency: string;
}>();
const model = defineModel<number | null>({
default: null,
});
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(props.currency, '');
return value.replace(/\./g, '');
}
function updateRate(value: string) {
value = value.trim();
if (value.includes(',')) {
const parts = value.split(',');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
// we detected a decimal number with 2 digits after the comma
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value.includes('.')) {
const parts = value.split('.');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value === '') {
model.value = 0;
} else {
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
if (parsedValue) {
model.value = parsedValue;
} else {
model.value = 0;
}
}
inputValue.value = formatValue(model.value);
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0, props.currency);
return formattedValue
.replace(getOrganizationCurrencySymbol(props.currency), '')
.trim();
}
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script>
<template>
<div class="relative">
<TextInput
:id="name"
ref="billableRateInput"
v-model="inputValue"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)"
type="text"
:name="name"
placeholder="Billable Rate"
class="mt-2 block w-full"
autocomplete="teamMemberRate" />
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
<span>
{{ currency }}
</span>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
const active = defineModel({ default: false });
const emit = defineEmits(['changed']);
function toggleBillable() {
active.value = !active.value;
emit('changed', active.value);
}
const props = withDefaults(
defineProps<{
size: 'small' | 'base';
}>(),
{
size: 'base',
}
);
const iconColorClasses = computed(() => {
if (active.value) {
return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
}
});
const iconSizeClasses = computed(() => {
if (props.size === 'small') {
return 'w-5 h-5';
} else {
return 'w-5 lg:w-6 h-5 lg:h-6';
}
});
const iconSizeWrapperClasses =
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-11 h-11';
</script>
<template>
<button
@click="toggleBillable"
:class="
twMerge(
iconColorClasses,
iconSizeWrapperClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
)
">
<BillableIcon :class="iconSizeClasses"></BillableIcon>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
default: false,
},
value: {
type: String,
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
v-model="proxyChecked"
type="checkbox"
:value="value"
class="rounded bg-input-background border-input-border text-indigo-600 shadow-sm focus:ring-indigo-500" />
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
class?: string;
}>();
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
default: null,
});
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
watch(model, (value) => {
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = getDayJsInstance()(newValue);
if (newDate.isValid()) {
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('month', newDate.month())
.set('date', newDate.date())
.format();
emit('changed', model.value);
}
}
const datePicker = ref<HTMLInputElement | null>(null);
function updateTempValue(event: Event) {
const target = event.target as HTMLInputElement;
tempDate.value = target.value;
}
const emit = defineEmits(['changed']);
</script>
<template>
<div class="flex items-center justify-center text-muted">
<input
ref="datePicker"
@change="updateTempValue"
@blur="updateDate"
@keydown.enter="updateDate"
:class="
twMerge(
'bg-input-background border text-white border-input-border rounded-md',
props.class
)
"
type="date"
id="start"
name="trip-start"
:value="tempDate" />
</div>
</template>
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { CalendarIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { formatDate } from '@/packages/ui/src/utils/time';
const start = defineModel('start', { default: '' });
const end = defineModel('end', { default: '' });
</script>
<template>
<Dropdown :close-on-content-click="false" align="bottom-end">
<template #trigger>
<button
class="px-3 py-1.5 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
<CalendarIcon class="w-5"></CalendarIcon>
<div class="text-white">
{{ formatDate(start) }}
<span class="px-1.5 text-muted">-</span>
{{ formatDate(end) }}
</div>
</button>
</template>
<template #content>
<div class="overflow-hidden w-[280px] px-3 py-1.5">
<div class="flex space-x-3 items-center justify-between">
<div class="text-sm font-medium text-muted">Start Date</div>
<DatePicker v-model="start"></DatePicker>
</div>
<div class="mt-2 flex space-x-3 items-center justify-between">
<div class="text-sm font-medium text-muted">End Date</div>
<DatePicker v-model="end"></DatePicker>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
flip,
type Placement,
type ReferenceElement,
useFloating,
} from '@floating-ui/vue';
import { offset } from '@floating-ui/vue';
import { autoUpdate } from '@floating-ui/vue';
const props = withDefaults(
defineProps<{
align: Placement;
closeOnContentClick: boolean;
}>(),
{
align: 'bottom-start',
closeOnContentClick: true,
}
);
const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false });
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
if (open.value && e.key === 'Enter') {
emit('submit');
if (props.closeOnContentClick) open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
function onContentClick() {
if (props.closeOnContentClick === true) {
open.value = false;
}
}
function toggleOpen() {
open.value = !open.value;
if (open.value === true) {
emit('open');
}
}
function onBackgroundClick() {
emit('submit');
open.value = false;
}
const reference = ref<null | ReferenceElement>(null);
const floating = ref(null);
const { floatingStyles } = useFloating(reference, floating, {
placement: props.align,
whileElementsMounted: autoUpdate,
middleware: [flip(), offset(10)],
});
</script>
<template>
<div class="min-w-0">
<div @click.prevent="toggleOpen" ref="reference" class="min-w-0">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<Teleport to="body">
<div
v-show="open"
class="fixed inset-0 z-40"
@click.prevent="onBackgroundClick" />
<transition
enter-active-class="transition-opacity ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition-opacity ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div
v-if="open"
class="z-50"
ref="floating"
:style="floatingStyles"
@click="onContentClick">
<div
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none shadow-dropdown bg-card-background">
<slot name="content" />
</div>
</div>
</transition>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps({
message: String,
});
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-400">
{{ message }}
</p>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps({
value: String,
});
</script>
<template>
<label class="block font-medium text-sm text-white">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, ref, watch } from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
const model = defineModel<string | null>({
default: null,
});
const props = withDefaults(
defineProps<{
items: T[];
getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string;
align?: Placement;
}>(),
{
align: 'bottom-start',
}
);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
// DropdownMultiselect
const filteredItems = computed<T[]>(() => {
return props.items.filter((item: T) => {
return props
.getNameForItem(item)
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
watch(filteredItems, () => {
if (filteredItems.value.length > 0) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
const emit = defineEmits(['update:modelValue', 'changed']);
function setItem(newValue: string | null) {
model.value = newValue;
emit('changed');
open.value = false;
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex - 1]
);
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[0]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
);
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
);
});
onKeyStroke('ArrowDown', (e) => {
if (open.value === true) {
moveHighlightDown();
e.preventDefault();
}
});
onKeyStroke('ArrowUp', (e) => {
if (open.value === true) {
moveHighlightUp();
e.preventDefault();
}
});
onKeyStroke('Enter', (e) => {
if (open.value === true) {
setItem(highlightedItemId.value);
e.preventDefault();
}
});
watch(open, () => {
if (open.value === true) {
highlightedItemId.value = model.value;
}
});
</script>
<template>
<Dropdown v-model="open" :align="align" :closeOnContentClick="false">
<template #trigger>
<slot name="trigger"> </slot>
</template>
<template #content>
<div ref="dropdownViewport" class="w-60">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'"
role="option"
:value="props.getKeyFromItem(item)"
:class="{
'bg-card-background-active':
props.getKeyFromItem(item) === highlightedItemId,
}"
:data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem
:selected="props.getKeyFromItem(item) === model"
@click="setItem(props.getKeyFromItem(item))"
:name="props.getNameForItem(item)"></SelectDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
</script>
<template>
<div
:class="
twMerge(
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
props.selected
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
defineProps<{
name?: string;
}>();
const input = ref<HTMLInputElement | null>(null);
onMounted(() => {
if (input.value?.hasAttribute('autofocus')) {
input.value?.focus();
}
});
defineExpose({ focus: () => input.value?.focus() });
const model = defineModel();
</script>
<template>
<input
ref="input"
class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm"
v-model="model"
:name="name" />
</template>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { useFocus } from '@vueuse/core';
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
default: null,
});
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
focus: boolean;
}>(),
{
size: 'base',
focus: false,
}
);
const hours = ref(
model.value ? getLocalizedDayJs(model.value).format('HH') : null
);
const minutes = ref(
model.value ? getLocalizedDayJs(model.value).format('mm') : null
);
watch(
() => model.value,
() => {
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
);
function updateMinutes(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (!isNaN(parseInt(newValue))) {
model.value = getDayJsInstance()(model.value)
.set('minutes', Math.min(parseInt(newValue), 59))
.format();
}
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
function updateHours(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (newValue.endsWith(':')) {
minutesInput.value?.focus();
} else if (!isNaN(parseInt(newValue))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.format();
}
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
}
const hoursInput = ref<HTMLInputElement | null>(null);
const minutesInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['changed']);
useFocus(hoursInput, { initialValue: props.focus });
</script>
<template>
<div class="flex items-center justify-center text-white">
<div
:class="
twMerge(
'border bg-input-background rounded-md border-input-border overflow-hidden',
props.size === 'large' ? 'py-1.5 px-2' : ''
)
">
<input
v-model="hours"
ref="hoursInput"
@input="updateHours"
@keydown.enter="emit('changed')"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_hour"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
<span>:</span>
<input
v-model="minutes"
ref="minutesInput"
@keydown.enter="emit('changed')"
@input="updateMinutes"
@focus="($event.target as HTMLInputElement).select()"
data-testid="time_picker_minute"
type="text"
:class="
twMerge(
'border-none bg-transparent px-1 py-1 w-[30px] text-center focus:ring-0 focus:bg-card-background-active',
props.size === 'large' ? 'text-base' : 'text-sm'
)
" />
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { defineProps, ref, watch } from 'vue';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { useFocusWithin } from '@vueuse/core';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
const props = defineProps<{
start: string;
end: string | null;
focus?: boolean;
}>();
// The timestamps for the changed event are UTC
const emit = defineEmits(['changed']);
const tempStart = ref(
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
);
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
watch(props, () => {
tempStart.value = getLocalizedDayJs(props.start).format();
tempEnd.value = getLocalizedDayJs(props.end).format();
});
function updateTimeEntry() {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value
? getDayJsInstance()(tempEnd.value).utc().format()
: null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit(
'changed',
getDayJsInstance()(tempStart.value).utc().format(),
getDayJsInstance()(tempEnd.value).utc().format()
);
}
}
const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
updateTimeEntry();
}
});
</script>
<template>
<div
ref="dropdownContent"
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div class="px-2">
<div class="font-bold text-white text-sm pb-2">Start</div>
<div class="space-y-1">
<TimePicker
data-testid="time_entry_range_start"
:focus
@changed="updateTimeEntry"
v-model="tempStart"></TimePicker>
<DatePicker
class="text-sm px-2 py-1"
@changed="updateTimeEntry"
v-model="tempStart"></DatePicker>
</div>
</div>
<div class="px-2">
<div class="font-bold text-white text-sm pb-2">End</div>
<div v-if="tempEnd !== null" class="space-y-1">
<TimePicker
data-testid="time_entry_range_end"
@changed="updateTimeEntry"
v-model="tempEnd"></TimePicker>
<DatePicker
class="text-sm px-2 py-1"
@changed="updateTimeEntry"
v-model="tempEnd"></DatePicker>
</div>
<div class="text-muted" v-else>-- : --</div>
</div>
</div>
</template>
<style></style>