refactor: deprecate legacy packages and clean up build scripts (#5162)
* refactor: deprecate legacy packages and clean up build scripts * chore: remove joi publish workflow * chore: core publish run on dispatch only * chore: correct version bump on web package * chore: make dev for tauri target
15
.github/workflows/publish-npm-core.yml
vendored
@ -1,10 +1,9 @@
|
||||
name: Publish core Package to npmjs
|
||||
on:
|
||||
push:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+-core"]
|
||||
paths: ["core/**", ".github/workflows/publish-npm-core.yml"]
|
||||
pull_request:
|
||||
paths: ["core/**", ".github/workflows/publish-npm-core.yml"]
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+-core']
|
||||
paths: ['core/**', '.github/workflows/publish-npm-core.yml']
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build-and-publish-plugins:
|
||||
environment: production
|
||||
@ -12,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
fetch-depth: '0'
|
||||
token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Install jq
|
||||
@ -24,7 +23,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
|
||||
- name: "Get Semantic Version from tag"
|
||||
- name: 'Get Semantic Version from tag'
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
# Get the tag from the event
|
||||
@ -42,8 +41,8 @@ jobs:
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- run: cd core && corepack enable && corepack prepare yarn@4.5.3 --activate && yarn --version && yarn config set -H enableImmutableInstalls false && yarn install && yarn build
|
||||
|
||||
|
||||
53
.github/workflows/publish-npm-joi.yml
vendored
@ -1,53 +0,0 @@
|
||||
name: Publish joi Package to npmjs
|
||||
on:
|
||||
push:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+-joi"]
|
||||
paths: ["joi/**", ".github/workflows/publish-npm-joi.yml"]
|
||||
pull_request:
|
||||
paths: ["joi/**", ".github/workflows/publish-npm-joi.yml"]
|
||||
jobs:
|
||||
build-and-publish-plugins:
|
||||
environment: production
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Install jq
|
||||
uses: dcarbone/install-jq-action@v2.0.1
|
||||
|
||||
- name: Extract tag name without v prefix
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV && echo "::set-output name=version::${GITHUB_REF#refs/tags/v}"
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
|
||||
- name: "Get Semantic Version from tag"
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
# Get the tag from the event
|
||||
tag=${GITHUB_REF#refs/tags/v}
|
||||
# remove the -joi suffix
|
||||
new_version=$(echo $tag | sed -n 's/-joi//p')
|
||||
echo $new_version
|
||||
# Replace the old version with the new version in package.json
|
||||
jq --arg version "$new_version" '.version = $version' joi/package.json > /tmp/package.json && mv /tmp/package.json joi/package.json
|
||||
|
||||
# Print the new version
|
||||
echo "Updated package.json version to: $new_version"
|
||||
cat joi/package.json
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- run: cd joi && corepack enable && corepack prepare yarn@4.5.3 --activate && yarn --version && yarn config set -H enableImmutableInstalls false && yarn install && yarn build
|
||||
|
||||
- run: cd joi && yarn publish --access public
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@ -119,8 +119,8 @@ jobs:
|
||||
"usr/lib/Jan/binaries/libvulkan.so": "binaries/libvulkan.so"}' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
|
||||
fi
|
||||
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web/package.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web-app/package.json
|
||||
|
||||
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
|
||||
cat ./src-tauri/Cargo.toml
|
||||
|
||||
@ -120,8 +120,8 @@ jobs:
|
||||
# Update tauri.conf.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
|
||||
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web/package.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web-app/package.json
|
||||
|
||||
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
|
||||
cat ./src-tauri/Cargo.toml
|
||||
|
||||
@ -97,8 +97,8 @@ jobs:
|
||||
# Update tauri.conf.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true | .bundle.windows.nsis.template = "tauri.bundle.windows.nsis.template"' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
|
||||
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web/package.json
|
||||
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
|
||||
mv /tmp/package.json web-app/package.json
|
||||
|
||||
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
|
||||
echo "---------Cargo.toml---------"
|
||||
|
||||
97
Makefile
@ -24,88 +24,26 @@ ifeq ($(OS),Windows_NT)
|
||||
echo "skip"
|
||||
endif
|
||||
yarn install
|
||||
yarn build:joi
|
||||
yarn build:core
|
||||
yarn build:extensions
|
||||
|
||||
check-file-counts: install-and-build
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -Command "if ((Get-ChildItem -Path pre-install -Filter *.tgz | Measure-Object | Select-Object -ExpandProperty Count) -ne (Get-ChildItem -Path extensions -Directory | Where-Object Name -like *-extension* | Measure-Object | Select-Object -ExpandProperty Count)) { Write-Host 'Number of .tgz files in pre-install does not match the number of subdirectories in extensions with package.json'; exit 1 } else { Write-Host 'Extension build successful' }"
|
||||
else
|
||||
@tgz_count=$$(find pre-install -type f -name "*.tgz" | wc -l); dir_count=$$(find extensions -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/package.json' \; -print | wc -l); if [ $$tgz_count -ne $$dir_count ]; then echo "Number of .tgz files in pre-install ($$tgz_count) does not match the number of subdirectories in extension ($$dir_count)"; exit 1; else echo "Extension build successful"; fi
|
||||
endif
|
||||
|
||||
dev: check-file-counts
|
||||
yarn dev
|
||||
|
||||
dev-tauri: check-file-counts
|
||||
dev: install-and-build
|
||||
yarn install:cortex
|
||||
yarn download:bin
|
||||
yarn copy:lib
|
||||
CLEAN=true yarn dev:tauri
|
||||
yarn dev
|
||||
|
||||
# Deprecated soon
|
||||
dev-tauri: install-and-build
|
||||
yarn install:cortex
|
||||
yarn download:bin
|
||||
yarn copy:lib
|
||||
yarn dev:tauri
|
||||
|
||||
# Linting
|
||||
lint: check-file-counts
|
||||
lint: install-and-build
|
||||
yarn lint
|
||||
|
||||
update-playwright-config:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
echo -e "const RPconfig = {\n\
|
||||
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
|
||||
endpoint: '$(REPORT_PORTAL_URL)',\n\
|
||||
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
|
||||
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
|
||||
attributes: [\n\
|
||||
{\n\
|
||||
key: 'key',\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
{\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
],\n\
|
||||
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
|
||||
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
|
||||
sed -i "s/^ reporter: .*/ reporter: [['@reportportal\/agent-js-playwright', RPconfig]],/" electron/playwright.config.ts
|
||||
|
||||
else ifeq ($(shell uname -s),Linux)
|
||||
echo "const RPconfig = {\n\
|
||||
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
|
||||
endpoint: '$(REPORT_PORTAL_URL)',\n\
|
||||
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
|
||||
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
|
||||
attributes: [\n\
|
||||
{\n\
|
||||
key: 'key',\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
{\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
],\n\
|
||||
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
|
||||
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
|
||||
sed -i "s/^ reporter: .*/ reporter: [['@reportportal\/agent-js-playwright', RPconfig]],/" electron/playwright.config.ts
|
||||
else
|
||||
echo "const RPconfig = {\n\
|
||||
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
|
||||
endpoint: '$(REPORT_PORTAL_URL)',\n\
|
||||
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
|
||||
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
|
||||
attributes: [\n\
|
||||
{\n\
|
||||
key: 'key',\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
{\n\
|
||||
value: 'value',\n\
|
||||
},\n\
|
||||
],\n\
|
||||
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
|
||||
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
|
||||
sed -i '' "s|^ reporter: .*| reporter: [['@reportportal\/agent-js-playwright', RPconfig]],|" electron/playwright.config.ts
|
||||
endif
|
||||
|
||||
# Testing
|
||||
test: lint
|
||||
# yarn build:test
|
||||
@ -114,16 +52,17 @@ test: lint
|
||||
yarn test
|
||||
|
||||
# Builds and publishes the app
|
||||
build-and-publish: check-file-counts
|
||||
yarn build:publish
|
||||
|
||||
# Build
|
||||
build: check-file-counts
|
||||
build-and-publish: install-and-build
|
||||
yarn build
|
||||
|
||||
build-tauri: check-file-counts
|
||||
# Build
|
||||
build: install-and-build
|
||||
yarn build
|
||||
|
||||
# Deprecated soon
|
||||
build-tauri: install-and-build
|
||||
yarn copy:lib
|
||||
yarn build-tauri
|
||||
yarn build
|
||||
|
||||
clean:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
],
|
||||
rules: {
|
||||
'react/prop-types': 'off', // In favor of strong typing - no need to dedupe
|
||||
'react/no-is-mounted': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
createClass: 'createReactClass', // Regex for Component Factory to use,
|
||||
// default to "createReactClass"
|
||||
pragma: 'React', // Pragma to use, default to "React"
|
||||
version: 'detect', // React version. "detect" automatically picks the version you have installed.
|
||||
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
|
||||
// default to latest and warns if missing
|
||||
// It will default to "detect" in the future
|
||||
},
|
||||
linkComponents: [
|
||||
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
|
||||
'Hyperlink',
|
||||
{ name: 'Link', linkAttribute: 'to' },
|
||||
],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'build',
|
||||
'renderer',
|
||||
'node_modules',
|
||||
'@global',
|
||||
'playwright-report',
|
||||
'test-data',
|
||||
],
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
core: any
|
||||
}
|
||||
}
|
||||
var core: any | undefined
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1,20 +0,0 @@
|
||||
import { Handler, RequestHandler } from '@janhq/core/node'
|
||||
import { ipcMain } from 'electron'
|
||||
import { windowManager } from '../managers/window'
|
||||
|
||||
export function injectHandler() {
|
||||
const ipcWrapper: Handler = (
|
||||
route: string,
|
||||
listener: (...args: any[]) => any
|
||||
) =>
|
||||
ipcMain.handle(route, async (_event, ...args: any[]) => {
|
||||
return listener(...args)
|
||||
})
|
||||
|
||||
const handler = new RequestHandler(
|
||||
ipcWrapper,
|
||||
(channel: string, args: any) =>
|
||||
windowManager.mainWindow?.webContents.send(channel, args)
|
||||
)
|
||||
handler.handle()
|
||||
}
|
||||
@ -1,327 +0,0 @@
|
||||
import { app, ipcMain, dialog, shell, nativeTheme } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { join } from 'path'
|
||||
import { windowManager } from '../managers/window'
|
||||
import {
|
||||
ModuleManager,
|
||||
getJanDataFolderPath,
|
||||
getJanExtensionsPath,
|
||||
init,
|
||||
AppEvent,
|
||||
NativeRoute,
|
||||
SelectFileProp,
|
||||
} from '@janhq/core/node'
|
||||
import { SelectFileOption } from '@janhq/core'
|
||||
import { menu } from '../utils/menu'
|
||||
import { migrate } from '../utils/migration'
|
||||
import { createUserSpace } from '../utils/path'
|
||||
import { setupExtensions } from '../utils/extension'
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
export function handleAppIPCs() {
|
||||
/**
|
||||
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
|
||||
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
|
||||
* @param _event - The IPC event object.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.openAppDirectory, async (_event) => {
|
||||
shell.openPath(getJanDataFolderPath())
|
||||
})
|
||||
|
||||
ipcMain.handle(NativeRoute.appUpdateDownload, async (_event) => {
|
||||
autoUpdater.downloadUpdate()
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light".
|
||||
* This will change the appearance of the app to the light theme.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.setNativeThemeLight, () => {
|
||||
nativeTheme.themeSource = 'light'
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "setCloseApp" IPC message by closing the main application window.
|
||||
* This effectively closes the application if no other windows are open.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.setCloseApp, () => {
|
||||
windowManager.mainWindow?.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "setMinimizeApp" IPC message by minimizing the main application window.
|
||||
* The window will be minimized to the system's taskbar or dock.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.setMinimizeApp, () => {
|
||||
windowManager.mainWindow?.minimize()
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "setMaximizeApp" IPC message. It toggles the maximization state of the main window.
|
||||
* If the window is currently maximized, it will be un-maximized (restored to its previous size).
|
||||
* If the window is not maximized, it will be maximized to fill the screen.
|
||||
* @param _event - The IPC event object.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.setMaximizeApp, async (_event) => {
|
||||
if (windowManager.mainWindow?.isMaximized()) {
|
||||
windowManager.mainWindow.unmaximize()
|
||||
} else {
|
||||
windowManager.mainWindow?.maximize()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark".
|
||||
* This will change the appearance of the app to the dark theme.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.setNativeThemeDark, () => {
|
||||
nativeTheme.themeSource = 'dark'
|
||||
})
|
||||
|
||||
/**
|
||||
* Opens a URL in the user's default browser.
|
||||
* @param _event - The IPC event object.
|
||||
* @param url - The URL to open.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.openExternalUrl, async (_event, url) => {
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
/**
|
||||
* Opens a URL in the user's default browser.
|
||||
* @param _event - The IPC event object.
|
||||
* @param url - The URL to open.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.openFileExplore, async (_event, url) => {
|
||||
shell.openPath(url)
|
||||
})
|
||||
|
||||
/**
|
||||
* Relaunches the app in production - reload window in development.
|
||||
* @param _event - The IPC event object.
|
||||
* @param url - The URL to reload.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.relaunch, async (_event) => {
|
||||
ModuleManager.instance.clearImportedModules()
|
||||
|
||||
if (app.isPackaged) {
|
||||
app.relaunch()
|
||||
app.exit()
|
||||
} else {
|
||||
for (const modulePath in ModuleManager.instance.requiredModules) {
|
||||
delete require.cache[
|
||||
require.resolve(join(getJanExtensionsPath(), modulePath))
|
||||
]
|
||||
}
|
||||
init({
|
||||
// Function to check from the main process that user wants to install a extension
|
||||
confirmInstall: async (_extensions: string[]) => {
|
||||
return true
|
||||
},
|
||||
// Path to install extension to
|
||||
extensionsPath: getJanExtensionsPath(),
|
||||
})
|
||||
windowManager.mainWindow?.reload()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "selectDirectory" IPC message to open a dialog for selecting a directory.
|
||||
* If no main window is found, logs an error and exits.
|
||||
* @returns {string} The path of the selected directory, or nothing if canceled.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.selectDirectory, async () => {
|
||||
const mainWindow = windowManager.mainWindow
|
||||
if (!mainWindow) {
|
||||
console.error('No main window found')
|
||||
return
|
||||
}
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Select a folder',
|
||||
buttonLabel: 'Select Folder',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
if (canceled) {
|
||||
return
|
||||
} else {
|
||||
return filePaths[0]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "selectFiles" IPC message to open a dialog for selecting files.
|
||||
* Allows options for setting the dialog title, button label, and selection properties.
|
||||
* Logs an error if no main window is found.
|
||||
* @param _event - The IPC event object.
|
||||
* @param option - Options for customizing file selection dialog.
|
||||
* @returns {string[]} An array of selected file paths, or nothing if canceled.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.selectFiles,
|
||||
async (_event, option?: SelectFileOption) => {
|
||||
const mainWindow = windowManager.mainWindow
|
||||
if (!mainWindow) {
|
||||
console.error('No main window found')
|
||||
return
|
||||
}
|
||||
|
||||
const title = option?.title ?? 'Select files'
|
||||
const buttonLabel = option?.buttonLabel ?? 'Select'
|
||||
const props: SelectFileProp[] = ['openFile']
|
||||
|
||||
if (option?.allowMultiple) {
|
||||
props.push('multiSelections')
|
||||
}
|
||||
|
||||
if (option?.selectDirectory) {
|
||||
props.push('openDirectory')
|
||||
}
|
||||
console.debug(`Select files with props: ${props}`)
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||
title,
|
||||
buttonLabel,
|
||||
properties: props,
|
||||
filters: option?.filters,
|
||||
})
|
||||
|
||||
if (canceled) return
|
||||
|
||||
return filePaths
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "hideQuickAskWindow" IPC message to hide the quick ask window.
|
||||
* @returns A promise that resolves when the window is hidden.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.hideQuickAskWindow,
|
||||
async (): Promise<void> => windowManager.hideQuickAskWindow()
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "sendQuickAskInput" IPC message to send user input to the main window.
|
||||
* @param _event - The IPC event object.
|
||||
* @param input - User input string to be sent.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.sendQuickAskInput,
|
||||
async (_event, input: string): Promise<void> => {
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onUserSubmitQuickAsk,
|
||||
input
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "showOpenMenu" IPC message to show the context menu at given coordinates.
|
||||
* Only applicable on non-Mac platforms.
|
||||
* @param e - The event object.
|
||||
* @param args - Contains coordinates where the menu should appear.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.showOpenMenu, function (e, args) {
|
||||
if (!isMac && windowManager.mainWindow) {
|
||||
menu.popup({
|
||||
window: windowManager.mainWindow,
|
||||
x: args.x,
|
||||
y: args.y,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "hideMainWindow" IPC message to hide the main application window.
|
||||
* @returns A promise that resolves when the window is hidden.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.hideMainWindow,
|
||||
async (): Promise<void> => windowManager.hideMainWindow()
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "showMainWindow" IPC message to show the main application window.
|
||||
* @returns A promise that resolves when the window is shown.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.showMainWindow,
|
||||
async (): Promise<void> => windowManager.showMainWindow()
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "quickAskSizeUpdated" IPC message to update the size of the quick ask window.
|
||||
* Resizes window by the given height offset.
|
||||
* @param _event - The IPC event object.
|
||||
* @param heightOffset - The amount of height to increase.
|
||||
* @returns A promise that resolves when the window is resized.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.quickAskSizeUpdated,
|
||||
async (_event, heightOffset: number): Promise<void> =>
|
||||
windowManager.expandQuickAskWindow(heightOffset)
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "ackDeepLink" IPC message to acknowledge a deep link.
|
||||
* Triggers handling of deep link in the application.
|
||||
* @param _event - The IPC event object.
|
||||
* @returns A promise that resolves when the deep link is acknowledged.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.ackDeepLink, async (_event): Promise<void> => {
|
||||
windowManager.ackDeepLink()
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "factoryReset" IPC message to reset the application to its initial state.
|
||||
* Clears loaded modules, recreates user space, runs migrations, and sets up extensions.
|
||||
* @param _event - The IPC event object.
|
||||
* @returns A promise that resolves after the reset operations are complete.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.factoryReset, async (_event): Promise<void> => {
|
||||
ModuleManager.instance.clearImportedModules()
|
||||
return createUserSpace().then(migrate).then(setupExtensions)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "startServer" IPC message to start the Jan API server.
|
||||
* Initializes and starts server with provided configuration options.
|
||||
* @param _event - The IPC event object.
|
||||
* @param args - Configuration object containing host, port, CORS settings etc.
|
||||
* @returns Promise that resolves when server starts successfully
|
||||
*/
|
||||
ipcMain.handle(
|
||||
NativeRoute.startServer,
|
||||
async (_event, args): Promise<void> => {
|
||||
const { startServer } = require('@janhq/server')
|
||||
return startServer({
|
||||
host: args?.host,
|
||||
port: args?.port,
|
||||
isCorsEnabled: args?.isCorsEnabled,
|
||||
isVerboseEnabled: args?.isVerboseEnabled,
|
||||
prefix: args?.prefix,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the "stopServer" IPC message to stop the Jan API server.
|
||||
* Gracefully shuts down the server instance.
|
||||
* @param _event - The IPC event object
|
||||
* @returns Promise that resolves when server stops successfully
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.stopServer, async (_event): Promise<void> => {
|
||||
/**
|
||||
* Stop Jan API Server.
|
||||
*/
|
||||
const { stopServer } = require('@janhq/server')
|
||||
return stopServer()
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "appToken" IPC message to generate a random app ID.
|
||||
*/
|
||||
ipcMain.handle(NativeRoute.appToken, async (_event): Promise<string> => {
|
||||
return process.env.appToken ?? 'cortex.cpp'
|
||||
})
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { windowManager } from './../managers/window'
|
||||
import {
|
||||
ProgressInfo,
|
||||
UpdateDownloadedEvent,
|
||||
UpdateInfo,
|
||||
autoUpdater,
|
||||
} from 'electron-updater'
|
||||
import { AppEvent } from '@janhq/core/node'
|
||||
import { trayManager } from '../managers/tray'
|
||||
|
||||
export let waitingToInstallVersion: string | undefined = undefined
|
||||
|
||||
export function handleAppUpdates() {
|
||||
/* Should not check for update during development */
|
||||
if (!app.isPackaged) {
|
||||
return
|
||||
}
|
||||
|
||||
/* New Update Available */
|
||||
autoUpdater.on('update-available', async (_info: UpdateInfo) => {
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onAppUpdateAvailable,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
/* App Update Completion Message */
|
||||
autoUpdater.on('update-downloaded', async (_info: UpdateDownloadedEvent) => {
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onAppUpdateDownloadSuccess,
|
||||
{}
|
||||
)
|
||||
const action = await dialog.showMessageBox({
|
||||
message: `Update downloaded. Please restart the application to apply the updates.`,
|
||||
buttons: ['Restart', 'Later'],
|
||||
})
|
||||
if (action.response === 0) {
|
||||
trayManager.destroyCurrentTray()
|
||||
windowManager.closeQuickAskWindow()
|
||||
waitingToInstallVersion = _info?.version
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
})
|
||||
|
||||
/* App Update Error */
|
||||
autoUpdater.on('error', (info: Error) => {
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onAppUpdateDownloadError,
|
||||
{ failedToInstallVersion: waitingToInstallVersion, info }
|
||||
)
|
||||
})
|
||||
|
||||
/* App Update Progress */
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
|
||||
console.debug('app update progress: ', progress.percent)
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onAppUpdateDownloadUpdate,
|
||||
{
|
||||
...progress,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
if (process.env.CI !== 'e2e') {
|
||||
autoUpdater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 49 KiB |
@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/tests'],
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
},
|
||||
runner: './testRunner.js',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
diagnostics: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
161
electron/main.ts
@ -1,161 +0,0 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
|
||||
import { join, resolve } from 'path'
|
||||
/**
|
||||
* Managers
|
||||
**/
|
||||
import { windowManager } from './managers/window'
|
||||
import { getAppConfigurations, log } from '@janhq/core/node'
|
||||
|
||||
/**
|
||||
* IPC Handlers
|
||||
**/
|
||||
import { injectHandler } from './handlers/common'
|
||||
import { handleAppUpdates } from './handlers/update'
|
||||
import { handleAppIPCs } from './handlers/native'
|
||||
|
||||
/**
|
||||
* Utils
|
||||
**/
|
||||
import { setupMenu } from './utils/menu'
|
||||
import { createUserSpace } from './utils/path'
|
||||
import { migrate } from './utils/migration'
|
||||
import { cleanUpAndQuit } from './utils/clean'
|
||||
import { setupExtensions } from './utils/extension'
|
||||
import { setupCore } from './utils/setup'
|
||||
import { setupReactDevTool } from './utils/dev'
|
||||
|
||||
import { trayManager } from './managers/tray'
|
||||
import { logSystemInfo } from './utils/system'
|
||||
import { registerGlobalShortcuts } from './utils/shortcut'
|
||||
import { registerLogger } from './utils/logger'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const preloadPath = join(__dirname, 'preload.js')
|
||||
const preloadQuickAskPath = join(__dirname, 'preload.quickask.js')
|
||||
const rendererPath = join(__dirname, '..', 'renderer')
|
||||
const quickAskPath = join(rendererPath, 'search.html')
|
||||
const mainPath = join(rendererPath, 'index.html')
|
||||
|
||||
const mainUrl = 'http://localhost:3000'
|
||||
const quickAskUrl = `${mainUrl}/search`
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient('jan', process.execPath, [
|
||||
resolve(process.argv[1]),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('jan')
|
||||
}
|
||||
|
||||
const createMainWindow = () => {
|
||||
const startUrl = app.isPackaged ? `file://${mainPath}` : mainUrl
|
||||
windowManager.createMainWindow(preloadPath, startUrl)
|
||||
}
|
||||
|
||||
// Generate a random token for the app
|
||||
// This token is used for authentication when making request to cortex.cpp server
|
||||
process.env.appToken = randomBytes(16).toString('hex')
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
throw new Error('Another instance of the app is already running')
|
||||
} else {
|
||||
app.on(
|
||||
'second-instance',
|
||||
(_event, commandLine, _workingDirectory): void => {
|
||||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||||
// this is for handling deeplink on windows and linux
|
||||
// since those OS will emit second-instance instead of open-url
|
||||
const url = commandLine.pop()
|
||||
if (url) {
|
||||
windowManager.sendMainAppDeepLink(url)
|
||||
}
|
||||
}
|
||||
windowManager.showMainWindow()
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
.then(setupCore)
|
||||
.then(createUserSpace)
|
||||
.then(registerLogger)
|
||||
.then(migrate)
|
||||
.then(setupExtensions)
|
||||
.then(setupMenu)
|
||||
.then(handleIPCs)
|
||||
.then(() => process.env.CI !== 'e2e' && createQuickAskWindow())
|
||||
.then(createMainWindow)
|
||||
.then(handleAppUpdates)
|
||||
.then(registerGlobalShortcuts)
|
||||
.then(() => {
|
||||
if (!app.isPackaged) {
|
||||
setupReactDevTool()
|
||||
windowManager.mainWindow?.webContents.openDevTools()
|
||||
}
|
||||
})
|
||||
.then(() => process.env.CI !== 'e2e' && trayManager.createSystemTray())
|
||||
.then(logSystemInfo)
|
||||
.then(() => {
|
||||
app.on('activate', () => {
|
||||
if (!BrowserWindow.getAllWindows().length) {
|
||||
createMainWindow()
|
||||
} else {
|
||||
windowManager.showMainWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('open-url', (_event, url) => {
|
||||
windowManager.sendMainAppDeepLink(url)
|
||||
})
|
||||
|
||||
app.on('before-quit', function (_event) {
|
||||
trayManager.destroyCurrentTray()
|
||||
})
|
||||
|
||||
app.once('quit', () => {
|
||||
cleanUpAndQuit()
|
||||
})
|
||||
|
||||
app.once('window-all-closed', () => {
|
||||
// Feature Toggle for Quick Ask
|
||||
if (
|
||||
getAppConfigurations().quick_ask &&
|
||||
!windowManager.isQuickAskWindowDestroyed()
|
||||
)
|
||||
return
|
||||
cleanUpAndQuit()
|
||||
})
|
||||
|
||||
function createQuickAskWindow() {
|
||||
// Feature Toggle for Quick Ask
|
||||
if (!getAppConfigurations().quick_ask) return
|
||||
const startUrl = app.isPackaged ? `file://${quickAskPath}` : quickAskUrl
|
||||
windowManager.createQuickAskWindow(preloadQuickAskPath, startUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles various IPC messages from the renderer process.
|
||||
*/
|
||||
function handleIPCs() {
|
||||
// Inject core handlers for IPCs
|
||||
injectHandler()
|
||||
|
||||
// Handle native IPCs
|
||||
handleAppIPCs()
|
||||
}
|
||||
|
||||
/*
|
||||
** Suppress Node error messages
|
||||
*/
|
||||
process.on('uncaughtException', function (err) {
|
||||
log(`Error: ${err}`)
|
||||
})
|
||||
@ -1,21 +0,0 @@
|
||||
const DEFAULT_MIN_WIDTH = 400
|
||||
const DEFAULT_MIN_HEIGHT = 600
|
||||
|
||||
export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
skipTaskbar: false,
|
||||
minWidth: DEFAULT_MIN_WIDTH,
|
||||
minHeight: DEFAULT_MIN_HEIGHT,
|
||||
show: true,
|
||||
// we want to go frameless on windows and linux
|
||||
transparent: process.platform === 'darwin',
|
||||
frame: process.platform === 'darwin',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
vibrancy: 'fullscreen-ui',
|
||||
visualEffectState: 'active',
|
||||
backgroundMaterial: 'acrylic',
|
||||
autoHideMenuBar: true,
|
||||
trafficLightPosition: {
|
||||
x: 16,
|
||||
y: 10,
|
||||
},
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
const DEFAULT_WIDTH = 556
|
||||
|
||||
const DEFAULT_HEIGHT = 60
|
||||
|
||||
export const quickAskWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
skipTaskbar: true,
|
||||
acceptFirstMouse: true,
|
||||
hasShadow: true,
|
||||
alwaysOnTop: true,
|
||||
show: false,
|
||||
fullscreenable: false,
|
||||
resizable: false,
|
||||
center: true,
|
||||
movable: true,
|
||||
maximizable: false,
|
||||
focusable: true,
|
||||
transparent: false,
|
||||
frame: false,
|
||||
type: 'panel',
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import { join } from 'path'
|
||||
import { Tray, app, Menu } from 'electron'
|
||||
import { windowManager } from '../managers/window'
|
||||
import { getAppConfigurations } from '@janhq/core/node'
|
||||
|
||||
class TrayManager {
|
||||
currentTray: Tray | undefined
|
||||
|
||||
createSystemTray = () => {
|
||||
// Feature Toggle for Quick Ask
|
||||
if (!getAppConfigurations().quick_ask) return
|
||||
|
||||
if (this.currentTray) {
|
||||
return
|
||||
}
|
||||
const iconPath = join(app.getAppPath(), 'icons', 'icon-tray.png')
|
||||
const tray = new Tray(iconPath)
|
||||
tray.setToolTip(app.getName())
|
||||
|
||||
tray.on('click', () => {
|
||||
windowManager.showQuickAskWindow()
|
||||
})
|
||||
|
||||
// Add context menu for windows only
|
||||
if (process.platform === 'win32') {
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Jan',
|
||||
type: 'normal',
|
||||
click: () => windowManager.showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: 'Open Quick Ask',
|
||||
type: 'normal',
|
||||
click: () => windowManager.showQuickAskWindow(),
|
||||
},
|
||||
{ label: 'Quit', type: 'normal', click: () => app.quit() },
|
||||
])
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
}
|
||||
this.currentTray = tray
|
||||
}
|
||||
|
||||
destroyCurrentTray() {
|
||||
this.currentTray?.destroy()
|
||||
this.currentTray = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const trayManager = new TrayManager()
|
||||
@ -1,215 +0,0 @@
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
import { quickAskWindowConfig } from './quickAskWindowConfig'
|
||||
import { mainWindowConfig } from './mainWindowConfig'
|
||||
import { getAppConfigurations, AppEvent } from '@janhq/core/node'
|
||||
import { getBounds, saveBounds } from '../utils/setup'
|
||||
|
||||
/**
|
||||
* Manages the current window instance.
|
||||
*/
|
||||
// TODO: refactor this
|
||||
let isAppQuitting = false
|
||||
|
||||
class WindowManager {
|
||||
public mainWindow?: BrowserWindow
|
||||
private _quickAskWindow: BrowserWindow | undefined = undefined
|
||||
private _quickAskWindowVisible = false
|
||||
private _mainWindowVisible = false
|
||||
|
||||
private deeplink: string | undefined
|
||||
/**
|
||||
* Creates a new window instance.
|
||||
* @returns The created window instance.
|
||||
*/
|
||||
async createMainWindow(preloadPath: string, startUrl: string) {
|
||||
const bounds = await getBounds()
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
...mainWindowConfig,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
show: false,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
preload: preloadPath,
|
||||
webSecurity: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||||
/// This is work around for windows deeplink.
|
||||
/// second-instance event is not fired when app is not open, so the app
|
||||
/// does not received the deeplink.
|
||||
const commandLine = process.argv.slice(1)
|
||||
if (commandLine.length > 0) {
|
||||
const url = commandLine[0]
|
||||
this.sendMainAppDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
this.mainWindow.on('resized', () => {
|
||||
saveBounds(this.mainWindow?.getBounds())
|
||||
})
|
||||
|
||||
this.mainWindow.on('moved', () => {
|
||||
saveBounds(this.mainWindow?.getBounds())
|
||||
})
|
||||
|
||||
/* Load frontend app to the window */
|
||||
this.mainWindow.loadURL(startUrl)
|
||||
|
||||
/* Open external links in the default browser */
|
||||
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
app.on('before-quit', function () {
|
||||
isAppQuitting = true
|
||||
})
|
||||
|
||||
windowManager.mainWindow?.on('close', function (evt) {
|
||||
// Feature Toggle for Quick Ask
|
||||
if (!getAppConfigurations().quick_ask) return
|
||||
|
||||
if (!isAppQuitting) {
|
||||
evt.preventDefault()
|
||||
windowManager.hideMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
windowManager.mainWindow?.on('ready-to-show', function () {
|
||||
windowManager.mainWindow?.show()
|
||||
})
|
||||
}
|
||||
|
||||
createQuickAskWindow(preloadPath: string, startUrl: string): void {
|
||||
this._quickAskWindow = new BrowserWindow({
|
||||
...quickAskWindowConfig,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
preload: preloadPath,
|
||||
webSecurity: false,
|
||||
},
|
||||
})
|
||||
|
||||
this._quickAskWindow.loadURL(startUrl)
|
||||
this._quickAskWindow.on('blur', () => {
|
||||
this.hideQuickAskWindow()
|
||||
})
|
||||
}
|
||||
|
||||
isMainWindowVisible(): boolean {
|
||||
return this._mainWindowVisible
|
||||
}
|
||||
|
||||
hideMainWindow(): void {
|
||||
this.mainWindow?.hide()
|
||||
this._mainWindowVisible = false
|
||||
}
|
||||
|
||||
showMainWindow(): void {
|
||||
this.mainWindow?.show()
|
||||
this._mainWindowVisible = true
|
||||
}
|
||||
|
||||
hideQuickAskWindow(): void {
|
||||
this._quickAskWindow?.hide()
|
||||
this._quickAskWindowVisible = false
|
||||
}
|
||||
|
||||
showQuickAskWindow(): void {
|
||||
this._quickAskWindow?.show()
|
||||
this._quickAskWindowVisible = true
|
||||
}
|
||||
|
||||
closeQuickAskWindow(): void {
|
||||
if (this._quickAskWindow?.isDestroyed()) return
|
||||
this._quickAskWindow?.close()
|
||||
this._quickAskWindow?.destroy()
|
||||
this._quickAskWindow = undefined
|
||||
this._quickAskWindowVisible = false
|
||||
}
|
||||
|
||||
isQuickAskWindowVisible(): boolean {
|
||||
return this._quickAskWindowVisible
|
||||
}
|
||||
|
||||
isQuickAskWindowDestroyed(): boolean {
|
||||
return this._quickAskWindow?.isDestroyed() ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the quick ask window
|
||||
*/
|
||||
expandQuickAskWindow(heightOffset: number): void {
|
||||
const width = quickAskWindowConfig.width!
|
||||
const height = quickAskWindowConfig.height! + heightOffset
|
||||
this._quickAskWindow?.setMinimumSize(width, height)
|
||||
this._quickAskWindow?.setSize(width, height, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the selected text to the quick ask window.
|
||||
*/
|
||||
sendQuickAskSelectedText(selectedText: string): void {
|
||||
this._quickAskWindow?.webContents.send(
|
||||
AppEvent.onSelectedText,
|
||||
selectedText
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to send the deep link to the main app.
|
||||
*/
|
||||
sendMainAppDeepLink(url: string): void {
|
||||
this.deeplink = url
|
||||
const interval = setInterval(() => {
|
||||
if (!this.deeplink) clearInterval(interval)
|
||||
const mainWindow = this.mainWindow
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(AppEvent.onDeepLink, this.deeplink)
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send main view state to the main app.
|
||||
*/
|
||||
sendMainViewState(route: string) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all windows.
|
||||
*/
|
||||
cleanUp(): void {
|
||||
if (!this.mainWindow?.isDestroyed()) {
|
||||
this.mainWindow?.close()
|
||||
this.mainWindow?.destroy()
|
||||
this.mainWindow = undefined
|
||||
this._mainWindowVisible = false
|
||||
}
|
||||
if (!this._quickAskWindow?.isDestroyed()) {
|
||||
this._quickAskWindow?.close()
|
||||
this._quickAskWindow?.destroy()
|
||||
this._quickAskWindow = undefined
|
||||
this._quickAskWindowVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledges that the window has received a deep link. We can remove it.
|
||||
*/
|
||||
ackDeepLink() {
|
||||
this.deeplink = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const windowManager = new WindowManager()
|
||||
@ -1,29 +0,0 @@
|
||||
const yaml = require('js-yaml')
|
||||
const fs = require('fs')
|
||||
|
||||
// get two file paths from arguments:
|
||||
const [, , ...args] = process.argv
|
||||
const file1 = args[0]
|
||||
const file2 = args[1]
|
||||
const file3 = args[2]
|
||||
|
||||
// check that all arguments are present and throw error instead
|
||||
if (!file1 || !file2 || !file3) {
|
||||
throw new Error(
|
||||
'Please provide 3 file paths as arguments: path to file1, to file2 and destination path'
|
||||
)
|
||||
}
|
||||
|
||||
const doc1 = yaml.load(fs.readFileSync(file1, 'utf8'))
|
||||
console.log('doc1: ', doc1)
|
||||
|
||||
const doc2 = yaml.load(fs.readFileSync(file2, 'utf8'))
|
||||
console.log('doc2: ', doc2)
|
||||
|
||||
const merged = { ...doc1, ...doc2 }
|
||||
merged.files.push(...doc1.files)
|
||||
|
||||
console.log('merged', merged)
|
||||
|
||||
const mergedYml = yaml.dump(merged)
|
||||
fs.writeFileSync(file3, mergedYml, 'utf8')
|
||||
@ -1,146 +0,0 @@
|
||||
{
|
||||
"name": "jan",
|
||||
"version": "0.1.1740752217",
|
||||
"main": "./build/main.js",
|
||||
"author": "Jan <service@jan.ai>",
|
||||
"license": "MIT",
|
||||
"productName": "Jan",
|
||||
"homepage": "https://github.com/menloresearch/jan/tree/main/electron",
|
||||
"description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.",
|
||||
"build": {
|
||||
"appId": "jan.ai.app",
|
||||
"productName": "Jan",
|
||||
"files": [
|
||||
"renderer/**/*",
|
||||
"build/**/*.{js,map}",
|
||||
"pre-install",
|
||||
"themes",
|
||||
"scripts/**/*",
|
||||
"icons/**/*",
|
||||
"themes",
|
||||
"shared"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"pre-install",
|
||||
"themes",
|
||||
"docs",
|
||||
"scripts",
|
||||
"icons",
|
||||
"themes",
|
||||
"shared"
|
||||
],
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "janhq",
|
||||
"repo": "jan"
|
||||
}
|
||||
],
|
||||
"extends": null,
|
||||
"mac": {
|
||||
"type": "distribution",
|
||||
"entitlements": "./entitlements.mac.plist",
|
||||
"entitlementsInherit": "./entitlements.mac.plist",
|
||||
"notarize": {
|
||||
"teamId": "F8AH6NHVY5"
|
||||
},
|
||||
"icon": "icons/icon.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"icon": "icons/"
|
||||
},
|
||||
"win": {
|
||||
"icon": "icons/icon.png",
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": true,
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"uninstallerIcon": "icons/icon.ico",
|
||||
"include": "scripts/uninstaller.nsh",
|
||||
"deleteAppDataOnUninstall": true
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Jan",
|
||||
"schemes": [
|
||||
"jan"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "jan-${os}-${arch}-${version}.${ext}"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"",
|
||||
"test:e2e": "DEBUG=pw:browser xvfb-maybe -- playwright test --workers=1",
|
||||
"copy:assets": "rimraf --glob \"./pre-install/*.tgz\" && cpx \"../pre-install/*.tgz\" \"./pre-install\"",
|
||||
"version-patch": "run-script-os",
|
||||
"version-patch:darwin:linux": "jq '.version' package.json | tr -d '\"' > .version.bak && jq --arg ver \"0.1.$(date +%s)\" '.version = $ver' package.json > package.tmp && mv package.tmp package.json",
|
||||
"version-patch:win32": "node -e \"const fs=require('fs');const pkg=require('./package.json');const bak=pkg.version;fs.writeFileSync('.version.bak',bak);pkg.version='0.1.'+Math.floor(Date.now()/1000);fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\"",
|
||||
"version-restore": "run-script-os",
|
||||
"version-restore:darwin:linux": "jq --arg ver $(cat .version.bak) '.version = $ver' package.json > package.tmp && mv package.tmp package.json && rm .version.bak",
|
||||
"version-restore:win32": "node -e \"const fs=require('fs');const pkg=require('./package.json');const bak=fs.readFileSync('.version.bak','utf8');pkg.version=bak;fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\"",
|
||||
"dev:darwin:linux": "yarn copy:assets && tsc -p . && yarn version-patch && electron . && yarn version-restore",
|
||||
"dev:windows": "yarn copy:assets && tsc -p . && electron .",
|
||||
"dev": "run-script-os",
|
||||
"compile": "tsc -p .",
|
||||
"start": "electron .",
|
||||
"build": "yarn copy:assets && run-script-os",
|
||||
"build:test": "yarn copy:assets && run-script-os",
|
||||
"build:test:darwin": "tsc -p . && electron-builder -p never -m --dir",
|
||||
"build:test:win32": "tsc -p . && electron-builder -p never -w --dir",
|
||||
"build:test:linux": "tsc -p . && electron-builder -p never -l --dir",
|
||||
"build:darwin": "tsc -p . && electron-builder -p never -m --universal",
|
||||
"build:win32": "tsc -p . && electron-builder -p never -w",
|
||||
"build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage",
|
||||
"build:publish": "yarn copy:assets && run-script-os",
|
||||
"build:publish:darwin": "tsc -p . && electron-builder -p always -m --universal",
|
||||
"build:publish:win32": "tsc -p . && electron-builder -p always -w",
|
||||
"build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alumna/reflect": "^1.1.3",
|
||||
"@janhq/core": "link:../core",
|
||||
"@janhq/server": "link:../server",
|
||||
"@kirillvakalov/nut-tree__nut-js": "4.2.1-2",
|
||||
"@npmcli/arborist": "^7.1.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^6.1.7",
|
||||
"fs-extra": "^11.2.0",
|
||||
"pacote": "^21.0.0",
|
||||
"request": "^2.88.2",
|
||||
"request-progress": "^3.0.0",
|
||||
"ulidx": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@playwright/test": "^1.38.1",
|
||||
"@reportportal/agent-js-playwright": "^5.1.7",
|
||||
"@types/npmcli__arborist": "^5.6.4",
|
||||
"@types/pacote": "^11.1.7",
|
||||
"@types/request": "^2.48.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||
"@typescript-eslint/parser": "^6.7.3",
|
||||
"electron": "30.0.6",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-builder-squirrel-windows": "^24.13.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-playwright-helpers": "^1.6.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"run-script-os": "^1.1.6",
|
||||
"typescript": "^5.3.3",
|
||||
"xvfb-maybe": "^0.2.1"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test'
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests/e2e',
|
||||
retries: 0,
|
||||
globalTimeout: 350000,
|
||||
use: {
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
// reporter: [['html', { outputFolder: './playwright-report' }]],
|
||||
}
|
||||
export default config
|
||||
@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Exposes a set of APIs to the renderer process via the contextBridge object.
|
||||
* @module preload
|
||||
*/
|
||||
|
||||
import { APIEvents, APIRoutes } from '@janhq/core/node'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const interfaces: { [key: string]: (...args: any[]) => any } = {}
|
||||
|
||||
// Loop over each route in APIRoutes
|
||||
APIRoutes.forEach((method) => {
|
||||
// For each method, create a function on the interfaces object
|
||||
// This function invokes the method on the ipcRenderer with any provided arguments
|
||||
|
||||
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
|
||||
})
|
||||
|
||||
// Loop over each method in APIEvents
|
||||
APIEvents.forEach((method) => {
|
||||
// For each method, create a function on the interfaces object
|
||||
// This function sets up an event listener on the ipcRenderer for the method
|
||||
// The handler for the event is provided as an argument to the function
|
||||
interfaces[method] = (handler: any) => ipcRenderer.on(method, handler)
|
||||
})
|
||||
|
||||
// Expose the 'interfaces' object in the main world under the name 'electronAPI'
|
||||
// This allows the renderer process to access these methods directly
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
...interfaces,
|
||||
isQuickAsk: () => true,
|
||||
})
|
||||
@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Exposes a set of APIs to the renderer process via the contextBridge object.
|
||||
* @module preload
|
||||
*/
|
||||
|
||||
import { APIEvents, APIRoutes, AppConfiguration } from '@janhq/core/node'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { readdirSync } from 'fs'
|
||||
|
||||
const interfaces: { [key: string]: (...args: any[]) => any } = {}
|
||||
|
||||
// Loop over each route in APIRoutes
|
||||
APIRoutes.forEach((method) => {
|
||||
// For each method, create a function on the interfaces object
|
||||
// This function invokes the method on the ipcRenderer with any provided arguments
|
||||
|
||||
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
|
||||
})
|
||||
|
||||
// Loop over each method in APIEvents
|
||||
APIEvents.forEach((method) => {
|
||||
// For each method, create a function on the interfaces object
|
||||
// This function sets up an event listener on the ipcRenderer for the method
|
||||
// The handler for the event is provided as an argument to the function
|
||||
interfaces[method] = (handler: any) => ipcRenderer.on(method, handler)
|
||||
})
|
||||
|
||||
interfaces['changeDataFolder'] = async (path) => {
|
||||
const appConfiguration: AppConfiguration = await ipcRenderer.invoke(
|
||||
'getAppConfigurations'
|
||||
)
|
||||
const currentJanDataFolder = appConfiguration.data_folder
|
||||
appConfiguration.data_folder = path
|
||||
const reflect = require('@alumna/reflect')
|
||||
const { err } = await reflect({
|
||||
src: currentJanDataFolder,
|
||||
dest: path,
|
||||
recursive: true,
|
||||
delete: false,
|
||||
overwrite: true,
|
||||
errorOnExist: false,
|
||||
})
|
||||
if (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
await ipcRenderer.invoke('updateAppConfiguration', appConfiguration)
|
||||
}
|
||||
|
||||
interfaces['isDirectoryEmpty'] = async (path) => {
|
||||
const dirChildren = await readdirSync(path)
|
||||
return dirChildren.filter((x) => x !== '.DS_Store').length === 0
|
||||
}
|
||||
|
||||
// Expose the 'interfaces' object in the main world under the name 'electronAPI'
|
||||
// This allows the renderer process to access these methods directly
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
...interfaces,
|
||||
isQuickAsk: () => false,
|
||||
})
|
||||
@ -1,46 +0,0 @@
|
||||
!include nsDialogs.nsh
|
||||
|
||||
XPStyle on
|
||||
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
; Define the process name of your Electron app
|
||||
StrCpy $0 "Jan.exe"
|
||||
|
||||
; Check if the application is running
|
||||
nsExec::ExecToStack 'tasklist /FI "IMAGENAME eq $0" /NH'
|
||||
Pop $1
|
||||
|
||||
StrCmp $1 "" notRunning
|
||||
|
||||
; If the app is running, notify the user and attempt to close it
|
||||
MessageBox MB_OK "Jan is being uninstalled, force close app." IDOK forceClose
|
||||
|
||||
forceClose:
|
||||
; Attempt to kill the running application
|
||||
nsExec::ExecToStack 'taskkill /F /IM $0'
|
||||
Pop $1
|
||||
|
||||
; Proceed with uninstallation
|
||||
Goto continueUninstall
|
||||
|
||||
notRunning:
|
||||
; If the app is not running, proceed with uninstallation
|
||||
Goto continueUninstall
|
||||
|
||||
continueUninstall:
|
||||
; Proceed with uninstallation
|
||||
DeleteRegKey HKLM "Software\Jan"
|
||||
RMDir /r "$INSTDIR"
|
||||
Delete "$INSTDIR\*.*"
|
||||
|
||||
; Clean up shortcuts and app data
|
||||
Delete "$DESKTOP\Jan.lnk"
|
||||
Delete "$STARTMENU\Programs\Jan.lnk"
|
||||
RMDir /r "$APPDATA\Jan"
|
||||
RMDir /r "$LOCALAPPDATA\jan-updater"
|
||||
|
||||
; Close the uninstaller
|
||||
Quit
|
||||
${endIf}
|
||||
!macroend
|
||||
@ -1,69 +0,0 @@
|
||||
const { exec } = require('child_process')
|
||||
|
||||
function execCommandWithRetry(command, retries = 3) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const execute = (attempt) => {
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`Error: ${error}`)
|
||||
if (attempt < retries) {
|
||||
console.log(`Retrying... Attempt ${attempt + 1}`)
|
||||
execute(attempt + 1)
|
||||
} else {
|
||||
return reject(error)
|
||||
}
|
||||
} else {
|
||||
console.log(`stdout: ${stdout}`)
|
||||
console.error(`stderr: ${stderr}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
execute(0)
|
||||
})
|
||||
}
|
||||
|
||||
function sign({
|
||||
path,
|
||||
name,
|
||||
certUrl,
|
||||
clientId,
|
||||
tenantId,
|
||||
clientSecret,
|
||||
certName,
|
||||
timestampServer,
|
||||
version,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = `azuresigntool.exe sign -kvu "${certUrl}" -kvi "${clientId}" -kvt "${tenantId}" -kvs "${clientSecret}" -kvc "${certName}" -tr "${timestampServer}" -v "${path}"`
|
||||
execCommandWithRetry(command)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
exports.default = async function (options) {
|
||||
const certUrl = process.env.AZURE_KEY_VAULT_URI
|
||||
const clientId = process.env.AZURE_CLIENT_ID
|
||||
const tenantId = process.env.AZURE_TENANT_ID
|
||||
const clientSecret = process.env.AZURE_CLIENT_SECRET
|
||||
const certName = process.env.AZURE_CERT_NAME
|
||||
const timestampServer = 'http://timestamp.globalsign.com/tsa/r6advanced1'
|
||||
|
||||
try {
|
||||
await sign({
|
||||
path: options.path,
|
||||
name: 'jan-win-x64',
|
||||
certUrl,
|
||||
clientId,
|
||||
tenantId,
|
||||
clientSecret,
|
||||
certName,
|
||||
timestampServer,
|
||||
version: options.version,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to sign after 3 attempts:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
const jestRunner = require('jest-runner');
|
||||
|
||||
class EmptyTestFileRunner extends jestRunner.default {
|
||||
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
|
||||
const nonEmptyTests = tests.filter(test => test.context.hasteFS.getSize(test.path) > 0);
|
||||
return super.runTests(nonEmptyTests, watcher, onStart, onResult, onFailure, options);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EmptyTestFileRunner;
|
||||
@ -1,4 +0,0 @@
|
||||
export const Constants = {
|
||||
VIDEO_DIR: './playwright-video',
|
||||
TIMEOUT: '300000',
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
import {
|
||||
_electron as electron,
|
||||
BrowserContext,
|
||||
ElectronApplication,
|
||||
expect,
|
||||
Page,
|
||||
test as base,
|
||||
} from '@playwright/test'
|
||||
import {
|
||||
ElectronAppInfo,
|
||||
findLatestBuild,
|
||||
parseElectronApp,
|
||||
stubDialog,
|
||||
} from 'electron-playwright-helpers'
|
||||
import { Constants } from './constants'
|
||||
import { HubPage } from '../pages/hubPage'
|
||||
import { CommonActions } from '../pages/commonActions'
|
||||
import { rmSync } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
export let electronApp: ElectronApplication
|
||||
export let page: Page
|
||||
export let appInfo: ElectronAppInfo
|
||||
export const TIMEOUT = parseInt(process.env.TEST_TIMEOUT || Constants.TIMEOUT)
|
||||
|
||||
export async function setupElectron() {
|
||||
console.log(`TEST TIMEOUT: ${TIMEOUT}`)
|
||||
|
||||
process.env.CI = 'e2e'
|
||||
|
||||
const latestBuild = findLatestBuild('dist')
|
||||
expect(latestBuild).toBeTruthy()
|
||||
|
||||
// parse the packaged Electron app and find paths and other info
|
||||
appInfo = parseElectronApp(latestBuild)
|
||||
expect(appInfo).toBeTruthy()
|
||||
|
||||
electronApp = await electron.launch({
|
||||
args: [appInfo.main, '--no-sandbox'], // main file from package.json
|
||||
executablePath: appInfo.executable, // path to the Electron executable
|
||||
// recordVideo: { dir: Constants.VIDEO_DIR }, // Specify the directory for video recordings
|
||||
})
|
||||
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
|
||||
|
||||
page = await electronApp.firstWindow({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
export async function teardownElectron() {
|
||||
await page.close()
|
||||
await electronApp.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* this fixture is needed to record and attach videos / screenshot on failed tests when
|
||||
* tests are run in serial mode (i.e. browser is not closed between tests)
|
||||
*/
|
||||
export const test = base.extend<
|
||||
{
|
||||
commonActions: CommonActions
|
||||
hubPage: HubPage
|
||||
attachVideoPage: Page
|
||||
attachScreenshotsToReport: void
|
||||
},
|
||||
{ createVideoContext: BrowserContext }
|
||||
>({
|
||||
commonActions: async ({ request }, use, testInfo) => {
|
||||
await use(new CommonActions(page, testInfo))
|
||||
},
|
||||
hubPage: async ({ commonActions }, use) => {
|
||||
await use(new HubPage(page, commonActions))
|
||||
},
|
||||
createVideoContext: [
|
||||
async ({ playwright }, use) => {
|
||||
const context = electronApp.context()
|
||||
await use(context)
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
attachVideoPage: [
|
||||
async ({ createVideoContext }, use, testInfo) => {
|
||||
await use(page)
|
||||
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
const path = await createVideoContext.pages()[0].video()?.path()
|
||||
await createVideoContext.close()
|
||||
await testInfo.attach('video', {
|
||||
path: path,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ scope: 'test', auto: true },
|
||||
],
|
||||
|
||||
attachScreenshotsToReport: [
|
||||
async ({ commonActions }, use, testInfo) => {
|
||||
await use()
|
||||
|
||||
// After the test, we can check whether the test passed or failed.
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await commonActions.takeScreenshot('')
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
})
|
||||
|
||||
test.beforeAll(async () => {
|
||||
rmSync(path.join(__dirname, '../../test-data'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
|
||||
test.setTimeout(TIMEOUT)
|
||||
await setupElectron()
|
||||
await page.waitForSelector('img[alt="Jan - Logo"]', {
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// teardownElectron()
|
||||
})
|
||||
@ -1,25 +0,0 @@
|
||||
import { test, appInfo, page, TIMEOUT } from '../config/fixtures'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.beforeAll(async () => {
|
||||
expect(appInfo).toMatchObject({
|
||||
asar: true,
|
||||
executable: expect.anything(),
|
||||
main: expect.anything(),
|
||||
name: 'jan',
|
||||
packageJson: expect.objectContaining({ name: 'jan' }),
|
||||
platform: process.platform,
|
||||
resourcesDir: expect.anything(),
|
||||
})
|
||||
})
|
||||
|
||||
test('explores hub', async ({ hubPage }) => {
|
||||
await hubPage.navigateByMenu()
|
||||
await hubPage.verifyContainerVisible()
|
||||
await hubPage.scrollToBottom()
|
||||
const useModelBtn = page.getByTestId(/^setup-btn/).first()
|
||||
|
||||
await expect(useModelBtn).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
@ -1,15 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { page, test, TIMEOUT } from '../config/fixtures'
|
||||
|
||||
test('renders left navigation panel', async () => {
|
||||
const threadBtn = page.getByTestId('Thread').first()
|
||||
await expect(threadBtn).toBeVisible({ timeout: TIMEOUT })
|
||||
// Chat section should be there
|
||||
await page.getByTestId('Local API Server').first().click({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
const localServer = page.getByTestId('local-server-testid').first()
|
||||
await expect(localServer).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
@ -1,11 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test, page, TIMEOUT } from '../config/fixtures'
|
||||
|
||||
test('shows settings', async () => {
|
||||
await page.getByTestId('Settings').first().click({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
const settingDescription = page.getByTestId('testid-setting-description')
|
||||
await expect(settingDescription).toBeVisible({ timeout: TIMEOUT })
|
||||
})
|
||||
@ -1,18 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { page, test, TIMEOUT } from '../config/fixtures'
|
||||
|
||||
test('show onboarding screen without any threads created or models downloaded', async () => {
|
||||
await page.getByTestId('Thread').first().click({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
|
||||
|
||||
if ((await denyButton.count()) > 0) {
|
||||
await denyButton.click({ force: true })
|
||||
}
|
||||
|
||||
const onboardScreen = page.getByTestId('onboard-screen')
|
||||
await expect(onboardScreen).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
@ -1,59 +0,0 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { CommonActions } from './commonActions'
|
||||
import { TIMEOUT } from '../config/fixtures'
|
||||
|
||||
export class BasePage {
|
||||
menuId: string
|
||||
|
||||
constructor(
|
||||
protected readonly page: Page,
|
||||
readonly action: CommonActions,
|
||||
protected containerId: string
|
||||
) {}
|
||||
|
||||
public getValue(key: string) {
|
||||
return this.action.getValue(key)
|
||||
}
|
||||
|
||||
public setValue(key: string, value: string) {
|
||||
this.action.setValue(key, value)
|
||||
}
|
||||
|
||||
async takeScreenshot(name: string = '') {
|
||||
await this.action.takeScreenshot(name)
|
||||
}
|
||||
|
||||
async navigateByMenu() {
|
||||
await this.clickFirstElement(this.menuId)
|
||||
}
|
||||
|
||||
async clickFirstElement(testId: string) {
|
||||
await this.page.getByTestId(testId).first().click()
|
||||
}
|
||||
|
||||
async verifyContainerVisible() {
|
||||
const container = this.page.getByTestId(this.containerId)
|
||||
expect(container.isVisible()).toBeTruthy()
|
||||
}
|
||||
|
||||
async scrollToBottom() {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight)
|
||||
})
|
||||
}
|
||||
|
||||
async waitUpdateLoader() {
|
||||
await this.isElementVisible('img[alt="Jan - Logo"]')
|
||||
}
|
||||
|
||||
//wait and find a specific element with its selector and return Visible
|
||||
async isElementVisible(selector: any) {
|
||||
let isVisible = true
|
||||
await this.page
|
||||
.waitForSelector(selector, { state: 'visible', timeout: TIMEOUT })
|
||||
.catch(() => {
|
||||
isVisible = false
|
||||
})
|
||||
return isVisible
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import { Page, TestInfo } from '@playwright/test'
|
||||
import { page } from '../config/fixtures'
|
||||
|
||||
export class CommonActions {
|
||||
private testData = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
public page: Page,
|
||||
public testInfo: TestInfo
|
||||
) {}
|
||||
|
||||
async takeScreenshot(name: string) {
|
||||
const screenshot = await page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const attachmentName = `${this.testInfo.title}_${name || new Date().toISOString().slice(5, 19).replace(/[-:]/g, '').replace('T', '_')}`
|
||||
await this.testInfo.attach(attachmentName.replace(/\s+/g, ''), {
|
||||
body: screenshot,
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
|
||||
async hooks() {
|
||||
console.log('hook from the scenario page')
|
||||
}
|
||||
|
||||
setValue(key: string, value: string) {
|
||||
this.testData.set(key, value)
|
||||
}
|
||||
|
||||
getValue(key: string) {
|
||||
return this.testData.get(key)
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { Page } from '@playwright/test'
|
||||
import { BasePage } from './basePage'
|
||||
import { CommonActions } from './commonActions'
|
||||
|
||||
export class HubPage extends BasePage {
|
||||
readonly menuId: string = 'Hub'
|
||||
static readonly containerId: string = 'hub-container-test-id'
|
||||
|
||||
constructor(
|
||||
public page: Page,
|
||||
readonly action: CommonActions
|
||||
) {
|
||||
super(page, action, HubPage.containerId)
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"noImplicitAny": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"outDir": "./build",
|
||||
"rootDir": "./",
|
||||
"noEmitOnError": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": { "*": ["node_modules/*"] },
|
||||
"typeRoots": ["node_modules/@types"]
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["core", "build", "dist", "tests", "node_modules", "test-data"]
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ModuleManager } from '@janhq/core/node'
|
||||
import { windowManager } from './../managers/window'
|
||||
import { dispose } from './disposable'
|
||||
import { app } from 'electron'
|
||||
|
||||
export function cleanUpAndQuit() {
|
||||
if (!ModuleManager.instance.cleaningResource) {
|
||||
ModuleManager.instance.cleaningResource = true
|
||||
windowManager.cleanUp()
|
||||
dispose(ModuleManager.instance.requiredModules)
|
||||
ModuleManager.instance.clearImportedModules()
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
export const setupReactDevTool = async () => {
|
||||
// Which means you're running from source code
|
||||
const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import(
|
||||
'electron-devtools-installer'
|
||||
) // Don't use import on top level, since the installer package is dev-only
|
||||
try {
|
||||
const name = await installExtension(REACT_DEVELOPER_TOOLS)
|
||||
console.debug(`Added Extension: ${name}`)
|
||||
} catch (err) {
|
||||
console.error('An error occurred while installing devtools:', err)
|
||||
// Only log the error and don't throw it because it's not critical
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
export function dispose(requiredModules: Record<string, any>) {
|
||||
for (const key in requiredModules) {
|
||||
const module = requiredModules[key]
|
||||
if (typeof module['dispose'] === 'function') {
|
||||
module['dispose']()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { getJanExtensionsPath, init } from '@janhq/core/node'
|
||||
|
||||
export const setupExtensions = async () => {
|
||||
init({
|
||||
// Function to check from the main process that user wants to install a extension
|
||||
confirmInstall: async (_extensions: string[]) => {
|
||||
return true
|
||||
},
|
||||
// Path to install extension to
|
||||
extensionsPath: getJanExtensionsPath(),
|
||||
})
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdir,
|
||||
stat,
|
||||
unlink,
|
||||
writeFileSync,
|
||||
} from 'fs'
|
||||
import util from 'util'
|
||||
import {
|
||||
getAppConfigurations,
|
||||
getJanDataFolderPath,
|
||||
Logger,
|
||||
LoggerManager,
|
||||
} from '@janhq/core/node'
|
||||
import path, { join } from 'path'
|
||||
|
||||
/**
|
||||
* File Logger
|
||||
*/
|
||||
export class FileLogger implements Logger {
|
||||
name = 'file'
|
||||
logCleaningInterval: number = 120000
|
||||
timeout: NodeJS.Timeout | undefined
|
||||
appLogPath: string = './'
|
||||
logEnabled: boolean = true
|
||||
|
||||
constructor(
|
||||
logEnabled: boolean = true,
|
||||
logCleaningInterval: number = 120000
|
||||
) {
|
||||
this.logEnabled = logEnabled
|
||||
if (logCleaningInterval) this.logCleaningInterval = logCleaningInterval
|
||||
|
||||
const appConfigurations = getAppConfigurations()
|
||||
const logFolderPath = join(appConfigurations.data_folder, 'logs')
|
||||
if (!existsSync(logFolderPath)) {
|
||||
mkdirSync(logFolderPath, { recursive: true })
|
||||
}
|
||||
|
||||
this.appLogPath = join(logFolderPath, 'app.log')
|
||||
}
|
||||
|
||||
log(args: any) {
|
||||
if (!this.logEnabled) return
|
||||
let message = args[0]
|
||||
const scope = args[1]
|
||||
if (!message) return
|
||||
const path = this.appLogPath
|
||||
if (!scope && !message.startsWith('[')) {
|
||||
message = `[APP]::${message}`
|
||||
} else if (scope) {
|
||||
message = `${scope}::${message}`
|
||||
}
|
||||
|
||||
message = `${new Date().toISOString()} ${message}`
|
||||
|
||||
writeLog(message, path)
|
||||
}
|
||||
|
||||
cleanLogs(
|
||||
maxFileSizeBytes?: number | undefined,
|
||||
daysToKeep?: number | undefined
|
||||
): void {
|
||||
// clear existing timeout
|
||||
// in case we rerun it with different values
|
||||
if (this.timeout) clearTimeout(this.timeout)
|
||||
this.timeout = undefined
|
||||
|
||||
if (!this.logEnabled) return
|
||||
|
||||
console.log(
|
||||
'Validating app logs. Next attempt in ',
|
||||
this.logCleaningInterval
|
||||
)
|
||||
|
||||
const size = maxFileSizeBytes ?? 1 * 1024 * 1024 // 1 MB
|
||||
const days = daysToKeep ?? 7 // 7 days
|
||||
const logDirectory = path.join(getJanDataFolderPath(), 'logs')
|
||||
// Perform log cleaning
|
||||
const currentDate = new Date()
|
||||
if (existsSync(logDirectory))
|
||||
readdir(logDirectory, (err, files) => {
|
||||
if (err) {
|
||||
console.error('Error reading log directory:', err)
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(logDirectory, file)
|
||||
stat(filePath, (err, stats) => {
|
||||
if (err) {
|
||||
console.error('Error getting file stats:', err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check size
|
||||
if (stats.size > size) {
|
||||
unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting log file:', err)
|
||||
return
|
||||
}
|
||||
console.debug(
|
||||
`Deleted log file due to exceeding size limit: ${filePath}`
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Check age
|
||||
const creationDate = new Date(stats.ctime)
|
||||
const daysDifference = Math.floor(
|
||||
(currentDate.getTime() - creationDate.getTime()) /
|
||||
(1000 * 3600 * 24)
|
||||
)
|
||||
if (daysDifference > days) {
|
||||
unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting log file:', err)
|
||||
return
|
||||
}
|
||||
console.debug(`Deleted old log file: ${filePath}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Schedule the next execution with doubled delays
|
||||
this.timeout = setTimeout(
|
||||
() => this.cleanLogs(maxFileSizeBytes, daysToKeep),
|
||||
this.logCleaningInterval
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write log function implementation
|
||||
* @param message
|
||||
* @param logPath
|
||||
*/
|
||||
const writeLog = (message: string, logPath: string) => {
|
||||
if (!existsSync(logPath)) {
|
||||
const logDirectory = path.join(getJanDataFolderPath(), 'logs')
|
||||
if (!existsSync(logDirectory)) {
|
||||
mkdirSync(logDirectory)
|
||||
}
|
||||
writeFileSync(logPath, message)
|
||||
} else {
|
||||
const logFile = createWriteStream(logPath, {
|
||||
flags: 'a',
|
||||
})
|
||||
logFile.write(util.format(message) + '\n')
|
||||
logFile.close()
|
||||
console.debug(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register logger for global application logging
|
||||
*/
|
||||
export const registerLogger = () => {
|
||||
const logger = new FileLogger()
|
||||
LoggerManager.instance().register(logger)
|
||||
logger.cleanLogs()
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { app, Menu, shell, dialog } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { log } from '@janhq/core/node'
|
||||
const isMac = process.platform === 'darwin'
|
||||
import { windowManager } from '../managers/window'
|
||||
|
||||
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{
|
||||
label: `About ${app.name}`,
|
||||
click: () =>
|
||||
dialog.showMessageBox({
|
||||
title: `Jan`,
|
||||
message: `Jan Version v${app.getVersion()}\n\nCopyright © 2024 Jan`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () =>
|
||||
// Check for updates and notify user if there are any
|
||||
autoUpdater
|
||||
.checkForUpdatesAndNotify()
|
||||
.then((updateCheckResult) => {
|
||||
if (
|
||||
!updateCheckResult?.updateInfo ||
|
||||
updateCheckResult?.updateInfo.version === app.getVersion()
|
||||
) {
|
||||
windowManager.mainWindow?.webContents.send(
|
||||
AppEvent.onAppUpdateNotAvailable,
|
||||
{}
|
||||
)
|
||||
return
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log('Error checking for updates:' + JSON.stringify(error))
|
||||
}),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{
|
||||
label: `Settings`,
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => {
|
||||
windowManager.showMainWindow()
|
||||
windowManager.sendMainViewState('Settings')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||
},
|
||||
]
|
||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' },
|
||||
{ role: 'front' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'window' },
|
||||
]
|
||||
: [{ role: 'close' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://jan.ai/guides/')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const menu = Menu.buildFromTemplate(template)
|
||||
|
||||
export const setupMenu = () => {
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { join } from 'path'
|
||||
import {
|
||||
rmdirSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
cpSync,
|
||||
lstatSync,
|
||||
} from 'fs'
|
||||
import Store from 'electron-store'
|
||||
import {
|
||||
getJanDataFolderPath,
|
||||
appResourcePath,
|
||||
getJanExtensionsPath,
|
||||
} from '@janhq/core/node'
|
||||
|
||||
/**
|
||||
* Migrates the extensions & themes.
|
||||
* If the `migrated_version` key in the `Store` object does not match the current app version,
|
||||
* the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version.
|
||||
* @returns A Promise that resolves when the migration is complete.
|
||||
*/
|
||||
export async function migrate() {
|
||||
const store = new Store()
|
||||
if (store.get('migrated_version') !== app.getVersion()) {
|
||||
console.debug('start migration:', store.get('migrated_version'))
|
||||
|
||||
if (existsSync(getJanExtensionsPath()))
|
||||
rmdirSync(getJanExtensionsPath(), { recursive: true })
|
||||
|
||||
await migrateThemes()
|
||||
|
||||
store.set('migrated_version', app.getVersion())
|
||||
console.debug('migrate extensions done')
|
||||
} else if (!existsSync(join(getJanDataFolderPath(), 'themes'))) {
|
||||
await migrateThemes()
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateThemes() {
|
||||
if (!existsSync(join(getJanDataFolderPath(), 'themes')))
|
||||
mkdirSync(join(getJanDataFolderPath(), 'themes'), { recursive: true })
|
||||
|
||||
const themes = readdirSync(join(appResourcePath(), 'themes'))
|
||||
for (const theme of themes) {
|
||||
const themePath = join(appResourcePath(), 'themes', theme)
|
||||
await checkAndMigrateTheme(theme, themePath)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndMigrateTheme(
|
||||
sourceThemeName: string,
|
||||
sourceThemePath: string
|
||||
) {
|
||||
const janDataThemesFolder = join(getJanDataFolderPath(), 'themes')
|
||||
const existingTheme = readdirSync(janDataThemesFolder).find(
|
||||
(theme) => theme === sourceThemeName
|
||||
)
|
||||
if (existingTheme) {
|
||||
const desTheme = join(janDataThemesFolder, existingTheme)
|
||||
if (!lstatSync(desTheme).isDirectory()) {
|
||||
return
|
||||
}
|
||||
console.debug('Updating theme', existingTheme)
|
||||
rmdirSync(desTheme, { recursive: true })
|
||||
cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), {
|
||||
recursive: true,
|
||||
})
|
||||
} else {
|
||||
console.debug('Adding new theme', sourceThemeName)
|
||||
cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { mkdir } from 'fs-extra'
|
||||
import { existsSync } from 'fs'
|
||||
import { getJanDataFolderPath } from '@janhq/core/node'
|
||||
|
||||
export async function createUserSpace(): Promise<void> {
|
||||
const janDataFolderPath = getJanDataFolderPath()
|
||||
if (!existsSync(janDataFolderPath)) {
|
||||
try {
|
||||
await mkdir(janDataFolderPath)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Unable to create Jan data folder at ${janDataFolderPath}: ${err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { clipboard, globalShortcut } from 'electron'
|
||||
import { keyboard, Key } from "@kirillvakalov/nut-tree__nut-js"
|
||||
|
||||
/**
|
||||
* Gets selected text by synthesizing the keyboard shortcut
|
||||
* "CommandOrControl+c" then reading text from the clipboard
|
||||
*/
|
||||
export const getSelectedText = async () => {
|
||||
const currentClipboardContent = clipboard.readText() // preserve clipboard content
|
||||
clipboard.clear()
|
||||
const hotkeys: Key[] = [
|
||||
process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
|
||||
Key.C,
|
||||
]
|
||||
await keyboard.pressKey(...hotkeys)
|
||||
await keyboard.releaseKey(...hotkeys)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)) // add a delay before checking clipboard
|
||||
const selectedText = clipboard.readText()
|
||||
clipboard.writeText(currentClipboardContent)
|
||||
return selectedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a global shortcut of `accelerator`. The `callback` is called
|
||||
* with the selected text when the registered shortcut is pressed by the user
|
||||
*
|
||||
* Returns `true` if the shortcut was registered successfully
|
||||
*/
|
||||
export const registerShortcut = (
|
||||
accelerator: Electron.Accelerator,
|
||||
callback: (selectedText: string) => void
|
||||
) => {
|
||||
return globalShortcut.register(accelerator, async () => {
|
||||
callback(await getSelectedText())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a global shortcut of `accelerator` and
|
||||
* is equivalent to electron.globalShortcut.unregister
|
||||
*/
|
||||
export const unregisterShortcut = (accelerator: Electron.Accelerator) => {
|
||||
globalShortcut.unregister(accelerator)
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { app, screen } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
|
||||
const DEFAULT_WIDTH = 1000
|
||||
const DEFAULT_HEIGHT = 800
|
||||
|
||||
const storage = new Store()
|
||||
|
||||
export const setupCore = async () => {
|
||||
// Setup core api for main process
|
||||
global.core = {
|
||||
// Define appPath function for app to retrieve app path globally
|
||||
appPath: () => app.getPath('userData'),
|
||||
}
|
||||
}
|
||||
|
||||
export const getBounds = async () => {
|
||||
const defaultBounds = {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
}
|
||||
|
||||
const bounds = (await storage.get('windowBounds')) as
|
||||
| Electron.Rectangle
|
||||
| undefined
|
||||
|
||||
// If no bounds are saved, use the defaults
|
||||
if (!bounds) {
|
||||
storage.set('windowBounds', defaultBounds)
|
||||
return defaultBounds
|
||||
}
|
||||
|
||||
// Validate that the bounds are on a valid display
|
||||
const displays = screen.getAllDisplays()
|
||||
const isValid = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds
|
||||
return (
|
||||
bounds.x >= x &&
|
||||
bounds.x < x + width &&
|
||||
bounds.y >= y &&
|
||||
bounds.y < y + height
|
||||
)
|
||||
})
|
||||
|
||||
// If the position is valid, return the saved bounds, otherwise return default bounds
|
||||
if (isValid) {
|
||||
return bounds
|
||||
} else {
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const resetBounds = {
|
||||
x: primaryDisplay.bounds.x,
|
||||
y: primaryDisplay.bounds.y,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
}
|
||||
storage.set('windowBounds', resetBounds)
|
||||
return resetBounds
|
||||
}
|
||||
}
|
||||
|
||||
export const saveBounds = (bounds: Electron.Rectangle | undefined) => {
|
||||
storage.set('windowBounds', bounds)
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { getAppConfigurations } from '@janhq/core/node'
|
||||
import { registerShortcut } from './selectedText'
|
||||
import { windowManager } from '../managers/window'
|
||||
// TODO: Retrieve from config later
|
||||
const quickAskHotKey = 'CommandOrControl+J'
|
||||
|
||||
export function registerGlobalShortcuts() {
|
||||
if (!getAppConfigurations().quick_ask) return
|
||||
const ret = registerShortcut(quickAskHotKey, (selectedText: string) => {
|
||||
// Feature Toggle for Quick Ask
|
||||
if (!windowManager.isQuickAskWindowVisible()) {
|
||||
windowManager.showQuickAskWindow()
|
||||
windowManager.sendQuickAskSelectedText(selectedText)
|
||||
} else {
|
||||
windowManager.hideQuickAskWindow()
|
||||
}
|
||||
})
|
||||
|
||||
if (!ret) {
|
||||
console.error('Global shortcut registration failed')
|
||||
} else {
|
||||
console.log('Global shortcut registered successfully')
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { log } from '@janhq/core/node'
|
||||
import { app } from 'electron'
|
||||
import os from 'os'
|
||||
|
||||
export const logSystemInfo = (): void => {
|
||||
log(`[SPECS]::Version: ${app.getVersion()}`)
|
||||
log(`[SPECS]::CPUs: ${JSON.stringify(os.cpus())}`)
|
||||
log(`[SPECS]::Machine: ${os.machine()}`)
|
||||
log(`[SPECS]::Endianness: ${os.endianness()}`)
|
||||
log(`[SPECS]::Parallelism: ${os.availableParallelism()}`)
|
||||
log(`[SPECS]::Free Mem: ${os.freemem()}`)
|
||||
log(`[SPECS]::Total Mem: ${os.totalmem()}`)
|
||||
log(`[SPECS]::OS Version: ${os.version()}`)
|
||||
log(`[SPECS]::OS Platform: ${os.platform()}`)
|
||||
log(`[SPECS]::OS Release: ${os.release()}`)
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "@janhq/model-extension",
|
||||
"productName": "Model Management",
|
||||
"version": "1.0.36",
|
||||
"description": "Handles model list, and settings.",
|
||||
"description": "Manages model operations including listing, importing, updating, and deleting.",
|
||||
"main": "dist/index.js",
|
||||
"author": "Jan <service@jan.ai>",
|
||||
"license": "AGPL-3.0",
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
dist/
|
||||
*.hbs
|
||||
*.mdx
|
||||
*.mjs
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "consistent",
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
# @janhq/joi
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
yarn run dev
|
||||
```
|
||||
@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.*'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jsdom',
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
{
|
||||
"name": "@janhq/joi",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"description": "A collection of UI component",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"design-system"
|
||||
],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/codecentrum/piksel#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/codecentrum/piksel.git"
|
||||
},
|
||||
"bugs": "https://github.com/codecentrum/piksel/issues",
|
||||
"scripts": {
|
||||
"dev": "rollup -c -w",
|
||||
"build": "rimraf ./dist || true && rollup -c",
|
||||
"test": "jest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"react": "^18",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"autoprefixer": "10.4.16",
|
||||
"jest": "^29.7.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "15.2.3",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^19",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-transform-css": "^6.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"rollup": "4.12.0",
|
||||
"rollup-plugin-bundle-size": "1.0.3",
|
||||
"rollup-plugin-commonjs": "10.1.0",
|
||||
"rollup-plugin-copy": "3.5.0",
|
||||
"rollup-plugin-dts": "6.1.0",
|
||||
"rollup-plugin-peer-deps-external": "2.2.4",
|
||||
"rollup-plugin-postcss": "4.0.2",
|
||||
"rollup-plugin-typescript2": "0.36.0",
|
||||
"sass": "^1.83.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import dts from 'rollup-plugin-dts'
|
||||
import terser from '@rollup/plugin-terser'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
import bundleSize from 'rollup-plugin-bundle-size'
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
|
||||
import postcss from 'rollup-plugin-postcss'
|
||||
import typescript from 'rollup-plugin-typescript2'
|
||||
import tailwindcss from 'tailwindcss'
|
||||
import typescriptEngine from 'typescript'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import copy from 'rollup-plugin-copy'
|
||||
|
||||
const packageJson = JSON.parse(readFileSync('./package.json'))
|
||||
|
||||
import tailwindConfig from './tailwind.config.js'
|
||||
|
||||
export default [
|
||||
{
|
||||
input: `./src/index.ts`,
|
||||
output: [
|
||||
{
|
||||
file: packageJson.main,
|
||||
format: 'es',
|
||||
exports: 'named',
|
||||
sourcemap: false,
|
||||
},
|
||||
],
|
||||
external: ['react', 'typescript', 'class-variance-authority'],
|
||||
plugins: [
|
||||
postcss({
|
||||
plugins: [autoprefixer(), tailwindcss(tailwindConfig)],
|
||||
sourceMap: true,
|
||||
use: {
|
||||
sass: {
|
||||
silenceDeprecations: ['legacy-js-api'],
|
||||
api: 'modern',
|
||||
},
|
||||
},
|
||||
minimize: true,
|
||||
extract: 'main.css',
|
||||
}),
|
||||
|
||||
peerDepsExternal({ includeDependencies: true }),
|
||||
commonjs(),
|
||||
resolve(),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
typescript: typescriptEngine,
|
||||
sourceMap: false,
|
||||
exclude: [
|
||||
'docs',
|
||||
'dist',
|
||||
'node_modules/**',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
],
|
||||
}),
|
||||
terser(),
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: 'dist/esm/index.d.ts',
|
||||
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
|
||||
external: [/\.(sc|sa|c)ss$/],
|
||||
plugins: [
|
||||
dts(),
|
||||
peerDepsExternal({ includeDependencies: true }),
|
||||
copy({
|
||||
targets: [{ src: 'dist/esm/main.css', dest: 'dist' }],
|
||||
}),
|
||||
bundleSize(),
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -1,64 +0,0 @@
|
||||
import React from 'react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Accordion, AccordionItem } from './index'
|
||||
|
||||
// Mock the SCSS import
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('Accordion', () => {
|
||||
it('renders accordion with items', () => {
|
||||
render(
|
||||
<Accordion defaultValue={['item1']}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item2" title="Item 2">
|
||||
Content 2
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses accordion items', () => {
|
||||
render(
|
||||
<Accordion defaultValue={[]}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('Item 1')
|
||||
|
||||
// Initially, content should not be visible
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('respects defaultValue prop', () => {
|
||||
render(
|
||||
<Accordion defaultValue={['item2']}>
|
||||
<AccordionItem value="item1" title="Item 1">
|
||||
Content 1
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item2" title="Item 2">
|
||||
Content 2
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Content 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,45 +0,0 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
|
||||
import { ChevronDownIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
type AccordionProps = {
|
||||
defaultValue: string[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type AccordionItemProps = {
|
||||
children: ReactNode
|
||||
value: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const AccordionItem = ({ children, value, title }: AccordionItemProps) => {
|
||||
return (
|
||||
<AccordionPrimitive.Item className="accordion__item" value={value}>
|
||||
<AccordionPrimitive.Header className="accordion__header">
|
||||
<AccordionPrimitive.Trigger className="accordion__trigger">
|
||||
<h6>{title}</h6>
|
||||
<ChevronDownIcon className="accordion__chevron" aria-hidden />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionPrimitive.Content className="accordion__content">
|
||||
<div className="accordion__content--wrapper">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
</AccordionPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const Accordion = ({ defaultValue, children }: AccordionProps) => (
|
||||
<AccordionPrimitive.Root
|
||||
className="accordion"
|
||||
type="multiple"
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
{children}
|
||||
</AccordionPrimitive.Root>
|
||||
)
|
||||
|
||||
export { Accordion, AccordionItem }
|
||||
@ -1,73 +0,0 @@
|
||||
.accordion {
|
||||
border-top: 1px solid hsla(var(--app-border));
|
||||
|
||||
&__item {
|
||||
overflow: hidden;
|
||||
margin-top: 1px;
|
||||
border-bottom: 1px solid hsla(var(--app-border));
|
||||
|
||||
:focus-within {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__trigger {
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__content {
|
||||
overflow: hidden;
|
||||
|
||||
&--wrapper {
|
||||
padding: 4px 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
color: hsla(var(--text-secondary));
|
||||
transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.accordion__content[data-state='open'] {
|
||||
animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
.accordion__content[data-state='closed'] {
|
||||
animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
.accordion__trigger[data-state='open'] > .accordion__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Badge, badgeConfig } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Badge', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Badge>Test Badge</Badge>)
|
||||
const badge = screen.getByText('Test Badge')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('badge')
|
||||
expect(badge).toHaveClass('badge--primary')
|
||||
expect(badge).toHaveClass('badge--medium')
|
||||
expect(badge).toHaveClass('badge--solid')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Badge className="custom-class">Test Badge</Badge>)
|
||||
const badge = screen.getByText('Test Badge')
|
||||
expect(badge).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('renders with different themes', () => {
|
||||
const themes = Object.keys(badgeConfig.variants.theme)
|
||||
themes.forEach((theme) => {
|
||||
render(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${theme}`)
|
||||
expect(badge).toHaveClass(`badge--${theme}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with different variants', () => {
|
||||
const variants = Object.keys(badgeConfig.variants.variant)
|
||||
variants.forEach((variant) => {
|
||||
render(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${variant}`)
|
||||
expect(badge).toHaveClass(`badge--${variant}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const sizes = Object.keys(badgeConfig.variants.size)
|
||||
sizes.forEach((size) => {
|
||||
render(<Badge size={size as any}>Test Badge {size}</Badge>)
|
||||
const badge = screen.getByText(`Test Badge ${size}`)
|
||||
expect(badge).toHaveClass(`badge--${size}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when a new theme is added without updating the test', () => {
|
||||
const expectedThemes = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'warning',
|
||||
'success',
|
||||
'info',
|
||||
'destructive',
|
||||
]
|
||||
const actualThemes = Object.keys(badgeConfig.variants.theme)
|
||||
expect(actualThemes).toEqual(expectedThemes)
|
||||
})
|
||||
|
||||
it('fails when a new variant is added without updating the test', () => {
|
||||
const expectedVariant = ['solid', 'soft', 'outline']
|
||||
const actualVariants = Object.keys(badgeConfig.variants.variant)
|
||||
expect(actualVariants).toEqual(expectedVariant)
|
||||
})
|
||||
|
||||
it('fails when a new size is added without updating the test', () => {
|
||||
const expectedSizes = ['small', 'medium', 'large']
|
||||
const actualSizes = Object.keys(badgeConfig.variants.size)
|
||||
expect(actualSizes).toEqual(expectedSizes)
|
||||
})
|
||||
|
||||
it('fails when a new variant CVA is added without updating the test', () => {
|
||||
const expectedVariantsCVA = ['theme', 'variant', 'size']
|
||||
const actualVariant = Object.keys(badgeConfig.variants)
|
||||
expect(actualVariant).toEqual(expectedVariantsCVA)
|
||||
})
|
||||
})
|
||||
@ -1,52 +0,0 @@
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
export const badgeConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'badge--primary',
|
||||
secondary: 'badge--secondary',
|
||||
warning: 'badge--warning',
|
||||
success: 'badge--success',
|
||||
info: 'badge--info',
|
||||
destructive: 'badge--destructive',
|
||||
},
|
||||
variant: {
|
||||
solid: 'badge--solid',
|
||||
soft: 'badge--soft',
|
||||
outline: 'badge--outline',
|
||||
},
|
||||
size: {
|
||||
small: 'badge--small',
|
||||
medium: 'badge--medium',
|
||||
large: 'badge--large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const badgeVariants = cva('badge', badgeConfig)
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
const Badge = ({ className, theme, size, variant, ...props }: BadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(badgeVariants({ theme, size, variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@ -1,131 +0,0 @@
|
||||
.badge {
|
||||
@apply inline-flex items-center justify-center px-2 font-medium transition-all;
|
||||
|
||||
// Primary
|
||||
&--primary {
|
||||
color: hsla(var(--primary-fg));
|
||||
background-color: hsla(var(--primary-bg));
|
||||
|
||||
// Variant soft primary
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--primary-bg-soft));
|
||||
color: hsla(var(--primary-bg));
|
||||
}
|
||||
|
||||
// Variant outline primary
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--primary-bg));
|
||||
color: hsla(var(--primary-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary
|
||||
&--secondary {
|
||||
background-color: hsla(var(--secondary-bg));
|
||||
color: hsla(var(--secondary-fg));
|
||||
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--secondary-bg-soft));
|
||||
color: hsla(var(--secondary-bg));
|
||||
}
|
||||
|
||||
// Variant outline secondary
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--secondary-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Destructive
|
||||
&--destructive {
|
||||
color: hsla(var(--destructive-fg));
|
||||
background-color: hsla(var(--destructive-bg));
|
||||
|
||||
// Variant soft destructive
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--destructive-bg-soft));
|
||||
color: hsla(var(--destructive-bg));
|
||||
}
|
||||
|
||||
// Variant outline destructive
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--destructive-bg));
|
||||
color: hsla(var(--destructive-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
&--success {
|
||||
@apply text-white;
|
||||
background-color: hsla(var(--success-bg));
|
||||
|
||||
// Variant soft success
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--success-bg-soft));
|
||||
color: hsla(var(--success-bg));
|
||||
}
|
||||
|
||||
// Variant outline success
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--success-bg));
|
||||
color: hsla(var(--success-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Warning
|
||||
&--warning {
|
||||
@apply text-white;
|
||||
background-color: hsla(var(--warning-bg));
|
||||
|
||||
// Variant soft warning
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--warning-bg-soft));
|
||||
color: hsla(var(--warning-bg));
|
||||
}
|
||||
|
||||
// Variant outline warning
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--warning-bg));
|
||||
color: hsla(var(--warning-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Info
|
||||
&--info {
|
||||
@apply text-white;
|
||||
background-color: hsla(var(--info-bg));
|
||||
|
||||
// Variant soft info
|
||||
&.badge--soft {
|
||||
background-color: hsla(var(--info-bg-soft));
|
||||
color: hsla(var(--info-bg));
|
||||
}
|
||||
|
||||
// Variant outline info
|
||||
&.badge--outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid hsla(var(--info-bg));
|
||||
color: hsla(var(--info-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Size
|
||||
&--small {
|
||||
@apply h-5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
@apply h-6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&--large {
|
||||
@apply h-7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Button, buttonConfig } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Button', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
const button = screen.getByRole('button', { name: /click me/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).toHaveClass('btn btn--primary btn--medium btn--solid')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Test Button</Button>)
|
||||
const badge = screen.getByText('Test Button')
|
||||
expect(badge).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('renders as a child component when asChild is true', () => {
|
||||
render(
|
||||
<Button asChild>
|
||||
<a href="/">Link Button</a>
|
||||
</Button>
|
||||
)
|
||||
const link = screen.getByRole('link', { name: /link button/i })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveClass('btn btn--primary btn--medium btn--solid')
|
||||
})
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.theme))(
|
||||
'renders with theme %s',
|
||||
(theme) => {
|
||||
render(<Button theme={theme as any}>Theme Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /theme button/i })
|
||||
expect(button).toHaveClass(`btn btn--${theme}`)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.variant))(
|
||||
'renders with variant %s',
|
||||
(variant) => {
|
||||
render(<Button variant={variant as any}>Variant Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /variant button/i })
|
||||
expect(button).toHaveClass(`btn btn--${variant}`)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(Object.keys(buttonConfig.variants.size))(
|
||||
'renders with size %s',
|
||||
(size) => {
|
||||
render(<Button size={size as any}>Size Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /size button/i })
|
||||
expect(button).toHaveClass(`btn btn--${size}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('renders with block prop', () => {
|
||||
render(<Button block>Block Button</Button>)
|
||||
const button = screen.getByRole('button', { name: /block button/i })
|
||||
expect(button).toHaveClass('btn btn--block')
|
||||
})
|
||||
|
||||
it('fails when a new theme is added without updating the test', () => {
|
||||
const expectedThemes = ['primary', 'ghost', 'icon', 'destructive']
|
||||
const actualThemes = Object.keys(buttonConfig.variants.theme)
|
||||
expect(actualThemes).toEqual(expectedThemes)
|
||||
})
|
||||
|
||||
it('fails when a new variant is added without updating the test', () => {
|
||||
const expectedVariant = ['solid', 'soft', 'outline']
|
||||
const actualVariants = Object.keys(buttonConfig.variants.variant)
|
||||
expect(actualVariants).toEqual(expectedVariant)
|
||||
})
|
||||
|
||||
it('fails when a new size is added without updating the test', () => {
|
||||
const expectedSizes = ['small', 'medium', 'large']
|
||||
const actualSizes = Object.keys(buttonConfig.variants.size)
|
||||
expect(actualSizes).toEqual(expectedSizes)
|
||||
})
|
||||
|
||||
it('fails when a new variant CVA is added without updating the test', () => {
|
||||
const expectedVariantsCVA = ['theme', 'variant', 'size', 'block']
|
||||
const actualVariant = Object.keys(buttonConfig.variants)
|
||||
expect(actualVariant).toEqual(expectedVariantsCVA)
|
||||
})
|
||||
})
|
||||
@ -1,65 +0,0 @@
|
||||
import React, { forwardRef, ButtonHTMLAttributes } from 'react'
|
||||
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
export const buttonConfig = {
|
||||
variants: {
|
||||
theme: {
|
||||
primary: 'btn--primary',
|
||||
ghost: 'btn--ghost',
|
||||
icon: 'btn--icon',
|
||||
destructive: 'btn--destructive',
|
||||
},
|
||||
variant: {
|
||||
solid: 'btn--solid',
|
||||
soft: 'btn--soft',
|
||||
outline: 'btn--outline',
|
||||
},
|
||||
size: {
|
||||
small: 'btn--small',
|
||||
medium: 'btn--medium',
|
||||
large: 'btn--large',
|
||||
},
|
||||
block: {
|
||||
true: 'btn--block',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: 'primary' as const,
|
||||
size: 'medium' as const,
|
||||
variant: 'solid' as const,
|
||||
block: false as const,
|
||||
},
|
||||
}
|
||||
const buttonVariants = cva('btn', buttonConfig)
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, theme, size, variant, block, asChild = false, ...props },
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={twMerge(
|
||||
buttonVariants({ theme, size, variant, block, className })
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export { Button }
|
||||
@ -1,134 +0,0 @@
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 font-semibold transition-all;
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
@apply outline-2 outline-offset-4;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(95%);
|
||||
}
|
||||
|
||||
// Primary
|
||||
&--primary {
|
||||
color: hsla(var(--primary-fg));
|
||||
background-color: hsla(var(--primary-bg)) !important;
|
||||
&:hover {
|
||||
filter: brightness(65%);
|
||||
}
|
||||
|
||||
// Variant soft primary
|
||||
&.btn--soft {
|
||||
background-color: hsla(var(--primary-bg-soft)) !important;
|
||||
color: hsla(var(--primary-bg));
|
||||
}
|
||||
|
||||
// Variant outline primary
|
||||
&.btn--outline {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid hsla(var(--primary-bg));
|
||||
color: hsla(var(--primary-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost
|
||||
&--ghost {
|
||||
background-color: transparent !important;
|
||||
&.btn--soft {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
// Variant outline ghost
|
||||
&.btn--outline {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid hsla(var(--ghost-border));
|
||||
}
|
||||
}
|
||||
|
||||
// Destructive
|
||||
&--destructive {
|
||||
color: hsla(var(--destructive-fg));
|
||||
background-color: hsla(var(--destructive-bg)) !important;
|
||||
&:hover {
|
||||
filter: brightness(65%);
|
||||
}
|
||||
|
||||
// Variant soft destructive
|
||||
&.btn--soft {
|
||||
background-color: hsla(var(--destructive-bg-soft)) !important;
|
||||
color: hsla(var(--destructive-bg));
|
||||
}
|
||||
|
||||
// Variant outline destructive
|
||||
&.btn--outline {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid hsla(var(--destructive-bg));
|
||||
color: hsla(var(--destructive-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled
|
||||
&:disabled {
|
||||
color: hsla(var(--disabled-fg));
|
||||
background-color: hsla(var(--disabled-bg)) !important;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon
|
||||
&--icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
&:hover {
|
||||
background-color: hsla(var(--icon-bg)) !important;
|
||||
}
|
||||
|
||||
&.btn--outline {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid hsla(var(--icon-border));
|
||||
&:hover {
|
||||
background-color: hsla(var(--icon-bg)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Size
|
||||
&--small {
|
||||
@apply h-6 px-2;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
&.btn--icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
@apply h-8;
|
||||
border-radius: 6px;
|
||||
&.btn--icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
@apply h-9;
|
||||
border-radius: 8px;
|
||||
&.btn--icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--block {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Checkbox } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Checkbox', () => {
|
||||
it('renders correctly with label', () => {
|
||||
render(<Checkbox id="test-checkbox" label="Test Checkbox" />)
|
||||
expect(screen.getByLabelText('Test Checkbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with helper description', () => {
|
||||
render(<Checkbox id="test-checkbox" helperDescription="Helper text" />)
|
||||
expect(screen.getByText('Helper text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error message when provided', () => {
|
||||
render(<Checkbox id="test-checkbox" errorMessage="Error occurred" />)
|
||||
expect(screen.getByText('Error occurred')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when clicked', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
render(
|
||||
<Checkbox
|
||||
id="test-checkbox"
|
||||
label="Test Checkbox"
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Test Checkbox'))
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Checkbox id="test-checkbox" className="custom-class" />)
|
||||
expect(screen.getByRole('checkbox').parentElement).toHaveClass(
|
||||
'custom-class'
|
||||
)
|
||||
})
|
||||
|
||||
it('disables the checkbox when disabled prop is true', () => {
|
||||
render(<Checkbox id="test-checkbox" label="Disabled Checkbox" disabled />)
|
||||
expect(screen.getByLabelText('Disabled Checkbox')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -1,51 +0,0 @@
|
||||
import React, { ChangeEvent, InputHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
label?: ReactNode
|
||||
helperDescription?: ReactNode
|
||||
errorMessage?: string
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
const Checkbox = ({
|
||||
id,
|
||||
name,
|
||||
checked,
|
||||
disabled,
|
||||
label,
|
||||
defaultChecked,
|
||||
helperDescription,
|
||||
errorMessage,
|
||||
className,
|
||||
onChange,
|
||||
...props
|
||||
}: CheckboxProps) => {
|
||||
return (
|
||||
<div className={twMerge('checkbox', className)}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
defaultChecked={defaultChecked}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={id} className="checkbox__label">
|
||||
{label}
|
||||
</label>
|
||||
<p className="checkbox__helper">{helperDescription}</p>
|
||||
{errorMessage && <p className="checkbox__error">{errorMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { Checkbox }
|
||||
@ -1,51 +0,0 @@
|
||||
.checkbox {
|
||||
@apply inline-flex items-start space-x-2;
|
||||
|
||||
> input[type='checkbox'] {
|
||||
@apply flex h-4 w-4 flex-shrink-0 cursor-pointer appearance-none items-center justify-center;
|
||||
background-color: transparent;
|
||||
margin-top: 1px;
|
||||
border: 1px solid hsla(var(--app-border));
|
||||
border-radius: 4px;
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
@apply outline-2 outline-offset-4;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background-color: hsla(var(--primary-bg));
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: hsla(var(----disabled-bg));
|
||||
color: hsla(var(--disabled-fg));
|
||||
|
||||
&:checked {
|
||||
background-color: hsla(var(--primary-bg));
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
& + div > .checkbox__label {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: hsla(var(--destructive-bg));
|
||||
}
|
||||
|
||||
&__label {
|
||||
@apply inline-block cursor-pointer;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: hsla(var(----disabled-bg));
|
||||
color: hsla(var(--disabled-fg));
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import React, { Fragment, PropsWithChildren, ReactNode } from 'react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import './styles.scss'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type Props = {
|
||||
options?: { name: ReactNode; value: string; suffix?: ReactNode }[]
|
||||
className?: string
|
||||
onValueChanged?: (value: string) => void
|
||||
}
|
||||
|
||||
const Dropdown = (props: PropsWithChildren & Props) => {
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>{props.children}</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className={twMerge(props.className, 'DropdownMenuContent')}
|
||||
sideOffset={0}
|
||||
align="end"
|
||||
>
|
||||
{props.options?.map((e, i) => (
|
||||
<Fragment key={e.value}>
|
||||
{i !== 0 && (
|
||||
<DropdownMenu.Separator className="DropdownMenuSeparator" />
|
||||
)}
|
||||
<DropdownMenu.Item
|
||||
className="DropdownMenuItem"
|
||||
onClick={() => props.onValueChanged?.(e.value)}
|
||||
>
|
||||
{e.name}
|
||||
<div />
|
||||
{e.suffix}
|
||||
</DropdownMenu.Item>
|
||||
</Fragment>
|
||||
))}
|
||||
<DropdownMenu.Arrow className="DropdownMenuArrow" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Dropdown }
|
||||
@ -1,155 +0,0 @@
|
||||
.DropdownMenuContent,
|
||||
.DropdownMenuSubContent {
|
||||
min-width: 220px;
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 0px;
|
||||
box-shadow:
|
||||
0px 10px 38px -10px rgba(22, 23, 24, 0.35),
|
||||
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.DropdownMenuContent[data-side='top'],
|
||||
.DropdownMenuSubContent[data-side='top'] {
|
||||
animation-name: slideDownAndFade;
|
||||
}
|
||||
.DropdownMenuContent[data-side='right'],
|
||||
.DropdownMenuSubContent[data-side='right'] {
|
||||
animation-name: slideLeftAndFade;
|
||||
}
|
||||
.DropdownMenuContent[data-side='bottom'],
|
||||
.DropdownMenuSubContent[data-side='bottom'] {
|
||||
animation-name: slideUpAndFade;
|
||||
}
|
||||
.DropdownMenuContent[data-side='left'],
|
||||
.DropdownMenuSubContent[data-side='left'] {
|
||||
animation-name: slideRightAndFade;
|
||||
}
|
||||
|
||||
.DropdownMenuItem {
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between; /* Distribute space between children */
|
||||
align-items: center; /* Optional: Align items vertically */
|
||||
gap: 16px;
|
||||
border-color: hsla(var(--app-border));
|
||||
}
|
||||
.DropdownMenuCheckboxItem,
|
||||
.DropdownMenuRadioItem,
|
||||
.DropdownMenuSubTrigger {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 25px;
|
||||
padding: 0 0;
|
||||
position: relative;
|
||||
padding-left: 25px;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
.DropdownMenuItem[data-disabled],
|
||||
.DropdownMenuCheckboxItem[data-disabled],
|
||||
.DropdownMenuRadioItem[data-disabled],
|
||||
.DropdownMenuSubTrigger[data-disabled] {
|
||||
pointer-events: none;
|
||||
}
|
||||
.DropdownMenuItem[data-highlighted],
|
||||
.DropdownMenuCheckboxItem[data-highlighted],
|
||||
.DropdownMenuRadioItem[data-highlighted],
|
||||
.DropdownMenuSubTrigger[data-highlighted] {
|
||||
background-color: hsla(var(--secondary-bg));
|
||||
}
|
||||
|
||||
.DropdownMenuSeparator {
|
||||
height: 1px;
|
||||
width: '100%';
|
||||
background-color: hsla(var(--app-border));
|
||||
}
|
||||
|
||||
.DropdownMenuItem::hover {
|
||||
background-color: hsla(var(--secondary-bg));
|
||||
}
|
||||
|
||||
.DropdownMenuLabel {
|
||||
padding-left: 25px;
|
||||
font-size: 12px;
|
||||
line-height: 25px;
|
||||
color: var(--mauve-11);
|
||||
}
|
||||
|
||||
.DropdownMenuItemIndicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 25px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.DropdownMenuArrow {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.RightSlot {
|
||||
margin-left: auto;
|
||||
padding-left: 20px;
|
||||
color: var(--mauve-11);
|
||||
}
|
||||
[data-highlighted] > .RightSlot {
|
||||
color: white;
|
||||
}
|
||||
[data-disabled] .RightSlot {
|
||||
color: var(--mauve-8);
|
||||
}
|
||||
|
||||
@keyframes slideUpAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRightAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDownAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeftAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Input } from './index'
|
||||
|
||||
// Mock the styles import
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Input', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<Input placeholder="Test input" />)
|
||||
expect(screen.getByPlaceholderText('Test input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Input className="custom-class" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('aligns text to the right when textAlign prop is set', () => {
|
||||
render(<Input textAlign="right" />)
|
||||
expect(screen.getByRole('textbox')).toHaveClass('text-right')
|
||||
})
|
||||
|
||||
it('renders prefix icon when provided', () => {
|
||||
render(<Input prefixIcon={<span data-testid="prefix-icon">Prefix</span>} />)
|
||||
expect(screen.getByTestId('prefix-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders suffix icon when provided', () => {
|
||||
render(<Input suffixIcon={<span data-testid="suffix-icon">Suffix</span>} />)
|
||||
expect(screen.getByTestId('suffix-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders clear icon when clearable is true', () => {
|
||||
render(<Input clearable />)
|
||||
expect(screen.getByTestId('cross-2-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClick when input is clicked', () => {
|
||||
const onClick = jest.fn()
|
||||
render(<Input onClick={onClick} />)
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClear when clear icon is clicked', () => {
|
||||
const onClear = jest.fn()
|
||||
render(<Input clearable onClear={onClear} />)
|
||||
fireEvent.click(screen.getByTestId('cross-2-icon'))
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,64 +0,0 @@
|
||||
import React, { ReactNode, forwardRef } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
|
||||
export interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
textAlign?: 'left' | 'right'
|
||||
prefixIcon?: ReactNode
|
||||
suffixIcon?: ReactNode
|
||||
onCLick?: () => void
|
||||
clearable?: boolean
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
textAlign,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
onClick,
|
||||
onClear,
|
||||
clearable,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div className="input__wrapper">
|
||||
{prefixIcon && (
|
||||
<div className="input__prefix-icon" onClick={onClick}>
|
||||
{prefixIcon}
|
||||
</div>
|
||||
)}
|
||||
{suffixIcon && (
|
||||
<div className="input__suffix-icon" onClick={onClick}>
|
||||
{suffixIcon}
|
||||
</div>
|
||||
)}
|
||||
{clearable && (
|
||||
<div className="input__clear-icon" onClick={onClear}>
|
||||
<Cross2Icon data-testid="cross-2-icon" className="text-red-200" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
className={twMerge(
|
||||
'input',
|
||||
className,
|
||||
textAlign === 'right' && 'text-right'
|
||||
)}
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export { Input }
|
||||
@ -1,50 +0,0 @@
|
||||
.input {
|
||||
background-color: hsla(var(--input-bg));
|
||||
border: 1px solid hsla(var(--app-border));
|
||||
@apply inline-flex h-8 w-full items-center rounded-md border px-3 transition-colors;
|
||||
@apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-1 focus-visible:ring-[hsla(var(--primary-bg))] focus-visible:ring-offset-0;
|
||||
@apply file:border-0 file:bg-transparent file:font-medium;
|
||||
@apply hover:border-[hsla(var(--primary-bg))];
|
||||
|
||||
&__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: hsla(var(--input-placeholder));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: hsla(var(--disabled-fg));
|
||||
background-color: hsla(var(--disabled-bg));
|
||||
cursor: not-allowed;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&__prefix-icon {
|
||||
@apply absolute left-3 top-1/2 -translate-y-1/2 cursor-pointer;
|
||||
color: hsla(var(--input-icon));
|
||||
+ .input {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__suffix-icon {
|
||||
@apply absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer;
|
||||
color: hsla(var(--input-icon));
|
||||
+ .input {
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
&__clear-icon {
|
||||
@apply absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer;
|
||||
color: hsla(var(--input-icon));
|
||||
+ .input {
|
||||
padding: 0 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Modal } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders the modal with trigger and content', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Open Modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the modal with title', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
title="Modal Title"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByText('Modal Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders full page modal', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
fullPage
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByRole('dialog')).toHaveClass('modal__content--fullpage')
|
||||
})
|
||||
|
||||
it('hides close button when hideClose is true', () => {
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
hideClose
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onOpenChange when opening and closing the modal', () => {
|
||||
const onOpenChangeMock = jest.fn()
|
||||
render(
|
||||
<Modal
|
||||
trigger={<button>Open Modal</button>}
|
||||
content={<div>Modal Content</div>}
|
||||
onOpenChange={onOpenChangeMock}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close'))
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -1,59 +0,0 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
|
||||
import './styles.scss'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type Props = {
|
||||
trigger?: ReactNode
|
||||
content: ReactNode
|
||||
open?: boolean
|
||||
className?: string
|
||||
fullPage?: boolean
|
||||
hideClose?: boolean
|
||||
title?: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ModalClose = DialogPrimitive.Close
|
||||
|
||||
const Modal = ({
|
||||
trigger,
|
||||
content,
|
||||
open,
|
||||
title,
|
||||
fullPage,
|
||||
className,
|
||||
onOpenChange,
|
||||
hideClose,
|
||||
}: Props) => (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="modal__overlay" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={twMerge(
|
||||
'modal__content',
|
||||
fullPage && 'modal__content--fullpage',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="modal__title">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
{content}
|
||||
{!hideClose && (
|
||||
<ModalClose asChild>
|
||||
<button className="modal__close-icon" aria-label="Close">
|
||||
<Cross2Icon />
|
||||
</button>
|
||||
</ModalClose>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
|
||||
export { Modal, ModalClose }
|
||||
@ -1,85 +0,0 @@
|
||||
/* reset */
|
||||
button,
|
||||
fieldset,
|
||||
.modal {
|
||||
&__overlay {
|
||||
background-color: hsla(var(--modal-overlay));
|
||||
z-index: 200;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
&__content {
|
||||
color: hsla(var(--modal-fg));
|
||||
overflow: auto;
|
||||
background-color: hsla(var(--modal-bg));
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 50vw;
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
padding: 16px;
|
||||
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border: 1px solid hsla(var(--app-border));
|
||||
@apply w-full;
|
||||
|
||||
&--fullpage {
|
||||
max-width: none;
|
||||
width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
@apply leading-relaxed;
|
||||
margin: 0 0 8px 0;
|
||||
padding-right: 16px;
|
||||
font-weight: 600;
|
||||
color: hsla(var(--modal-fg));
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&__close-icon {
|
||||
font-family: inherit;
|
||||
border-radius: 100%;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsla(var(--modal-fg));
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Progress } from './index'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
describe('@joi/core/Progress', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Progress value={50} />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toBeInTheDocument()
|
||||
expect(progressElement).toHaveClass('progress')
|
||||
expect(progressElement).toHaveClass('progress--medium')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Progress value={50} className="custom-class" />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<Progress value={50} size="small" />)
|
||||
let progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('progress--small')
|
||||
|
||||
rerender(<Progress value={50} size="large" />)
|
||||
progressElement = screen.getByRole('progressbar')
|
||||
expect(progressElement).toHaveClass('progress--large')
|
||||
})
|
||||
|
||||
it('sets the correct transform style based on value', () => {
|
||||
render(<Progress value={75} />)
|
||||
const progressElement = screen.getByRole('progressbar')
|
||||
const indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-25%)')
|
||||
})
|
||||
|
||||
it('handles edge cases for value', () => {
|
||||
const { rerender } = render(<Progress value={0} />)
|
||||
let progressElement = screen.getByRole('progressbar')
|
||||
let indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-100%)')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '0')
|
||||
|
||||
rerender(<Progress value={100} />)
|
||||
progressElement = screen.getByRole('progressbar')
|
||||
indicatorElement = progressElement.firstChild as HTMLElement
|
||||
expect(indicatorElement).toHaveStyle('transform: translateX(-0%)')
|
||||
expect(progressElement).toHaveAttribute('aria-valuenow', '100')
|
||||
})
|
||||
})
|
||||
@ -1,46 +0,0 @@
|
||||
import React, { HTMLAttributes } from 'react'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
const progressVariants = cva('progress', {
|
||||
variants: {
|
||||
size: {
|
||||
small: 'progress--small',
|
||||
medium: 'progress--medium',
|
||||
large: 'progress--large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ProgressProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof progressVariants> {
|
||||
value: number
|
||||
}
|
||||
|
||||
const Progress = ({ className, size, value, ...props }: ProgressProps) => {
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className={twMerge(progressVariants({ size, className }))}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
className="progress--indicator"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@ -1,25 +0,0 @@
|
||||
.progress {
|
||||
background-color: hsla(var(--progress-track-bg));
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@apply transition-all;
|
||||
|
||||
&--indicator {
|
||||
background-color: hsla(var(--primary-bg));
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--small {
|
||||
height: 6px;
|
||||
}
|
||||
&--medium {
|
||||
@apply h-2;
|
||||
}
|
||||
&--large {
|
||||
@apply h-3;
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { ScrollArea } from './index'
|
||||
|
||||
declare const global: typeof globalThis
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock
|
||||
|
||||
describe('@joi/core/ScrollArea', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<ScrollArea>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
const child = screen.getByTestId('child')
|
||||
expect(child).toBeInTheDocument()
|
||||
expect(child).toHaveTextContent('Test Content')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<ScrollArea className="custom-class" />)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toHaveClass('scroll-area__root')
|
||||
expect(root).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('forwards ref to the Viewport component', () => {
|
||||
const ref = React.createRef<HTMLDivElement>()
|
||||
render(<ScrollArea ref={ref} />)
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
||||
expect(ref.current).toHaveClass('scroll-area__viewport')
|
||||
})
|
||||
})
|
||||
@ -1,39 +0,0 @@
|
||||
import React, { PropsWithChildren, forwardRef } from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './styles.scss'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, onScroll, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
type="auto"
|
||||
className={twMerge('scroll-area__root', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
className="scroll-area__viewport"
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
className="scroll-area__bar"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
className="scroll-area__bar"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb className="scroll-area__thumb" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner className="scroll-area__corner" />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
|
||||
export { ScrollArea }
|
||||
@ -1,53 +0,0 @@
|
||||
.scroll-area {
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
|
||||
&__root {
|
||||
width: 200px;
|
||||
height: 225px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
padding: 1px;
|
||||
background: hsla(var(--scrollbar-tracker));
|
||||
transition: background 160ms ease-out;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
flex: 1;
|
||||
background: hsla(var(--scrollbar-thumb));
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
|
||||
::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-area__bar[data-orientation='vertical'] {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scroll-area__bar[data-orientation='horizontal'] {
|
||||
flex-direction: column;
|
||||
height: 8px;
|
||||
}
|
||||
@ -1,107 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Select } from './index'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock the styles
|
||||
jest.mock('./styles.scss', () => ({}))
|
||||
|
||||
jest.mock('tailwind-merge', () => ({
|
||||
twMerge: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockOnValueChange = jest.fn()
|
||||
jest.mock('@radix-ui/react-select', () => ({
|
||||
Root: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
}) => {
|
||||
mockOnValueChange.mockImplementation(onValueChange)
|
||||
return <div data-testid="select-root">{children}</div>
|
||||
},
|
||||
Trigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button data-testid="select-trigger" className={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Value: ({ placeholder }: { placeholder?: string }) => (
|
||||
<span data-testid="select-value">{placeholder}</span>
|
||||
),
|
||||
Icon: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-icon">{children}</span>
|
||||
),
|
||||
Portal: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-portal">{children}</div>
|
||||
),
|
||||
Content: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-content">{children}</div>
|
||||
),
|
||||
Viewport: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="select-viewport">{children}</div>
|
||||
),
|
||||
Item: ({ children, value }: { children: React.ReactNode; value: string }) => (
|
||||
<div
|
||||
data-testid={`select-item-${value}`}
|
||||
onClick={() => mockOnValueChange(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ItemText: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-item-text">{children}</span>
|
||||
),
|
||||
ItemIndicator: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="select-item-indicator">{children}</span>
|
||||
),
|
||||
Arrow: () => <div data-testid="select-arrow" />,
|
||||
}))
|
||||
describe('@joi/core/Select', () => {
|
||||
const options = [
|
||||
{ name: 'Option 1', value: 'option1' },
|
||||
{ name: 'Option 2', value: 'option2' },
|
||||
]
|
||||
|
||||
it('renders with placeholder', () => {
|
||||
render(<Select placeholder="Select an option" options={options} />)
|
||||
expect(screen.getByTestId('select-value')).toHaveTextContent(
|
||||
'Select an option'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders options', () => {
|
||||
render(<Select options={options} />)
|
||||
expect(screen.getByTestId('select-item-option1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-item-option2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onValueChange when an option is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = jest.fn()
|
||||
render(<Select options={options} onValueChange={onValueChange} />)
|
||||
|
||||
await user.click(screen.getByTestId('select-trigger'))
|
||||
await user.click(screen.getByTestId('select-item-option1'))
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('applies disabled class when disabled prop is true', () => {
|
||||
render(<Select options={options} disabled />)
|
||||
expect(screen.getByTestId('select-trigger')).toHaveClass('select__disabled')
|
||||
})
|
||||
|
||||
it('applies block class when block prop is true', () => {
|
||||
render(<Select options={options} block />)
|
||||
expect(screen.getByTestId('select-trigger')).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||