Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
bg: {
|
||||
type: String,
|
||||
default: 'bg-white dark:bg-n-solid-2',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative my-4 section-separator">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-n-strong" />
|
||||
</div>
|
||||
<div v-if="label" class="relative flex justify-center text-sm">
|
||||
<span class="px-2 text-n-slate-10" :class="bg">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
isChecked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const checked = computed({
|
||||
get: () => props.isChecked,
|
||||
set: value => emit('update', props.value, value),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="value"
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
class="flex-shrink-0 mt-0.5 border-n-strong border bg-n-slate-2 checked:border-none checked:bg-n-brand shadow-sm appearance-none rounded-[4px] w-4 h-4 focus:ring-1 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center after:text-center after:text-xs after:font-bold after:relative"
|
||||
/>
|
||||
</template>
|
||||
104
research/chatwoot/app/javascript/v3/components/Form/Input.vue
Normal file
104
research/chatwoot/app/javascript/v3/components/Form/Input.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { defineProps, defineModel, computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WithLabel from './WithLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasError: Boolean,
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spacing: {
|
||||
type: String,
|
||||
default: 'base',
|
||||
validator: value => ['base', 'compact'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const FIELDS = {
|
||||
TEXT: 'text',
|
||||
PASSWORD: 'password',
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const model = defineModel({
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const [isPasswordVisible, togglePasswordVisibility] = useToggle();
|
||||
|
||||
const isPasswordField = computed(() => props.type === FIELDS.PASSWORD);
|
||||
|
||||
const currentInputType = computed(() => {
|
||||
if (isPasswordField.value) {
|
||||
return isPasswordVisible.value ? FIELDS.TEXT : FIELDS.PASSWORD;
|
||||
}
|
||||
return props.type;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WithLabel
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<template #rightOfLabel>
|
||||
<slot />
|
||||
</template>
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
v-model="model"
|
||||
:name="name"
|
||||
:type="currentInputType"
|
||||
class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3"
|
||||
:class="{
|
||||
'error outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8':
|
||||
hasError,
|
||||
'outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus:outline-n-brand dark:focus:outline-n-brand':
|
||||
!hasError,
|
||||
'px-3 py-3': spacing === 'base',
|
||||
'px-3 py-2 mb-0': spacing === 'compact',
|
||||
'pl-9': icon,
|
||||
'pr-10': isPasswordField,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="isPasswordField"
|
||||
type="button"
|
||||
slate
|
||||
sm
|
||||
link
|
||||
:icon="isPasswordVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
class="absolute inset-y-0 right-0 pr-3"
|
||||
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="isPasswordVisible"
|
||||
@click="togglePasswordVisibility()"
|
||||
/>
|
||||
</WithLabel>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('update:modelValue', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WithLabel
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<select
|
||||
:id="id"
|
||||
:selected="modelValue"
|
||||
:name="name"
|
||||
:class="{
|
||||
'text-n-slate-9': !modelValue,
|
||||
'text-n-slate-12': modelValue,
|
||||
'pl-9': icon,
|
||||
}"
|
||||
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
|
||||
@input="onInput"
|
||||
>
|
||||
<option value="" disabled selected class="hidden">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<slot>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</slot>
|
||||
</select>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-caret {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-origin: content-box;
|
||||
background-position: right -1rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 9px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, default: '' },
|
||||
name: { type: String, required: true },
|
||||
icon: { type: String, default: '' },
|
||||
hasError: { type: Boolean, default: false },
|
||||
helpMessage: { type: String, default: '' },
|
||||
errorMessage: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
class="flex justify-between text-sm font-medium leading-6 text-n-slate-12"
|
||||
:class="{ 'text-n-ruby-12': hasError }"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
<slot name="rightOfLabel" />
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center relative w-full">
|
||||
<fluent-icon
|
||||
v-if="icon"
|
||||
size="16"
|
||||
:icon="icon"
|
||||
class="absolute left-2 transform text-n-slate-9 w-5 h-5"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage && hasError"
|
||||
class="text-sm mt-1.5 ml-px text-n-ruby-9 leading-tight"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="helpMessage || $slots.help"
|
||||
class="text-sm mt-1.5 ml-px text-n-slate-10 leading-tight"
|
||||
>
|
||||
<slot name="help">
|
||||
{{ helpMessage }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import GoogleOAuthButton from './Button.vue';
|
||||
|
||||
function getWrapper() {
|
||||
return shallowMount(GoogleOAuthButton, {
|
||||
mocks: { $t: text => text },
|
||||
});
|
||||
}
|
||||
|
||||
describe('GoogleOAuthButton.vue', () => {
|
||||
beforeEach(() => {
|
||||
window.chatwootConfig = {
|
||||
googleOAuthClientId: 'clientId',
|
||||
googleOAuthCallbackUrl: 'http://localhost:3000/test-callback',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.chatwootConfig = {};
|
||||
});
|
||||
|
||||
it('generates the correct Google Auth URL', () => {
|
||||
const wrapper = getWrapper();
|
||||
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
|
||||
const params = googleAuthUrl.searchParams;
|
||||
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
|
||||
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth');
|
||||
expect(params.get('client_id')).toBe('clientId');
|
||||
expect(params.get('redirect_uri')).toBe(
|
||||
'http://localhost:3000/test-callback'
|
||||
);
|
||||
expect(params.get('response_type')).toBe('code');
|
||||
expect(params.get('scope')).toBe('email profile');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
getGoogleAuthUrl() {
|
||||
// Ideally a request to /auth/google_oauth2 should be made
|
||||
// Creating the URL manually because the devise-token-auth with
|
||||
// omniauth has a standing issue on redirecting the post request
|
||||
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
|
||||
const baseUrl = 'https://accounts.google.com/o/oauth2/auth';
|
||||
const clientId = window.chatwootConfig.googleOAuthClientId;
|
||||
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
|
||||
const responseType = 'code';
|
||||
const scope = 'email profile';
|
||||
|
||||
// Build the query string
|
||||
const queryString = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: responseType,
|
||||
scope: scope,
|
||||
}).toString();
|
||||
|
||||
// Construct the full URL
|
||||
return `${baseUrl}?${queryString}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-unused-refs -->
|
||||
<!-- Added ref for writing specs -->
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
:href="getGoogleAuthUrl()"
|
||||
class="inline-flex justify-center w-full px-4 py-3 bg-n-background dark:bg-n-solid-3 items-center rounded-md shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
|
||||
>
|
||||
<span class="i-logos-google-icon h-6" />
|
||||
<span class="ml-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import SnackbarItem from './Item.vue';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
components: { SnackbarItem },
|
||||
props: {
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 2500,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
snackbarAlertMessages: [],
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.SHOW_TOAST, this.onNewToastMessage);
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(BUS_EVENTS.SHOW_TOAST, this.onNewToastMessage);
|
||||
},
|
||||
methods: {
|
||||
onNewToastMessage({ message, action }) {
|
||||
this.snackbarAlertMessages.push({
|
||||
key: new Date().getTime(),
|
||||
message,
|
||||
action,
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
this.snackbarAlertMessages.splice(0, 1);
|
||||
}, this.duration);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition-group
|
||||
name="toast-fade"
|
||||
tag="div"
|
||||
class="fixed left-0 right-0 mx-auto overflow-hidden text-center top-10 z-50 max-w-[40rem]"
|
||||
>
|
||||
<SnackbarItem
|
||||
v-for="snackbarAlertMessage in snackbarAlertMessages"
|
||||
:key="snackbarAlertMessage.key"
|
||||
:message="snackbarAlertMessage.message"
|
||||
:action="snackbarAlertMessage.action"
|
||||
/>
|
||||
</transition-group>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
message: { type: String, default: '' },
|
||||
action: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isActionPresent() {
|
||||
return this.action && this.action.message;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-slate-12 dark:bg-n-slate-7 rounded-md drop-shadow-md mb-4 max-w-[40rem] inline-flex items-center min-w-[22rem] py-3 px-4"
|
||||
:class="isActionPresent ? 'justify-between' : 'justify-center'"
|
||||
>
|
||||
<div class="text-sm font-medium text-white">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="isActionPresent" class="ml-4">
|
||||
<router-link v-if="action.type == 'link'" :to="action.to" class="">
|
||||
{{ action.message }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user