@ -0,0 +1,147 @@
|
||||
name: Playwright test reusable workflow |
||||
|
||||
on: |
||||
workflow_call: |
||||
inputs: |
||||
shard: |
||||
description: 'Shard number' |
||||
required: true |
||||
type: string |
||||
db: |
||||
required: true |
||||
type: string |
||||
|
||||
jobs: |
||||
playwright: |
||||
runs-on: ubuntu-20.04 |
||||
timeout-minutes: 30 |
||||
steps: |
||||
# Reference: https://github.com/pierotofy/set-swap-space/blob/master/action.yml |
||||
- name: Set 5gb swap |
||||
shell: bash |
||||
# Delete the swap file, allocate a new one, and activate it |
||||
run: | |
||||
export SWAP_FILE=$(swapon --show=NAME | tail -n 1) |
||||
sudo swapoff $SWAP_FILE |
||||
sudo rm $SWAP_FILE |
||||
sudo fallocate -l 5G $SWAP_FILE |
||||
sudo chmod 600 $SWAP_FILE |
||||
sudo mkswap $SWAP_FILE |
||||
sudo swapon $SWAP_FILE |
||||
- name: Setup Node |
||||
uses: actions/setup-node@v3 |
||||
with: |
||||
node-version: 16.15.0 |
||||
- name: Checkout |
||||
uses: actions/checkout@v3 |
||||
- name: Cache node modules |
||||
uses: actions/cache@v3 |
||||
env: |
||||
cache-name: cache-node-modules |
||||
|
||||
with: |
||||
# npm cache files are stored in `~/.npm` on Linux/macOS |
||||
path: ~/.npm |
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} |
||||
restore-keys: | |
||||
${{ runner.os }}-build-${{ env.cache-name }}- |
||||
${{ runner.os }}-build- |
||||
${{ runner.os }}- |
||||
- name: install dependencies nocodb-sdk |
||||
working-directory: ./packages/nocodb-sdk |
||||
run: npm install |
||||
- name: build nocodb-sdk |
||||
working-directory: ./packages/nocodb-sdk |
||||
run: npm run build |
||||
- name: setup mysql |
||||
if: ${{ inputs.db == 'mysql' }} |
||||
working-directory: ./ |
||||
run: docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d & |
||||
- name: setup pg |
||||
if: ${{ inputs.db == 'pg' }} |
||||
working-directory: ./ |
||||
run: docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml up -d & |
||||
- name: setup pg for quick tests |
||||
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }} |
||||
working-directory: ./ |
||||
run: docker-compose -f ./tests/playwright/scripts/docker-compose-pg-pw-quick.yml up -d & |
||||
- name: run frontend |
||||
working-directory: ./packages/nc-gui |
||||
run: npm run ci:run |
||||
- name: Run backend |
||||
working-directory: ./packages/nocodb |
||||
run: | |
||||
npm install |
||||
npm run watch:run:playwright > ${{ inputs.db }}_${{ inputs.shard }}_test_backend.log & |
||||
- name: Cache playwright npm modules |
||||
uses: actions/cache@v3 |
||||
id: playwright-cache |
||||
with: |
||||
path: | |
||||
**/tests/playwright/node_modules |
||||
key: cache-nc-playwright-${{ hashFiles('**/tests/playwright/package-lock.json') }} |
||||
- name: Install dependencies |
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' |
||||
working-directory: ./tests/playwright |
||||
run: npm install |
||||
- name: Install Playwright Browsers |
||||
working-directory: ./tests/playwright |
||||
run: npx playwright install chromium --with-deps |
||||
- name: Wait for backend |
||||
run: | |
||||
while ! curl --output /dev/null --silent --head --fail http://localhost:8080; do |
||||
printf '.' |
||||
sleep 2 |
||||
done |
||||
|
||||
- name: Run Playwright tests |
||||
working-directory: ./tests/playwright |
||||
run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }} |
||||
|
||||
# Quick tests (pg on sqlite shard 0 and sqlite on sqlite shard 1) |
||||
- name: Run quick server and tests (pg) |
||||
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }} |
||||
working-directory: ./packages/nocodb |
||||
run: | |
||||
kill -9 $(lsof -t -i:8080) |
||||
npm run watch:run:playwright:pg:cyquick & |
||||
- name: Run quick server and tests (sqlite) |
||||
if: ${{ inputs.db == 'sqlite' && inputs.shard == '2' }} |
||||
working-directory: ./packages/nocodb |
||||
run: | |
||||
kill -9 $(lsof -t -i:8080) |
||||
npm run watch:run:playwright:quick > quick_${{ inputs.shard }}_test_backend.log & |
||||
- name: Wait for backend & run quick tests |
||||
if: ${{ inputs.db == 'sqlite' }} |
||||
working-directory: ./tests/playwright |
||||
run: | |
||||
while ! curl --output /dev/null --silent --head --fail http://localhost:8080; do |
||||
printf '.' |
||||
sleep 2 |
||||
done |
||||
PLAYWRIGHT_HTML_REPORT=playwright-report-quick npm run test:quick |
||||
- uses: actions/upload-artifact@v3 |
||||
if: ${{ inputs.db == 'sqlite' }} |
||||
with: |
||||
name: quick-backend-log-${{ inputs.shard }} |
||||
path: ./packages/nocodb/quick_${{ inputs.shard }}_test_backend.log |
||||
retention-days: 2 |
||||
- uses: actions/upload-artifact@v3 |
||||
if: ${{ inputs.db == 'sqlite' }} |
||||
with: |
||||
name: playwright-report-quick-${{ inputs.shard }} |
||||
path: ./tests/playwright/playwright-report-quick/ |
||||
retention-days: 2 |
||||
|
||||
- uses: actions/upload-artifact@v3 |
||||
if: always() |
||||
with: |
||||
name: playwright-report-${{ inputs.db }}-${{ inputs.shard }} |
||||
path: ./tests/playwright/playwright-report/ |
||||
retention-days: 2 |
||||
- uses: actions/upload-artifact@v3 |
||||
if: always() |
||||
with: |
||||
name: backend-logs-${{ inputs.db }}-${{ inputs.shard }} |
||||
path: ./packages/nocodb/${{ inputs.db }}_${{ inputs.shard }}_test_backend.log |
||||
retention-days: 2 |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 982 B |
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 13 KiB |
@ -0,0 +1,8 @@
|
||||
<template> |
||||
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('title.appStore') }}</div> |
||||
<div> |
||||
<LazyDashboardSettingsAppStore /> |
||||
</div> |
||||
</div> |
||||
</template> |
@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup> |
||||
import { useNuxtApp } from '#app' |
||||
import { message } from 'ant-design-vue' |
||||
import { extractSdkResponseErrorMsg, useApi } from '#imports' |
||||
|
||||
const { api, isLoading } = useApi() |
||||
|
||||
const {$e} = useNuxtApp() |
||||
|
||||
let key = $ref('') |
||||
|
||||
const loadLicense = async () => { |
||||
try { |
||||
const response = await api.orgLicense.get() |
||||
|
||||
key = response.key |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
const setLicense = async () => { |
||||
try { |
||||
await api.orgLicense.set({ key: key }) |
||||
message.success('License key updated') |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
$e('a:account:license') |
||||
} |
||||
|
||||
loadLicense() |
||||
|
||||
</script> |
||||
|
||||
<template> |
||||
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div> |
||||
<div> |
||||
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea> |
||||
</div> |
||||
<a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,146 @@
|
||||
<script lang="ts" setup> |
||||
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports' |
||||
|
||||
const { api, error } = useApi({ useGlobalInstance: true }) |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { signOut } = useGlobal() |
||||
|
||||
const formValidator = ref() |
||||
|
||||
const form = reactive({ |
||||
currentPassword: '', |
||||
password: '', |
||||
passwordRepeat: '', |
||||
}) |
||||
|
||||
const formRules = { |
||||
currentPassword: [ |
||||
// Current password is required |
||||
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||
], |
||||
password: [ |
||||
// Password is required |
||||
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||
{ min: 8, message: t('msg.error.signUpRules.passwdLength') }, |
||||
], |
||||
passwordRepeat: [ |
||||
// PasswordRepeat is required |
||||
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||
// Passwords match |
||||
{ |
||||
validator: (_: unknown, _v: string) => { |
||||
return new Promise((resolve, reject) => { |
||||
if (form.password === form.passwordRepeat) return resolve(true) |
||||
reject(new Error(t('msg.error.signUpRules.passwdMismatch'))) |
||||
}) |
||||
}, |
||||
message: t('msg.error.signUpRules.passwdMismatch'), |
||||
}, |
||||
], |
||||
} |
||||
|
||||
const passwordChange = async () => { |
||||
const valid = formValidator.value.validate() |
||||
if (!valid) return |
||||
|
||||
error.value = null |
||||
|
||||
await api.auth.passwordChange({ |
||||
currentPassword: form.currentPassword, |
||||
newPassword: form.password, |
||||
}) |
||||
|
||||
message.success(t('msg.success.passwordChanged')) |
||||
|
||||
signOut() |
||||
|
||||
navigateTo('/signin') |
||||
} |
||||
|
||||
const resetError = () => { |
||||
if (error.value) error.value = null |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="mx-auto relative flex flex-col justify-center gap-2 w-full px-8 md:(bg-white) max-w-[900px]"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('activity.changePwd') }}</div> |
||||
<a-form |
||||
ref="formValidator" |
||||
data-testid="nc-user-settings-form" |
||||
layout="vertical" |
||||
class="change-password lg:max-w-3/4 w-full !mx-auto" |
||||
no-style |
||||
:model="form" |
||||
@finish="passwordChange" |
||||
> |
||||
<Transition name="layout"> |
||||
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1"> |
||||
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center"> |
||||
<MaterialSymbolsWarning /> |
||||
{{ error }} |
||||
</div> |
||||
</div> |
||||
</Transition> |
||||
|
||||
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword"> |
||||
<a-input-password |
||||
v-model:value="form.currentPassword" |
||||
data-testid="nc-user-settings-form__current-password" |
||||
size="large" |
||||
class="password" |
||||
:placeholder="$t('placeholder.password.current')" |
||||
@focus="resetError" |
||||
/> |
||||
</a-form-item> |
||||
|
||||
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password"> |
||||
<a-input-password |
||||
v-model:value="form.password" |
||||
data-testid="nc-user-settings-form__new-password" |
||||
size="large" |
||||
class="password" |
||||
:placeholder="$t('placeholder.password.new')" |
||||
@focus="resetError" |
||||
/> |
||||
</a-form-item> |
||||
|
||||
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat"> |
||||
<a-input-password |
||||
v-model:value="form.passwordRepeat" |
||||
data-testid="nc-user-settings-form__new-password-repeat" |
||||
size="large" |
||||
class="password" |
||||
:placeholder="$t('placeholder.password.confirm')" |
||||
@focus="resetError" |
||||
/> |
||||
</a-form-item> |
||||
|
||||
<div class="text-center"> |
||||
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit"> |
||||
<span class="flex items-center gap-2"> |
||||
<MdiKeyChange /> |
||||
{{ $t('activity.changePwd') }} |
||||
</span> |
||||
</button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss"> |
||||
.change-password { |
||||
.ant-input-affix-wrapper, |
||||
.ant-input { |
||||
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded; |
||||
} |
||||
|
||||
.password { |
||||
input { |
||||
@apply !border-none !m-0; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup> |
||||
import { message } from 'ant-design-vue' |
||||
import { extractSdkResponseErrorMsg, useApi } from '#imports' |
||||
|
||||
const { api } = useApi() |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
let settings = $ref<{ invite_only_signup?: boolean }>({ invite_only_signup: false }) |
||||
|
||||
const loadSettings = async () => { |
||||
try { |
||||
const response = await api.orgAppSettings.get() |
||||
settings = response |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
const saveSettings = async () => { |
||||
try { |
||||
await api.orgAppSettings.set(settings) |
||||
message.success(t('msg.success.settingsSaved')) |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
loadSettings() |
||||
</script> |
||||
|
||||
<template> |
||||
<div data-testid="nc-app-settings"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Settings</div> |
||||
<div class="flex justify-center"> |
||||
<a-form-item> |
||||
<a-checkbox |
||||
v-model:checked="settings.invite_only_signup" |
||||
v-e="['c:account:enable-signup']" |
||||
class="nc-checkbox nc-invite-only-signup-checkbox" |
||||
name="virtual" |
||||
@change="saveSettings" |
||||
> |
||||
{{ $t('labels.inviteOnlySignup') }} |
||||
</a-checkbox> |
||||
</a-form-item> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
:deep(.ant-checkbox-wrapper) { |
||||
@apply !flex-row-reverse !flex !justify-start gap-4; |
||||
justify-content: start; |
||||
} |
||||
</style> |
@ -0,0 +1,262 @@
|
||||
<script lang="ts" setup> |
||||
import { Empty, Modal, message } from 'ant-design-vue' |
||||
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk' |
||||
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports' |
||||
|
||||
const { api, isLoading } = useApi() |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
const { copy } = useCopy() |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
let tokens = $ref<UserType[]>([]) |
||||
|
||||
let currentPage = $ref(1) |
||||
|
||||
let showNewTokenModal = $ref(false) |
||||
|
||||
const currentLimit = $ref(10) |
||||
|
||||
let selectedTokenData = $ref<ApiTokenType>({}) |
||||
|
||||
const searchText = ref<string>('') |
||||
|
||||
const pagination = reactive({ |
||||
total: 0, |
||||
pageSize: 10, |
||||
}) |
||||
const loadTokens = async (page = currentPage, limit = currentLimit) => { |
||||
currentPage = page |
||||
try { |
||||
const response: any = await api.orgTokens.list({ |
||||
query: { |
||||
limit, |
||||
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||
}, |
||||
} as RequestParams) |
||||
if (!response) return |
||||
|
||||
pagination.total = response.pageInfo.totalRows ?? 0 |
||||
pagination.pageSize = 10 |
||||
|
||||
tokens = response.list as UserType[] |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
loadTokens() |
||||
|
||||
const deleteToken = async (token: string) => { |
||||
Modal.confirm({ |
||||
title: t('msg.info.deleteTokenConfirmation'), |
||||
type: 'warn', |
||||
onOk: async () => { |
||||
try { |
||||
// todo: delete token |
||||
await api.orgTokens.delete(token) |
||||
message.success(t('msg.success.tokenDeleted')) |
||||
await loadTokens() |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
$e('a:account:token:delete') |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
const generateToken = async () => { |
||||
try { |
||||
await api.orgTokens.create(selectedTokenData) |
||||
showNewTokenModal = false |
||||
// Token generated successfully |
||||
message.success(t('msg.success.tokenGenerated')) |
||||
selectedTokenData = {} |
||||
await loadTokens() |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
$e('a:api-token:generate') |
||||
} |
||||
|
||||
const copyToken = (token: string | undefined) => { |
||||
if (!token) return |
||||
|
||||
copy(token) |
||||
// Copied to clipboard |
||||
message.info(t('msg.info.copiedToClipboard')) |
||||
|
||||
$e('c:api-token:copy') |
||||
} |
||||
|
||||
const descriptionInput = (el) => { |
||||
el?.focus() |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Token Management</div> |
||||
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list"> |
||||
<div class="py-2 flex gap-4 items-center"> |
||||
<div class="flex-grow"></div> |
||||
<MdiReload class="cursor-pointer" @click="loadTokens" /> |
||||
<a-button data-testid="nc-token-create" size="small" type="primary" @click="showNewTokenModal = true"> |
||||
<div class="flex items-center gap-1"> |
||||
<MdiAdd /> |
||||
Add new token |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
<a-table |
||||
:row-key="(record) => record.id" |
||||
:data-source="tokens" |
||||
:pagination="{ position: ['bottomCenter'] }" |
||||
:loading="isLoading" |
||||
size="small" |
||||
@change="loadTokens($event.current)" |
||||
> |
||||
<template #emptyText> |
||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
||||
</template> |
||||
|
||||
<!-- Created By --> |
||||
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by"> |
||||
<template #default="{ text }"> |
||||
<div v-if="text"> |
||||
{{ text }} |
||||
</div> |
||||
<div v-else class="text-gray-400">N/A</div> |
||||
</template> |
||||
</a-table-column> |
||||
|
||||
<!-- Description --> |
||||
<a-table-column key="description" :title="$t('labels.description')" data-index="description"> |
||||
<template #default="{ text }"> |
||||
{{ text }} |
||||
</template> |
||||
</a-table-column> |
||||
|
||||
<!-- Token --> |
||||
<a-table-column key="token" :title="$t('labels.token')" data-index="token"> |
||||
<template #default="{ text, record }"> |
||||
<div class="w-[320px]"> |
||||
<span v-if="record.show">{{ text }}</span> |
||||
<span v-else>*******************************************</span> |
||||
</div> |
||||
</template> |
||||
</a-table-column> |
||||
|
||||
<!-- Actions --> |
||||
|
||||
<a-table-column key="actions" :title="$t('labels.actions')" data-index="token"> |
||||
<template #default="{ record }"> |
||||
<div class="flex items-center gap-2"> |
||||
<a-tooltip placement="bottom"> |
||||
<template #title> |
||||
<span v-if="record.show"> {{ $t('general.hide') }} </span> |
||||
<span v-else> {{ $t('general.show') }} </span> |
||||
</template> |
||||
|
||||
<a-button type="text" class="!rounded-md nc-toggle-token-visibility" @click="record.show = !record.show"> |
||||
<template #icon> |
||||
<MaterialSymbolsVisibilityOff v-if="record.show" class="flex mx-auto h-[1.1rem]" /> |
||||
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
|
||||
<a-tooltip placement="bottom"> |
||||
<template #title> {{ $t('general.copy') }}</template> |
||||
|
||||
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)"> |
||||
<template #icon> |
||||
<MdiContentCopy class="flex mx-auto h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
|
||||
<a-dropdown |
||||
:trigger="['click']" |
||||
class="flex" |
||||
placement="bottomRight" |
||||
overlay-class-name="nc-dropdown-api-token-mgmt" |
||||
> |
||||
<div class="flex flex-row items-center"> |
||||
<a-button type="text" class="!px-0"> |
||||
<div class="flex flex-row items-center h-[1.2rem]"> |
||||
<IcBaselineMoreVert class="nc-token-menu" /> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
|
||||
<template #overlay> |
||||
<a-menu data-testid="nc-token-row-action-icon"> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-3 h-[1rem] nc-delete-token" @click="deleteToken(record.token)"> |
||||
<MdiDeleteOutline class="flex" /> |
||||
<div class="text-xs pl-2">{{ $t('general.remove') }}</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
</a-dropdown> |
||||
</div> |
||||
</template> |
||||
</a-table-column> |
||||
</a-table> |
||||
</div> |
||||
|
||||
<a-modal |
||||
v-model:visible="showNewTokenModal" |
||||
:closable="false" |
||||
width="28rem" |
||||
centered |
||||
:footer="null" |
||||
wrap-class-name="nc-modal-generate-token" |
||||
> |
||||
<div class="relative flex flex-col h-full"> |
||||
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false"> |
||||
<template #icon> |
||||
<MaterialSymbolsCloseRounded class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
|
||||
<!-- Generate Token --> |
||||
<div class="flex flex-row justify-center w-full -mt-1 mb-3"> |
||||
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title> |
||||
</div> |
||||
|
||||
<!-- Description --> |
||||
<a-form |
||||
ref="form" |
||||
:model="selectedTokenData" |
||||
name="basic" |
||||
layout="vertical" |
||||
class="flex flex-col justify-center space-y-6" |
||||
no-style |
||||
autocomplete="off" |
||||
@finish="generateToken" |
||||
> |
||||
<a-input |
||||
:ref="descriptionInput" |
||||
v-model:value="selectedTokenData.description" |
||||
data-testid="nc-token-modal-description" |
||||
:placeholder="$t('labels.description')" |
||||
/> |
||||
|
||||
<!-- Generate --> |
||||
<div class="flex flex-row justify-center"> |
||||
<a-button type="primary" html-type="submit" data-testid="nc-token-modal-save"> |
||||
{{ $t('general.generate') }} |
||||
</a-button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</a-modal> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -0,0 +1,283 @@
|
||||
<script lang="ts" setup> |
||||
import { Modal, message } from 'ant-design-vue' |
||||
import type { RequestParams, UserType } from 'nocodb-sdk' |
||||
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports' |
||||
import type { User } from '~/lib' |
||||
|
||||
const { api, isLoading } = useApi() |
||||
|
||||
const { $e } = useNuxtApp() |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { dashboardUrl } = $(useDashboard()) |
||||
|
||||
const { copy } = useCopy() |
||||
|
||||
let users = $ref<UserType[]>([]) |
||||
|
||||
let currentPage = $ref(1) |
||||
|
||||
const currentLimit = $ref(10) |
||||
|
||||
const showUserModal = ref(false) |
||||
|
||||
const userMadalKey = ref(0) |
||||
|
||||
const searchText = ref<string>('') |
||||
|
||||
const pagination = reactive({ |
||||
total: 0, |
||||
pageSize: 10, |
||||
position: ['bottomCenter'], |
||||
}) |
||||
|
||||
const loadUsers = async (page = currentPage, limit = currentLimit) => { |
||||
currentPage = page |
||||
try { |
||||
const response: any = await api.orgUsers.list({ |
||||
query: { |
||||
limit, |
||||
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||
query: searchText.value, |
||||
}, |
||||
} as RequestParams) |
||||
if (!response) return |
||||
|
||||
pagination.total = response.pageInfo.totalRows ?? 0 |
||||
pagination.pageSize = 10 |
||||
|
||||
users = response.list as UserType[] |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
loadUsers() |
||||
|
||||
const updateRole = async (userId: string, roles: Role) => { |
||||
try { |
||||
await api.orgUsers.update(userId, { |
||||
roles, |
||||
} as unknown as UserType) |
||||
message.success(t('msg.success.roleUpdated')) |
||||
|
||||
$e('a:org-user:role-updated', { role: roles }) |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
const deleteUser = async (userId: string) => { |
||||
Modal.confirm({ |
||||
title: 'Are you sure you want to delete this user?', |
||||
type: 'warn', |
||||
content: 'On deleting, user will remove from organization and any sync source(Airtable) created by user will get removed', |
||||
onOk: async () => { |
||||
try { |
||||
await api.orgUsers.delete(userId) |
||||
message.success(t('msg.success.userDeleted')) |
||||
await loadUsers() |
||||
$e('a:org-user:user-deleted') |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
const resendInvite = async (user: User) => { |
||||
try { |
||||
await api.orgUsers.resendInvite(user.id) |
||||
|
||||
// Invite email sent successfully |
||||
message.success(t('msg.success.inviteEmailSent')) |
||||
await loadUsers() |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
$e('a:org-user:resend-invite') |
||||
} |
||||
|
||||
const copyInviteUrl = (user: User) => { |
||||
if (!user.invite_token) return |
||||
|
||||
copy(`${dashboardUrl}#/signup/${user.invite_token}`) |
||||
|
||||
// Invite URL copied to clipboard |
||||
message.success(t('msg.success.inviteURLCopied')) |
||||
$e('c:user:copy-url') |
||||
} |
||||
|
||||
const copyPasswordResetUrl = async (user: User) => { |
||||
try { |
||||
const { reset_password_url } = await api.orgUsers.generatePasswordResetToken(user.id) |
||||
|
||||
copy(reset_password_url) |
||||
|
||||
// Invite URL copied to clipboard |
||||
message.success(t('msg.success.passwordResetURLCopied')) |
||||
$e('c:user:copy-url') |
||||
} catch (e) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div data-testid="nc-super-user-list"> |
||||
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">User Management</div> |
||||
<div class="max-w-[900px] mx-auto p-4"> |
||||
<div class="py-2 flex gap-4 items-center"> |
||||
<a-input-search |
||||
v-model:value="searchText" |
||||
size="small" |
||||
class="max-w-[300px]" |
||||
placeholder="Filter by email" |
||||
@blur="loadUsers" |
||||
@keydown.enter="loadUsers" |
||||
> |
||||
</a-input-search> |
||||
<div class="flex-grow"></div> |
||||
<MdiReload class="cursor-pointer" @click="loadUsers" /> |
||||
<a-button |
||||
data-testid="nc-super-user-invite" |
||||
size="small" |
||||
type="primary" |
||||
@click=" |
||||
() => { |
||||
showUserModal = true |
||||
userMadalKey++ |
||||
} |
||||
" |
||||
> |
||||
<div class="flex items-center gap-1"> |
||||
<MdiAdd /> |
||||
Invite new user |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
<a-table |
||||
:row-key="(record) => record.id" |
||||
:data-source="users" |
||||
:pagination="pagination" |
||||
:loading="isLoading" |
||||
size="small" |
||||
@change="loadUsers($event.current)" |
||||
> |
||||
<template #emptyText> |
||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
||||
</template> |
||||
|
||||
<!-- Email --> |
||||
<a-table-column key="email" :title="$t('labels.email')" data-index="email"> |
||||
<template #default="{ text }"> |
||||
<div> |
||||
{{ text }} |
||||
</div> |
||||
</template> |
||||
</a-table-column> |
||||
|
||||
<!-- Role --> |
||||
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles"> |
||||
<template #default="{ record }"> |
||||
<div> |
||||
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div> |
||||
<a-select |
||||
v-else |
||||
v-model:value="record.roles" |
||||
class="w-[220px] nc-user-roles" |
||||
:dropdown-match-select-width="false" |
||||
@change="updateRole(record.id, record.roles)" |
||||
> |
||||
<a-select-option |
||||
class="nc-users-list-role-option" |
||||
:value="Role.OrgLevelCreator" |
||||
:label="$t(`objects.roleType.orgLevelCreator`)" |
||||
> |
||||
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div> |
||||
<span class="text-gray-500 text-xs whitespace-normal"> |
||||
{{ $t('msg.info.roles.orgCreator') }} |
||||
</span> |
||||
</a-select-option> |
||||
|
||||
<a-select-option |
||||
class="nc-users-list-role-option" |
||||
:value="Role.OrgLevelViewer" |
||||
:label="$t(`objects.roleType.orgLevelViewer`)" |
||||
> |
||||
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div> |
||||
<span class="text-gray-500 text-xs whitespace-normal"> |
||||
{{ $t('msg.info.roles.orgViewer') }} |
||||
</span> |
||||
</a-select-option> |
||||
</a-select> |
||||
</div> |
||||
</template> |
||||
</a-table-column> |
||||
|
||||
<!-- <!– Projects –> |
||||
<a-table-column key="projectsCount" :title="$t('objects.projects')" data-index="projectsCount"> |
||||
<template #default="{ text }"> |
||||
<div> |
||||
{{ text }} |
||||
</div> |
||||
</template> |
||||
</a-table-column> --> |
||||
|
||||
<!-- Actions --> |
||||
|
||||
<a-table-column key="id" :title="$t('labels.actions')" data-index="id"> |
||||
<template #default="{ text, record }"> |
||||
<div v-if="!record.roles.includes('super')" class="flex items-center gap-2"> |
||||
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt"> |
||||
<div class="flex flex-row items-center"> |
||||
<a-button type="text" class="!px-0"> |
||||
<div class="flex flex-row items-center h-[1.2rem]"> |
||||
<MdiDotsHorizontal class="nc-user-row-action" /> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
|
||||
<template #overlay> |
||||
<a-menu> |
||||
<template v-if="record.invite_token"> |
||||
<a-menu-item> |
||||
<!-- Resend invite Email --> |
||||
<div class="flex flex-row items-center py-3" @click="resendInvite(record)"> |
||||
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" /> |
||||
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div> |
||||
</div> |
||||
</a-menu-item> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(record)"> |
||||
<MdiContentCopy class="flex h-[1rem] text-gray-500" /> |
||||
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</template> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-3" @click="copyPasswordResetUrl(record)"> |
||||
<MdiContentCopy class="flex h-[1rem] text-gray-500" /> |
||||
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div> |
||||
</div> |
||||
</a-menu-item> |
||||
<a-menu-item> |
||||
<div class="flex flex-row items-center py-3" @click="deleteUser(text)"> |
||||
<MdiDeleteOutline data-testid="nc-super-user-delete" class="flex h-[1rem] text-gray-500" /> |
||||
<div class="text-xs pl-2">{{ $t('general.delete') }}</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
</a-dropdown> |
||||
</div> |
||||
<span v-else></span> |
||||
</template> |
||||
</a-table-column> |
||||
</a-table> |
||||
|
||||
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" /> |
||||
</div> |
||||
</div> |
||||
</template> |
@ -0,0 +1,256 @@
|
||||
<script setup lang="ts"> |
||||
import type { UserType } from 'nocodb-sdk' |
||||
import { |
||||
Form, |
||||
computed, |
||||
extractSdkResponseErrorMsg, |
||||
message, |
||||
ref, |
||||
useCopy, |
||||
useDashboard, |
||||
useI18n, |
||||
useNuxtApp, |
||||
validateEmail, |
||||
} from '#imports' |
||||
import type { User } from '~/lib' |
||||
import { Role } from '~/lib' |
||||
|
||||
interface Props { |
||||
show: boolean |
||||
selectedUser?: User |
||||
} |
||||
|
||||
interface Users { |
||||
emails: string |
||||
role: Role.OrgLevelCreator | Role.OrgLevelViewer |
||||
invitationToken?: string |
||||
} |
||||
|
||||
const { show } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['closed', 'reload']) |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { $api, $e } = useNuxtApp() |
||||
|
||||
const { copy } = useCopy() |
||||
|
||||
const { dashboardUrl } = $(useDashboard()) |
||||
|
||||
const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitationToken: undefined }) |
||||
|
||||
const formRef = ref() |
||||
|
||||
const useForm = Form.useForm |
||||
const validators = computed(() => { |
||||
return { |
||||
emails: [ |
||||
{ |
||||
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => { |
||||
if (!value || value.length === 0) { |
||||
callback('Email is required') |
||||
return |
||||
} |
||||
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e)) |
||||
if (invalidEmails.length > 0) { |
||||
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `) |
||||
} else { |
||||
callback() |
||||
} |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
}) |
||||
|
||||
const { validateInfos } = useForm(usersData, validators) |
||||
|
||||
const saveUser = async () => { |
||||
$e('a:org-user:invite', { role: usersData.role }) |
||||
|
||||
await formRef.value?.validateFields() |
||||
|
||||
try { |
||||
// todo: update sdk(swagger.json) |
||||
const res = await $api.orgUsers.add({ |
||||
roles: usersData.role, |
||||
email: usersData.emails, |
||||
} as unknown as UserType) |
||||
|
||||
usersData.invitationToken = res.invite_token |
||||
emit('reload') |
||||
|
||||
// Successfully updated the user details |
||||
message.success(t('msg.success.userAdded')) |
||||
} catch (e: any) { |
||||
console.error(e) |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
|
||||
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}#/signup/${usersData.invitationToken}` : null)) |
||||
|
||||
const copyUrl = async () => { |
||||
if (!inviteUrl) return |
||||
|
||||
await copy(inviteUrl) |
||||
|
||||
// Copied shareable base url to clipboard! |
||||
message.success(t('msg.success.shareableURLCopied')) |
||||
|
||||
$e('c:shared-base:copy-url') |
||||
} |
||||
|
||||
const clickInviteMore = () => { |
||||
$e('c:user:invite-more') |
||||
usersData.invitationToken = undefined |
||||
usersData.role = Role.OrgLevelViewer |
||||
usersData.emails = '' |
||||
} |
||||
const emailInput = ref((el) => { |
||||
el?.focus() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal |
||||
:footer="null" |
||||
centered |
||||
:visible="show" |
||||
:closable="false" |
||||
width="max(50vw, 44rem)" |
||||
wrap-class-name="nc-modal-invite-user" |
||||
@cancel="emit('closed')" |
||||
> |
||||
<div class="flex flex-col"> |
||||
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full"> |
||||
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title> |
||||
|
||||
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')"> |
||||
<template #icon> |
||||
<MaterialSymbolsCloseRounded data-testid="nc-root-user-invite-modal-close" class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
|
||||
<div class="px-2 mt-1.5"> |
||||
<template v-if="usersData.invitationToken"> |
||||
<div class="flex flex-col mt-1 border-b-1 pb-5"> |
||||
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]"> |
||||
<MdiAccountOutline /> |
||||
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div> |
||||
</div> |
||||
|
||||
<a-alert class="mt-1" type="success" show-icon> |
||||
<template #message> |
||||
<div class="flex flex-row justify-between items-center py-1"> |
||||
<div class="flex pl-2 text-green-700 text-xs"> |
||||
{{ inviteUrl }} |
||||
</div> |
||||
|
||||
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl"> |
||||
<template #icon> |
||||
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
</template> |
||||
</a-alert> |
||||
|
||||
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2"> |
||||
{{ $t('msg.info.userInviteNoSMTP') }} |
||||
{{ usersData.invitationToken && usersData.emails }} |
||||
</div> |
||||
|
||||
<div class="flex flex-row justify-start mt-4 ml-2"> |
||||
<a-button size="small" outlined @click="clickInviteMore"> |
||||
<div class="flex flex-row justify-center items-center space-x-0.5"> |
||||
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" /> |
||||
|
||||
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<div v-else class="flex flex-col pb-4"> |
||||
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]"> |
||||
<MdiAccountOutline /> |
||||
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div> |
||||
</div> |
||||
|
||||
<div class="border-1 py-3 px-4 rounded-md mt-1"> |
||||
<a-form |
||||
ref="formRef" |
||||
:validate-on-rule-change="false" |
||||
:model="usersData" |
||||
validate-trigger="onBlur" |
||||
@finish="saveUser" |
||||
> |
||||
<div class="flex flex-row space-x-4"> |
||||
<div class="flex flex-col w-3/4"> |
||||
<a-form-item |
||||
v-bind="validateInfos.emails" |
||||
validate-trigger="onBlur" |
||||
name="emails" |
||||
:rules="[{ required: true, message: 'Please input email' }]" |
||||
> |
||||
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div> |
||||
|
||||
<a-input |
||||
:ref="emailInput" |
||||
v-model:value="usersData.emails" |
||||
validate-trigger="onBlur" |
||||
:placeholder="$t('labels.email')" |
||||
/> |
||||
</a-form-item> |
||||
</div> |
||||
|
||||
<div class="flex flex-col w-2/4"> |
||||
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]"> |
||||
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div> |
||||
|
||||
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role"> |
||||
<a-select-option |
||||
class="nc-role-option" |
||||
:value="Role.OrgLevelCreator" |
||||
:label="$t(`objects.roleType.orgLevelCreator`)" |
||||
> |
||||
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div> |
||||
<span class="text-gray-500 text-xs whitespace-normal"> |
||||
{{ $t('msg.info.roles.orgCreator') }} |
||||
</span> |
||||
</a-select-option> |
||||
|
||||
<a-select-option |
||||
class="nc-role-option" |
||||
:value="Role.OrgLevelViewer" |
||||
:label="$t(`objects.roleType.orgLevelViewer`)" |
||||
> |
||||
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div> |
||||
<span class="text-gray-500 text-xs whitespace-normal"> |
||||
{{ $t('msg.info.roles.orgViewer') }} |
||||
</span> |
||||
</a-select-option> |
||||
</a-select> |
||||
</a-form-item> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="flex flex-row justify-center"> |
||||
<a-button type="primary" html-type="submit"> |
||||
<div class="flex flex-row justify-center items-center space-x-1.5"> |
||||
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" /> |
||||
<div>{{ $t('activity.invite') }}</div> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
</template> |
@ -0,0 +1,132 @@
|
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import type { PropType } from '@vue/runtime-core' |
||||
import { |
||||
ColumnInj, |
||||
computed, |
||||
defineComponent, |
||||
h, |
||||
inject, |
||||
isAttachment, |
||||
isBoolean, |
||||
isCurrency, |
||||
isDate, |
||||
isDateTime, |
||||
isDecimal, |
||||
isDuration, |
||||
isEmail, |
||||
isFloat, |
||||
isInt, |
||||
isJSON, |
||||
isPercent, |
||||
isPhoneNumber, |
||||
isPrimary, |
||||
isRating, |
||||
isSet, |
||||
isSingleSelect, |
||||
isSpecificDBType, |
||||
isString, |
||||
isTextArea, |
||||
isTime, |
||||
isURL, |
||||
isYear, |
||||
toRef, |
||||
useProject, |
||||
} from '#imports' |
||||
import FilePhoneIcon from '~icons/mdi/file-phone' |
||||
import KeyIcon from '~icons/mdi/key-variant' |
||||
import JSONIcon from '~icons/mdi/code-json' |
||||
import ClockIcon from '~icons/mdi/clock-time-five' |
||||
import WebIcon from '~icons/mdi/web' |
||||
import TextAreaIcon from '~icons/mdi/card-text-outline' |
||||
import StringIcon from '~icons/mdi/alpha-a-box-outline' |
||||
import BooleanIcon from '~icons/mdi/check-box-outline' |
||||
import CalendarIcon from '~icons/mdi/calendar' |
||||
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle' |
||||
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square' |
||||
import DatetimeIcon from '~icons/mdi/calendar-clock' |
||||
import RatingIcon from '~icons/mdi/star' |
||||
import GenericIcon from '~icons/mdi/square-rounded' |
||||
import NumericIcon from '~icons/mdi/numeric' |
||||
import AttachmentIcon from '~icons/mdi/image-multiple-outline' |
||||
import EmailIcon from '~icons/mdi/email' |
||||
import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline' |
||||
import PercentIcon from '~icons/mdi/percent-outline' |
||||
import DecimalIcon from '~icons/mdi/decimal' |
||||
import SpecificDBTypeIcon from '~icons/mdi/database-settings' |
||||
import DurationIcon from '~icons/mdi/timer-outline' |
||||
|
||||
const renderIcon = (column: ColumnType, abstractType: any) => { |
||||
if (isPrimary(column)) { |
||||
return KeyIcon |
||||
} else if (isJSON(column)) { |
||||
return JSONIcon |
||||
} else if (isDate(column, abstractType)) { |
||||
return CalendarIcon |
||||
} else if (isDateTime(column, abstractType)) { |
||||
return DatetimeIcon |
||||
} else if (isSet(column)) { |
||||
return MultiSelectIcon |
||||
} else if (isSingleSelect(column)) { |
||||
return SingleSelectIcon |
||||
} else if (isBoolean(column, abstractType)) { |
||||
return BooleanIcon |
||||
} else if (isTextArea(column)) { |
||||
return TextAreaIcon |
||||
} else if (isEmail(column)) { |
||||
return EmailIcon |
||||
} else if (isYear(column, abstractType)) { |
||||
return CalendarIcon |
||||
} else if (isTime(column, abstractType)) { |
||||
return ClockIcon |
||||
} else if (isRating(column)) { |
||||
return RatingIcon |
||||
} else if (isAttachment(column)) { |
||||
return AttachmentIcon |
||||
} else if (isDecimal(column)) { |
||||
return DecimalIcon |
||||
} else if (isPhoneNumber(column)) { |
||||
return FilePhoneIcon |
||||
} else if (isURL(column)) { |
||||
return WebIcon |
||||
} else if (isCurrency(column)) { |
||||
return CurrencyIcon |
||||
} else if (isDuration(column)) { |
||||
return DurationIcon |
||||
} else if (isPercent(column)) { |
||||
return PercentIcon |
||||
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) { |
||||
return NumericIcon |
||||
} else if (isString(column, abstractType)) { |
||||
return StringIcon |
||||
} else if (isSpecificDBType(column)) { |
||||
return SpecificDBTypeIcon |
||||
} else { |
||||
return GenericIcon |
||||
} |
||||
} |
||||
|
||||
export default defineComponent({ |
||||
name: 'CellIcon', |
||||
|
||||
props: { |
||||
columnMeta: { |
||||
type: Object as PropType<ColumnType>, |
||||
required: false, |
||||
}, |
||||
}, |
||||
setup(props) { |
||||
const columnMeta = toRef(props, 'columnMeta') |
||||
|
||||
const column = inject(ColumnInj, columnMeta) |
||||
|
||||
const { sqlUi } = useProject() |
||||
|
||||
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value)) |
||||
|
||||
return () => { |
||||
if (!column.value) return null |
||||
|
||||
return h(renderIcon(column.value, abstractType.value), { class: 'text-grey mx-1 !text-xs' }) |
||||
} |
||||
}, |
||||
}) |
@ -1,89 +0,0 @@
|
||||
<script setup lang="ts"> |
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import type { Ref } from 'vue' |
||||
import { ColumnInj, computed, inject, toRef, useColumn } from '#imports' |
||||
import FilePhoneIcon from '~icons/mdi/file-phone' |
||||
import KeyIcon from '~icons/mdi/key-variant' |
||||
import JSONIcon from '~icons/mdi/code-json' |
||||
import ClockIcon from '~icons/mdi/clock-time-five' |
||||
import WebIcon from '~icons/mdi/web' |
||||
import TextAreaIcon from '~icons/mdi/card-text-outline' |
||||
import StringIcon from '~icons/mdi/alpha-a-box-outline' |
||||
import BooleanIcon from '~icons/mdi/check-box-outline' |
||||
import CalendarIcon from '~icons/mdi/calendar' |
||||
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle' |
||||
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square' |
||||
import DatetimeIcon from '~icons/mdi/calendar-clock' |
||||
import RatingIcon from '~icons/mdi/star' |
||||
import GenericIcon from '~icons/mdi/square-rounded' |
||||
import NumericIcon from '~icons/mdi/numeric' |
||||
import AttachmentIcon from '~icons/mdi/image-multiple-outline' |
||||
import EmailIcon from '~icons/mdi/email' |
||||
import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline' |
||||
import PercentIcon from '~icons/mdi/percent-outline' |
||||
import DecimalIcon from '~icons/mdi/decimal' |
||||
import SpecificDBTypeIcon from '~icons/mdi/database-settings' |
||||
import DurationIcon from '~icons/mdi/timer-outline' |
||||
|
||||
const props = defineProps<{ columnMeta?: ColumnType }>() |
||||
|
||||
const columnMeta = toRef(props, 'columnMeta') |
||||
|
||||
const column = inject(ColumnInj, columnMeta) |
||||
|
||||
const additionalColMeta = useColumn(column as Ref<ColumnType>) |
||||
|
||||
const icon = computed(() => { |
||||
if (column?.value?.pk) { |
||||
return KeyIcon |
||||
} else if (additionalColMeta.isJSON.value) { |
||||
return JSONIcon |
||||
} else if (additionalColMeta.isDate.value) { |
||||
return CalendarIcon |
||||
} else if (additionalColMeta.isDateTime.value) { |
||||
return DatetimeIcon |
||||
} else if (additionalColMeta.isSet.value) { |
||||
return MultiSelectIcon |
||||
} else if (additionalColMeta.isSingleSelect.value) { |
||||
return SingleSelectIcon |
||||
} else if (additionalColMeta.isBoolean.value) { |
||||
return BooleanIcon |
||||
} else if (additionalColMeta.isTextArea.value) { |
||||
return TextAreaIcon |
||||
} else if (additionalColMeta.isEmail.value) { |
||||
return EmailIcon |
||||
} else if (additionalColMeta.isYear.value) { |
||||
return CalendarIcon |
||||
} else if (additionalColMeta.isTime.value) { |
||||
return ClockIcon |
||||
} else if (additionalColMeta.isRating.value) { |
||||
return RatingIcon |
||||
} else if (additionalColMeta.isAttachment.value) { |
||||
return AttachmentIcon |
||||
} else if (additionalColMeta.isDecimal.value) { |
||||
return DecimalIcon |
||||
} else if (additionalColMeta.isPhoneNumber.value) { |
||||
return FilePhoneIcon |
||||
} else if (additionalColMeta.isURL.value) { |
||||
return WebIcon |
||||
} else if (additionalColMeta.isCurrency.value) { |
||||
return CurrencyIcon |
||||
} else if (additionalColMeta.isDuration.value) { |
||||
return DurationIcon |
||||
} else if (additionalColMeta.isPercent.value) { |
||||
return PercentIcon |
||||
} else if (additionalColMeta.isInt.value || additionalColMeta.isFloat.value) { |
||||
return NumericIcon |
||||
} else if (additionalColMeta.isString.value) { |
||||
return StringIcon |
||||
} else if (additionalColMeta.isSpecificDBType.value) { |
||||
return SpecificDBTypeIcon |
||||
} else { |
||||
return GenericIcon |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<component :is="icon" class="text-grey mx-1 !text-xs" /> |
||||
</template> |
@ -1,11 +1,40 @@
|
||||
<script setup lang="ts"> |
||||
import { CellValueInj, inject } from '#imports' |
||||
import { CellValueInj, inject, refAutoReset } from '#imports' |
||||
|
||||
const value = inject(CellValueInj) |
||||
|
||||
const timeout = 3000 // in ms |
||||
|
||||
const showEditWarning = refAutoReset(false, timeout) |
||||
const showClearWarning = refAutoReset(false, timeout) |
||||
|
||||
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => { |
||||
switch (e.key) { |
||||
case 'Enter': |
||||
showEditWarning.value = true |
||||
break |
||||
case 'Delete': |
||||
showClearWarning.value = true |
||||
break |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<span class="text-center pl-3"> |
||||
{{ value }} |
||||
</span> |
||||
<div> |
||||
<span class="text-center pl-3"> |
||||
{{ value }} |
||||
</span> |
||||
|
||||
<div> |
||||
<div v-if="showEditWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs"> |
||||
<!-- TODO: i18n --> |
||||
Warning: Computed field - unable to edit content. |
||||
</div> |
||||
<div v-if="showClearWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs"> |
||||
<!-- TODO: i18n --> |
||||
Warning: Computed field - unable to clear content. |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
@ -1,96 +0,0 @@
|
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import { SqlUiFactory, UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import type { ComputedRef, Ref } from 'vue' |
||||
import { computed, useProject } from '#imports' |
||||
|
||||
export function useColumn(column: Ref<ColumnType | undefined>) { |
||||
const { project } = useProject() |
||||
|
||||
const uiDatatype: ComputedRef<UITypes> = computed(() => column.value?.uidt as UITypes) |
||||
|
||||
const abstractType = computed(() => { |
||||
// kludge: CY test hack; column.value is being received NULL during attach cell delete operation
|
||||
return (column.value && isVirtualCol(column.value)) || !column.value |
||||
? null |
||||
: SqlUiFactory.create( |
||||
project.value?.bases?.[0]?.type ? { client: project.value.bases[0].type } : { client: 'mysql2' }, |
||||
).getAbstractType(column.value) |
||||
}) |
||||
|
||||
const dataTypeLow = computed(() => column.value?.dt?.toLowerCase()) |
||||
const isBoolean = computed(() => abstractType.value === 'boolean') |
||||
const isString = computed(() => uiDatatype.value === UITypes.SingleLineText || abstractType.value === 'string') |
||||
const isTextArea = computed(() => uiDatatype.value === UITypes.LongText) |
||||
const isInt = computed(() => abstractType.value === 'integer') |
||||
const isFloat = computed(() => abstractType.value === 'float' || abstractType.value === UITypes.Number) |
||||
const isDate = computed(() => abstractType.value === 'date' || uiDatatype.value === UITypes.Date) |
||||
const isYear = computed(() => abstractType.value === 'year' || uiDatatype.value === UITypes.Year) |
||||
const isTime = computed(() => abstractType.value === 'time' || uiDatatype.value === UITypes.Time) |
||||
const isDateTime = computed(() => abstractType.value === 'datetime' || uiDatatype.value === UITypes.DateTime) |
||||
const isJSON = computed(() => uiDatatype.value === UITypes.JSON) |
||||
const isEnum = computed(() => uiDatatype.value === UITypes.SingleSelect) |
||||
const isSingleSelect = computed(() => uiDatatype.value === UITypes.SingleSelect) |
||||
const isSet = computed(() => uiDatatype.value === UITypes.MultiSelect) |
||||
const isMultiSelect = computed(() => uiDatatype.value === UITypes.MultiSelect) |
||||
const isURL = computed(() => uiDatatype.value === UITypes.URL) |
||||
const isEmail = computed(() => uiDatatype.value === UITypes.Email) |
||||
const isAttachment = computed(() => uiDatatype.value === UITypes.Attachment) |
||||
const isRating = computed(() => uiDatatype.value === UITypes.Rating) |
||||
const isCurrency = computed(() => uiDatatype.value === UITypes.Currency) |
||||
const isPhoneNumber = computed(() => uiDatatype.value === UITypes.PhoneNumber) |
||||
const isDecimal = computed(() => uiDatatype.value === UITypes.Decimal) |
||||
const isDuration = computed(() => uiDatatype.value === UITypes.Duration) |
||||
const isPercent = computed(() => uiDatatype.value === UITypes.Percent) |
||||
const isSpecificDBType = computed(() => uiDatatype.value === UITypes.SpecificDBType) |
||||
const isAutoSaved = computed(() => |
||||
[ |
||||
UITypes.SingleLineText, |
||||
UITypes.LongText, |
||||
UITypes.PhoneNumber, |
||||
UITypes.Email, |
||||
UITypes.URL, |
||||
UITypes.Number, |
||||
UITypes.Decimal, |
||||
UITypes.Percent, |
||||
UITypes.Count, |
||||
UITypes.AutoNumber, |
||||
UITypes.SpecificDBType, |
||||
UITypes.Geometry, |
||||
UITypes.Duration, |
||||
].includes(uiDatatype.value), |
||||
) |
||||
const isManualSaved = computed(() => [UITypes.Currency].includes(uiDatatype.value)) |
||||
const isPrimary = computed(() => column.value?.pv) |
||||
|
||||
return { |
||||
abstractType, |
||||
dataTypeLow, |
||||
isPrimary, |
||||
isBoolean, |
||||
isString, |
||||
isTextArea, |
||||
isInt, |
||||
isFloat, |
||||
isDate, |
||||
isYear, |
||||
isTime, |
||||
isDateTime, |
||||
isJSON, |
||||
isEnum, |
||||
isSet, |
||||
isURL, |
||||
isEmail, |
||||
isAttachment, |
||||
isRating, |
||||
isCurrency, |
||||
isDecimal, |
||||
isDuration, |
||||
isAutoSaved, |
||||
isManualSaved, |
||||
isSingleSelect, |
||||
isMultiSelect, |
||||
isPercent, |
||||
isPhoneNumber, |
||||
isSpecificDBType, |
||||
} |
||||
} |
@ -0,0 +1,28 @@
|
||||
import { isClient } from '@vueuse/core' |
||||
import type { Ref } from 'vue' |
||||
|
||||
export function useMenuCloseOnEsc(open: Ref<boolean>) { |
||||
const handler = (e: KeyboardEvent) => { |
||||
if (e.key === 'Escape') { |
||||
e.preventDefault() |
||||
e.stopPropagation() |
||||
open.value = false |
||||
} |
||||
} |
||||
if (isClient) { |
||||
watch(open, (nextVal, _, cleanup) => { |
||||
// bind listener when `open` is truthy
|
||||
if (nextVal) { |
||||
document.addEventListener('keydown', handler, true) |
||||
// if `open` is falsy then remove the event handler
|
||||
} else { |
||||
document.removeEventListener('keydown', handler, true) |
||||
} |
||||
|
||||
// cleanup is called whenever the watcher is re-evaluated or stopped
|
||||
cleanup(() => { |
||||
document.removeEventListener('keydown', handler, true) |
||||
}) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,21 @@
|
||||
import { isClient } from '@vueuse/core' |
||||
import type { Ref } from 'vue' |
||||
|
||||
export function useSelectedCellKeyupListener(selected: Ref<boolean>, handler: (e: KeyboardEvent) => void) { |
||||
if (isClient) { |
||||
watch(selected, (nextVal, _, cleanup) => { |
||||
// bind listener when `selected` is truthy
|
||||
if (nextVal) { |
||||
document.addEventListener('keydown', handler, true) |
||||
// if `selected` is falsy then remove the event handler
|
||||
} else { |
||||
document.removeEventListener('keydown', handler, true) |
||||
} |
||||
|
||||
// cleanup is called whenever the watcher is re-evaluated or stopped
|
||||
cleanup(() => { |
||||
document.removeEventListener('keydown', handler, true) |
||||
}) |
||||
}) |
||||
} |
||||
} |