diff --git a/.github/workflows/check-if-ui-has-changed.yml b/.github/workflows/check-if-ui-has-changed.yml new file mode 100644 index 0000000000..cc371b74e2 --- /dev/null +++ b/.github/workflows/check-if-ui-has-changed.yml @@ -0,0 +1,50 @@ +# .github/workflows/chromatic.yml + +# Workflow name +name: 'Chromatic' + +# Event for the workflow +on: push + +# List of jobs +jobs: + check-if-ui-has-changed: + runs-on: ubuntu-latest + # Declare outputs for next jobs + outputs: + docs_changed: ${{ steps.check_file_changed.outputs.docs_changed }} + steps: + - uses: actions/checkout@v2 + with: + # Checkout as many commits as needed for the diff + fetch-depth: 2 + - shell: pwsh + id: check_file_changed + run: | + # Diff HEAD with the previous commit + $diff = git diff --name-only HEAD^ HEAD + + # Check if a file under /packages/ui or apps/storybook has been modified since the previous commit + $SourceDiff = $diff | Where-Object { $_ -match '^packages/ui/' -or $_ -match '^apps/storybook/' } + $HasDiff = $SourceDiff.Length -gt 0 + + # Set the output named "hasUiChanges" + Write-Host "::set-output name=hasUiChanges::$HasDiff" + chromatic-deployment: + runs-on: ubuntu-latest + needs: [ check-if-ui-has-changed ] + if: needs.checkIfUiHasChanged.outputs.hasUiChanges== 'True' + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 # 👈 Required to retrieve git history + - name: Install dependencies + run: yarn + # 👇 Adds Chromatic as a step in the workflow + - name: Publish to Chromatic + uses: chromaui/action@v1 + # Options required to the GitHub Chromatic Action + with: + # 👇 Chromatic projectToken, refer to the manage page to obtain it. + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} diff --git a/.gitignore b/.gitignore index a99226a783..b61bab8872 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ dist # Linting lint-results +#Storybook +apps/storybook/build-storybook.log + # Snaplet .snaplet/snapshots .snaplet/structure.d.ts diff --git a/README.md b/README.md index b758dea3e9..5d66ec2e3b 100644 --- a/README.md +++ b/README.md @@ -424,14 +424,14 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type 1. Select all events for the webhook you interested, e.g. `sleep_created` 1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file. -## Workflows +## Workflows ### Setting up SendGrid for Email reminders 1. Create a SendGrid account (https://signup.sendgrid.com/) -2. Go to Settings -> API keys and create an API key +2. Go to Settings -> API keys and create an API key 3. Copy API key to your .env file into the SENDGRID_API_KEY field -4. Go to Settings -> Sender Authentication and verify a single sender +4. Go to Settings -> Sender Authentication and verify a single sender 5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field ### Setting up Twilio for SMS reminders @@ -445,9 +445,9 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type 7. Click 'Add Senders' 8. Choose phone number as sender type 9. Add the listed phone number -9. Leave all other fields as they are -10. Complete setup and click ‘View my new Messaging Service’ -11. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field +10. Leave all other fields as they are +11. Complete setup and click ‘View my new Messaging Service’ +12. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field diff --git a/apps/api b/apps/api index a8e8acd053..aba7b1ec1c 160000 --- a/apps/api +++ b/apps/api @@ -1 +1 @@ -Subproject commit a8e8acd053e0de1da9ad623c3664a837950d6a06 +Subproject commit aba7b1ec1c9b5122609dea916c7b114e9a3ba66f diff --git a/apps/console b/apps/console index a26db083fa..ac2567263d 160000 --- a/apps/console +++ b/apps/console @@ -1 +1 @@ -Subproject commit a26db083faaa79a40f96dddac888ba2c2bea921e +Subproject commit ac2567263de74449c6e4b98b468415ce1c1815d4 diff --git a/apps/storybook/.gitignore b/apps/storybook/.gitignore new file mode 100755 index 0000000000..0bcd6d7e0a --- /dev/null +++ b/apps/storybook/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +storybook-static/ diff --git a/apps/storybook/.gitkeep b/apps/storybook/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/storybook/.storybook/main.js b/apps/storybook/.storybook/main.js new file mode 100644 index 0000000000..55a0703097 --- /dev/null +++ b/apps/storybook/.storybook/main.js @@ -0,0 +1,57 @@ +const path = require("path"); + +module.exports = { + stories: ["../stories/**/*.stories.mdx", "../stories/**/*.stories.@(js|jsx|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + "storybook-addon-designs", + "@storybook/addon-a11y", + "storybook-addon-next", + ], + framework: "@storybook/react", + core: { + builder: "@storybook/builder-webpack5", + }, + webpackFinal: async (config) => { + /** + * Fixes font import with / + * @see https://github.com/storybookjs/storybook/issues/12844#issuecomment-867544160 + */ + config.resolve.roots = [path.resolve(__dirname, "../public"), "node_modules"]; + + /** + * Why webpack5... Just why? + * @type {{console: boolean, process: boolean, timers: boolean, os: boolean, querystring: boolean, sys: boolean, fs: boolean, url: boolean, crypto: boolean, path: boolean, zlib: boolean, punycode: boolean, util: boolean, stream: boolean, assert: boolean, string_decoder: boolean, domain: boolean, vm: boolean, tty: boolean, http: boolean, buffer: boolean, constants: boolean, https: boolean, events: boolean}} + */ + config.resolve.fallback = { + fs: false, + assert: false, + buffer: false, + console: false, + constants: false, + crypto: false, + domain: false, + events: false, + http: false, + https: false, + os: false, + path: false, + punycode: false, + process: false, + querystring: false, + stream: false, + string_decoder: false, + sys: false, + timers: false, + tty: false, + url: false, + util: false, + vm: false, + zlib: false, + }; + + return config; + }, +}; diff --git a/apps/storybook/.storybook/preview-head.html b/apps/storybook/.storybook/preview-head.html new file mode 100644 index 0000000000..d14ce9f633 --- /dev/null +++ b/apps/storybook/.storybook/preview-head.html @@ -0,0 +1,8 @@ + + diff --git a/apps/storybook/.storybook/preview.js b/apps/storybook/.storybook/preview.js new file mode 100644 index 0000000000..829c0b8026 --- /dev/null +++ b/apps/storybook/.storybook/preview.js @@ -0,0 +1,20 @@ +import * as NextImage from "next/image"; + +import "../styles/globals.css"; + +const OriginalNextImage = NextImage.default; + +Object.defineProperty(NextImage, "default", { + configurable: true, + value: (props) => , +}); + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/apps/storybook/README.md b/apps/storybook/README.md new file mode 100644 index 0000000000..97ce36c5aa --- /dev/null +++ b/apps/storybook/README.md @@ -0,0 +1,21 @@ +# Storybook & UI + +Storybook is home to all of our commonly used components. We use this app to visually show all components that we have within the project + automatic type documentation generated for each component. + +All changes to storybook/ui must require a visual/code review. + +- Visual reviews happen on a tool called [Chormatic](http://chromatic.com/) and these reviews will happen by our product team. + +- Code reviews happen as normal on any changes to storybook/ui designs + +## Deployment + +To deploy this project run + +```bash + cd apps/storybook +``` + +```bash + yarn dev +``` diff --git a/apps/storybook/next-env.d.ts b/apps/storybook/next-env.d.ts new file mode 100755 index 0000000000..4f11a03dc6 --- /dev/null +++ b/apps/storybook/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/storybook/next.config.js b/apps/storybook/next.config.js new file mode 100755 index 0000000000..c79b3b3772 --- /dev/null +++ b/apps/storybook/next.config.js @@ -0,0 +1,7 @@ +const withTM = require("next-transpile-modules")(["@calcom/dayjs", "@calcom/ui"]); +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +module.exports = withTM(nextConfig); diff --git a/apps/storybook/package.json b/apps/storybook/package.json new file mode 100644 index 0000000000..becdf9d6a3 --- /dev/null +++ b/apps/storybook/package.json @@ -0,0 +1,62 @@ +{ + "name": "@calcom/storybook", + "version": "0.1.0", + "private": true, + "scripts": { + "dev:next": "next dev", + "build:next": "next build", + "start": "next start", + "lint": "next lint", + "lint:report": "eslint . --format json --output-file ../../lint-results/storybook.json", + "dev": "start-storybook -p 6006", + "build": "build-storybook", + "chromatic": "npx chromatic --project-token=56fadc9ab496" + }, + "dependencies": { + "@calcom/dayjs": "*", + "@calcom/ui": "*", + "@radix-ui/react-avatar": "^0.1.4", + "@radix-ui/react-collapsible": "^0.1.0", + "@radix-ui/react-dialog": "^0.1.0", + "@radix-ui/react-dropdown-menu": "^0.1.1", + "@radix-ui/react-id": "^0.1.0", + "@radix-ui/react-radio-group": "^0.1.1", + "@radix-ui/react-slider": "^0.1.1", + "@radix-ui/react-switch": "^0.1.1", + "@radix-ui/react-tooltip": "^0.1.0", + "next": "12.2.0", + "next-transpile-modules": "^9.0.0", + "react": "18.1.0", + "react-dom": "18.1.0", + "react-feather": "^2.0.9", + "react-hot-toast": "^2.2.0", + "storybook-addon-next": "^1.6.7" + }, + "devDependencies": { + "@babel/core": "^7.18.6", + "@calcom/config": "*", + "@storybook/addon-a11y": "^6.5.9", + "@storybook/addon-actions": "^6.5.9", + "@storybook/addon-essentials": "^6.5.9", + "@storybook/addon-interactions": "^6.5.9", + "@storybook/addon-links": "^6.5.9", + "@storybook/addon-postcss": "^2.0.0", + "@storybook/builder-webpack5": "^6.5.9", + "@storybook/manager-webpack5": "^6.5.9", + "@storybook/react": "^6.5.9", + "@types/node": "16.9.1", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.4", + "autoprefixer": "^10.4.7", + "babel-loader": "^8.2.5", + "chromatic": "^6.6.4", + "eslint": "^8.15.0", + "postcss": "^8.4.13", + "postcss-loader": "^7.0.0", + "tailwindcss": "^3.1.3", + "tsconfig-paths-webpack-plugin": "^3.5.2", + "typescript": "^4.6.4" + }, + "readme": "ERROR: No README data found!", + "_id": "@calcom/storybook@0.1.0" +} diff --git a/apps/storybook/pages/_app.tsx b/apps/storybook/pages/_app.tsx new file mode 100755 index 0000000000..e9a8374578 --- /dev/null +++ b/apps/storybook/pages/_app.tsx @@ -0,0 +1,9 @@ +import type { AppProps } from "next/app"; + +import "../styles/globals.css"; + +function MyApp({ Component, pageProps }: AppProps) { + return ; +} + +export default MyApp; diff --git a/apps/storybook/pages/index.tsx b/apps/storybook/pages/index.tsx new file mode 100755 index 0000000000..785828e44a --- /dev/null +++ b/apps/storybook/pages/index.tsx @@ -0,0 +1,63 @@ +import type { NextPage } from "next"; +import Head from "next/head"; +import Image from "next/image"; + +const Home: NextPage = () => { + return ( +
+ + Create Next App + + + + +
+

+ Welcome to Next.js! +

+ +

+ Get started by editing pages/index.tsx +

+ + +
+ + +
+ ); +}; + +export default Home; diff --git a/apps/storybook/postcss.config.js b/apps/storybook/postcss.config.js new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/apps/storybook/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/storybook/public/favicon.ico b/apps/storybook/public/favicon.ico new file mode 100755 index 0000000000..718d6fea48 Binary files /dev/null and b/apps/storybook/public/favicon.ico differ diff --git a/apps/storybook/public/vercel.svg b/apps/storybook/public/vercel.svg new file mode 100755 index 0000000000..fbf0e25a65 --- /dev/null +++ b/apps/storybook/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/storybook/stories/Avatar.stories.tsx b/apps/storybook/stories/Avatar.stories.tsx new file mode 100644 index 0000000000..252f5bd640 --- /dev/null +++ b/apps/storybook/stories/Avatar.stories.tsx @@ -0,0 +1,26 @@ +import { ComponentMeta } from "@storybook/react"; + +import Avatar from "@calcom/ui/v2/Avatar"; + +export default { + title: "Avatar", + component: Avatar, +} as ComponentMeta; + +export const Default = () => { + return ( + <> + + + + ); +}; + +export const Accepted = () => { + return ( + <> + + + + ); +}; diff --git a/apps/storybook/stories/AvatarGroup.stories.tsx b/apps/storybook/stories/AvatarGroup.stories.tsx new file mode 100644 index 0000000000..1c322867d9 --- /dev/null +++ b/apps/storybook/stories/AvatarGroup.stories.tsx @@ -0,0 +1,60 @@ +import { ComponentMeta } from "@storybook/react"; + +import Avatar from "@calcom/ui/v2/Avatar"; +import AvatarGroup from "@calcom/ui/v2/AvatarGroup"; + +export default { + title: "Avatar/Group", + component: AvatarGroup, +} as ComponentMeta; + +const IMAGES = [ + { + image: "https://cal.com/stakeholder/peer.jpg", + alt: "Peer", + title: "Peer Richelsen", + }, + { + image: "https://cal.com/stakeholder/bailey.jpg", + alt: "Bailey", + title: "Bailey Pumfleet", + }, + { + image: "https://cal.com/stakeholder/alex-van-andel.jpg", + alt: "Alex", + title: "Alex Van Andel", + }, + { + image: "https://cal.com/stakeholder/ciaran.jpg", + alt: "Ciarán", + title: "Ciarán Hanrahan", + }, + { + image: "https://cal.com/stakeholder/peer.jpg", + alt: "Peer", + title: "Peer Richelsen", + }, + { + image: "https://cal.com/stakeholder/bailey.jpg", + alt: "Bailey", + title: "Bailey Pumfleet", + }, + { + image: "https://cal.com/stakeholder/alex-van-andel.jpg", + alt: "Alex", + title: "Alex Van Andel", + }, + { + image: "https://cal.com/stakeholder/ciaran.jpg", + alt: "Ciarán", + title: "Ciarán Hanrahan", + }, +]; + +export const Default = () => { + return ( + <> + + + ); +}; diff --git a/apps/storybook/stories/Badge.stories.tsx b/apps/storybook/stories/Badge.stories.tsx new file mode 100644 index 0000000000..e4e6103c86 --- /dev/null +++ b/apps/storybook/stories/Badge.stories.tsx @@ -0,0 +1,58 @@ +import { ComponentMeta } from "@storybook/react"; +import { Bell } from "react-feather"; + +import { Badge } from "@calcom/ui/v2"; + +export default { + title: "Badge", + component: Badge, +} as ComponentMeta; + +export const All = () => ( +
+

Default

+
+ Badge + Badge + Badge + Badge + Badge +
+

Icons

+
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+

Large

+
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+
+); diff --git a/apps/storybook/stories/Banner.stories.tsx b/apps/storybook/stories/Banner.stories.tsx new file mode 100644 index 0000000000..7c36991343 --- /dev/null +++ b/apps/storybook/stories/Banner.stories.tsx @@ -0,0 +1,46 @@ +import { ComponentStory, ComponentMeta } from "@storybook/react"; +import { Info } from "react-feather"; + +import { Banner } from "@calcom/ui/v2"; + +export default { + title: "Banner", + component: Banner, +} as ComponentMeta; + +export const Default = () => { + return ( + console.log("dismissed")} + /> + ); +}; + +export const Warning = () => { + return ( + console.log("dismissed")} + /> + ); +}; + +export const Error = () => { + return ( + console.log("dismissed")} + /> + ); +}; diff --git a/apps/storybook/stories/Breadcrumb.stories.tsx b/apps/storybook/stories/Breadcrumb.stories.tsx new file mode 100644 index 0000000000..e432f37b32 --- /dev/null +++ b/apps/storybook/stories/Breadcrumb.stories.tsx @@ -0,0 +1,20 @@ +import { Breadcrumb, BreadcrumbItem } from "@calcom/ui/v2"; + +export default { + title: "Breadcrumbs", + component: Breadcrumb, +}; + +export const Default = () => ( + + Home + Test + +); + +Default.parameters = { + nextRouter: { + path: "/test", + asPath: "/test", + }, +}; diff --git a/apps/storybook/stories/Button.stories.tsx b/apps/storybook/stories/Button.stories.tsx new file mode 100644 index 0000000000..673866b581 --- /dev/null +++ b/apps/storybook/stories/Button.stories.tsx @@ -0,0 +1,95 @@ +import { ComponentMeta, ComponentStory } from "@storybook/react"; +import { Trash2 } from "react-feather"; + +import { Button as ButtonComponent } from "@calcom/ui/v2"; + +export default { + title: "Button", + component: ButtonComponent, + argTypes: { + color: { + options: ["primary", "secondary", "minimal", "destructive"], + control: { type: "select" }, + }, + disabled: { + options: [true, false], + control: { type: "boolean" }, + }, + loading: { + options: [true, false], + control: { type: "boolean" }, + }, + size: { + options: ["base", "lg", "icon"], + control: { type: "radio" }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const All = () => ( +
+

Primary

+
+ Button Text + + Button Text + +
+

Secondary

+
+ + Button Text + + + Button Text + + +
+

Minimal

+
+ + Button Text + + + Button Text + + +
+

Destructive

+ +

Tooltip

+ +
+); + +export const Button = Template.bind({}); +Button.args = { + color: "primary", + children: "Button Text", +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...Button.args, + disabled: true, +}; + +export const Loading = Template.bind({}); +Loading.args = { + ...Button.args, + loading: true, +}; +export const Icon = Template.bind({}); +Icon.args = { + color: "secondary", + StartIcon: Trash2, + size: "icon", +}; diff --git a/apps/storybook/stories/ButtonGroup.stories.tsx b/apps/storybook/stories/ButtonGroup.stories.tsx new file mode 100644 index 0000000000..96c2231837 --- /dev/null +++ b/apps/storybook/stories/ButtonGroup.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta } from "@storybook/react"; +import { ArrowLeft, ArrowRight, Clipboard, Navigation2, Trash2 } from "react-feather"; + +import { Button, ButtonGroup } from "@calcom/ui/v2"; + +export default { + title: "Button Group", + component: ButtonGroup, +} as ComponentMeta; + +export const Default = () => ( + + + + + + + ); +}; diff --git a/apps/storybook/stories/Input.stories.tsx b/apps/storybook/stories/Input.stories.tsx new file mode 100644 index 0000000000..42c2f17816 --- /dev/null +++ b/apps/storybook/stories/Input.stories.tsx @@ -0,0 +1,68 @@ +import { ComponentMeta, ComponentStory } from "@storybook/react"; +import { Copy } from "react-feather"; + +import DatePicker from "@calcom/ui/v2/form/DatePicker"; +import { TextAreaField, TextField } from "@calcom/ui/v2/form/fields"; + +export default { + title: "Inputs", + component: TextField, + argTypes: { + disabled: { + options: [false, true], + }, + }, +} as ComponentMeta; + +const TextInputTemplate: ComponentStory = (args) => ; +// name="demo" label="Demo Label" hint="Hint text" +export const TextInput = TextInputTemplate.bind({}); +TextInput.args = { + name: "demo", + label: "Demo Label", + hint: "Hint Text", + disabled: false, +}; + +export const TextInputPrefix = TextInputTemplate.bind({}); +TextInputPrefix.args = { + name: "demo", + label: "Demo Label", + hint: "Hint Text", + addOnLeading: "https://", + disabled: false, +}; + +export const TextInputSuffix = TextInputTemplate.bind({}); +TextInputSuffix.args = { + name: "demo", + label: "Demo Label", + hint: "Hint Text", + addOnSuffix: "Minutes", + disabled: false, +}; + +export const TextInputPrefixIcon = TextInputTemplate.bind({}); +TextInputPrefixIcon.args = { + name: "demo", + label: "Demo Label", + hint: "Hint Text", + addOnFilled: false, + addOnSuffix: , + disabled: false, +}; +export const TextInputSuffixIcon = TextInputTemplate.bind({}); +TextInputSuffixIcon.args = { + name: "demo", + label: "Demo Label", + hint: "Hint Text", + addOnFilled: false, + addOnLeading: , + disabled: false, +}; + +export const TextAreaInput: ComponentStory = () => ( + +); + +export const DatePickerInput: ComponentStory = () => ; diff --git a/apps/storybook/stories/Modal.stories.tsx b/apps/storybook/stories/Modal.stories.tsx new file mode 100644 index 0000000000..82b2cdb026 --- /dev/null +++ b/apps/storybook/stories/Modal.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { Info } from "react-feather"; + +import { Button } from "@calcom/ui/v2"; +import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/v2/Dialog"; +import { TextField } from "@calcom/ui/v2/form/fields"; + +export default { + title: "pattern/Modal", + component: Dialog, +}; + +export const Creation = () => { + const [open, setOpen] = useState(false); + return ( + + + + + setOpen(false)}> + + + + + + + ); +}; + +export const Confirmation = () => { + const [open, setOpen] = useState(false); + return ( + + + + + setOpen(false)} + /> + + ); +}; diff --git a/apps/storybook/stories/Notifcations.stories.tsx b/apps/storybook/stories/Notifcations.stories.tsx new file mode 100644 index 0000000000..3ce0693a4f --- /dev/null +++ b/apps/storybook/stories/Notifcations.stories.tsx @@ -0,0 +1,25 @@ +import { ComponentMeta } from "@storybook/react"; +import { Toaster } from "react-hot-toast"; + +import { Button } from "@calcom/ui/v2"; +import showToast from "@calcom/ui/v2/notfications"; + +export default { + title: "Notifcations-Toasts", + decorators: [ + (Story) => ( + <> + + + + ), + ], +} as ComponentMeta; // We have to fake this type as the story for this component isn't really a component. + +export const All = () => ( +
+ + + +
+); diff --git a/apps/storybook/stories/PageHeader.stories.tsx b/apps/storybook/stories/PageHeader.stories.tsx new file mode 100644 index 0000000000..6204e9e698 --- /dev/null +++ b/apps/storybook/stories/PageHeader.stories.tsx @@ -0,0 +1,52 @@ +import { ComponentMeta } from "@storybook/react"; +import { Search } from "react-feather"; + +import { Button, PageHeader } from "@calcom/ui/v2"; +import { TextField } from "@calcom/ui/v2/form"; + +export default { + title: "pattern/Page Header", + component: PageHeader, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} as ComponentMeta; + +export const Default = () => ( + +); + +export const ButtonRight = () => ( + Button Text} + /> +); +export const ComingSoon = () => ( + +); +export const SearchInstalledApps = () => ( + } + name="search" + labelSrOnly + placeholder="WIP" + /> + } + /> +); diff --git a/apps/storybook/stories/Radio.stories.tsx b/apps/storybook/stories/Radio.stories.tsx new file mode 100644 index 0000000000..fcc328cef4 --- /dev/null +++ b/apps/storybook/stories/Radio.stories.tsx @@ -0,0 +1,19 @@ +import * as Radio from "@calcom/ui/v2/form/radio-area/Radio"; +import { RadioField } from "@calcom/ui/v2/form/radio-area/Radio"; + +export default { + title: "Radio", + component: RadioField, +}; + +export const RadioGroupDemo = () => { + return ( +
+ + + + + +
+ ); +}; diff --git a/apps/storybook/stories/Select.stories.tsx b/apps/storybook/stories/Select.stories.tsx new file mode 100644 index 0000000000..1668b48c6a --- /dev/null +++ b/apps/storybook/stories/Select.stories.tsx @@ -0,0 +1,10 @@ +import { ComponentMeta } from "@storybook/react"; + +import { Select } from "@calcom/ui/v2"; + +export default { + title: "Form/Select", + component: Select, +} as ComponentMeta; + +export const Single = () => +
+
+ {severity === "error" && ( +
+
+

{props.title}

+
{props.message}
+
+ {props.actions &&
{props.actions}
} +
+ + ); +} diff --git a/packages/ui/v2/Avatar.tsx b/packages/ui/v2/Avatar.tsx new file mode 100644 index 0000000000..4c2a78e0bb --- /dev/null +++ b/packages/ui/v2/Avatar.tsx @@ -0,0 +1,57 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import { Check } from "react-feather"; + +import classNames from "@calcom/lib/classNames"; +import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; + +import { Maybe } from "@trpc/server"; + +export type AvatarProps = { + className?: string; + size: "sm" | "lg"; + imageSrc?: Maybe; + title?: string; + alt: string; + gravatarFallbackMd5?: string; + accepted?: boolean; +}; + +export default function Avatar(props: AvatarProps) { + const { imageSrc, gravatarFallbackMd5, size, alt, title } = props; + const rootClass = classNames("rounded-full", props.size === "sm" ? "w-6" : "w-16", "h-auto"); + const avatar = ( + + + + {gravatarFallbackMd5 && ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + )} + + {props.accepted && ( +
+
+ {size === "lg" && } +
+
+ )} +
+ ); + + return title ? ( + + {avatar} + + + {title} + + + ) : ( + <>{avatar} + ); +} diff --git a/packages/ui/v2/AvatarGroup.tsx b/packages/ui/v2/AvatarGroup.tsx new file mode 100644 index 0000000000..0d3ee36c83 --- /dev/null +++ b/packages/ui/v2/AvatarGroup.tsx @@ -0,0 +1,64 @@ +import classNames from "@calcom/lib/classNames"; + +import Avatar from "./Avatar"; + +export type AvatarGroupProps = { + size: "sm" | "lg"; + items: { + image: string; + title?: string; + alt?: string; + }[]; + className?: string; + accepted?: boolean; +}; + +export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { + const avatars = props.items.slice(0, 4); + const LENGTH = props.items.length; + + return ( +
    + {avatars.map((item, enumerator) => { + if (item.image != null) { + if (LENGTH > 4 && enumerator === 3) { + return ( +
  • +
    + +
    +
    + +{LENGTH - 3} +
    +
  • + ); + } + // Always display the first Four items items + return ( +
  • + +
  • + ); + } + })} +
+ ); +}; + +export default AvatarGroup; diff --git a/packages/ui/v2/Badge.tsx b/packages/ui/v2/Badge.tsx new file mode 100644 index 0000000000..3cea987958 --- /dev/null +++ b/packages/ui/v2/Badge.tsx @@ -0,0 +1,48 @@ +import { Icon } from "react-feather"; + +import classNames from "@calcom/lib/classNames"; + +export const badgeClassNameByVariant = { + default: "bg-orange-100 text-orange-800", + warning: "bg-orange-100 text-orange-800", + orange: "bg-orange-100 text-orange-800", + success: "bg-green-100 text-green-800", + green: "bg-green-100 text-green-800", + gray: "bg-gray-200 text-gray-800", + blue: "bg-blue-100 text-blue-800", + red: "bg-red-100 text-red-800", + error: "bg-red-100 text-red-800", +}; + +const classNameBySize = { + default: "h-5", + lg: "h-6", +}; + +export type BadgeProps = { + variant: keyof typeof badgeClassNameByVariant; + size?: keyof typeof classNameBySize; + StartIcon?: Icon; +} & JSX.IntrinsicElements["div"]; + +export const Badge = function Badge(props: BadgeProps) { + const { variant, className, size = "default", StartIcon, ...passThroughProps } = props; + + return ( +
+ <> + {StartIcon && } + {props.children} + +
+ ); +}; + +export default Badge; diff --git a/packages/ui/v2/Breadcrumb.tsx b/packages/ui/v2/Breadcrumb.tsx new file mode 100644 index 0000000000..9a15061201 --- /dev/null +++ b/packages/ui/v2/Breadcrumb.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { Children, Fragment, useEffect, useState } from "react"; + +type BreadcrumbProps = { + children: React.ReactNode; +}; +export const Breadcrumb = ({ children }: BreadcrumbProps) => { + const childrenArray = Children.toArray(children); + + const childrenSeperated = childrenArray.map((child, index) => { + // If not the last item in the array insert a / + if (index !== childrenArray.length - 1) { + return ( + + {child} + / + + ); + } + // Else return just the child + return child; + }); + + return ( + + ); +}; + +type BreadcrumbItemProps = { + children: React.ReactNode; + href: string; + listProps?: JSX.IntrinsicElements["li"]; +}; + +export const BreadcrumbItem = ({ children, href, listProps }: BreadcrumbItemProps) => { + return ( +
  • + + {children} + +
  • + ); +}; + +export const BreadcrumbContainer = () => { + const router = useRouter(); + const [breadcrumbs, setBreadcrumbs] = useState<{ href: string; label: string }[]>(); + + useEffect(() => { + const rawPath = router.asPath.split("?")[0]; // this will ignore any query params for now? + + let pathArray = rawPath.split("/"); + pathArray.shift(); + + pathArray = pathArray.filter((path) => path !== ""); + + const allBreadcrumbs = pathArray.map((path, idx) => { + const href = `/${pathArray.slice(0, idx + 1).join("/")}`; + return { + href, + label: path.charAt(0).toUpperCase() + path.slice(1), + }; + }); + setBreadcrumbs(allBreadcrumbs); + }, []); +}; diff --git a/packages/ui/v2/Button.tsx b/packages/ui/v2/Button.tsx new file mode 100644 index 0000000000..b0e1ee5e68 --- /dev/null +++ b/packages/ui/v2/Button.tsx @@ -0,0 +1,147 @@ +import Link, { LinkProps } from "next/link"; +import React, { forwardRef } from "react"; +import { Icon } from "react-feather"; + +import classNames from "@calcom/lib/classNames"; + +import { Tooltip } from "./Tooltip"; + +export type ButtonBaseProps = { + /* Primary: Signals most important actions at any given point in the application. + Secondary: Gives visual weight to actions that are important + Minimal: Used for actions that we want to give very little significane to */ + color?: keyof typeof variantClassName; + /**Default: H = 36px (default) + Large: H = 38px (Onboarding, modals) + Icon: Makes the button be an icon button */ + size?: "base" | "lg" | "icon"; + /**Signals the button is loading */ + loading?: boolean; + /** Disables the button from being clicked */ + disabled?: boolean; + /** Action that happens when the button is clicked */ + onClick?: (event: React.MouseEvent) => void; + /**Left aligned icon*/ + StartIcon?: Icon; + /**Right aligned icon */ + EndIcon?: Icon; + shallow?: boolean; + /**Tool tip used when icon size is set to small */ + tooltip?: string; + combined?: boolean; +}; +export type ButtonProps = ButtonBaseProps & + ( + | (Omit & LinkProps) + | (Omit & { href?: never }) + ); + +const variantClassName = { + primary: + "border border-transparent text-white bg-brand-500 hover:bg-brand-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + secondary: "border border-gray-200 text-brand-900 bg-white hover:bg-gray-100", + minimal: + "text-gray-700 bg-transparent hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-brand-900", + destructive: + "text-gray-700 bg-transparent hover:bg-red-100 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-100 focus:ring-red-700", +}; +const variantDisabledClassName = { + primary: "border border-transparent bg-brand-500 bg-opacity-20 text-white", + secondary: "border border-gray-200 text-brand-900 bg-white opacity-30", + minimal: "text-gray-400 bg-transparent", + destructive: "text-red-700 bg-transparent opacity-30", +}; + +export const Button = forwardRef(function Button( + props: ButtonProps, + forwardedRef +) { + const { + loading = false, + color = "primary", + size = "base", + StartIcon, + EndIcon, + shallow, + combined = false, + // attributes propagated from `HTMLAnchorProps` or `HTMLButtonProps` + ...passThroughProps + } = props; + // Buttons are **always** disabled if we're in a `loading` state + const disabled = props.disabled || loading; + // If pass an `href`-attr is passed it's ``, otherwise it's a ` + + + + + + ) +); + +type DialogHeaderProps = { + title: React.ReactNode; + subtitle?: React.ReactNode; +}; + +export function DialogHeader(props: DialogHeaderProps) { + return ( + <> + + {props.subtitle &&
    {props.subtitle}
    } + + ); +} + +export function DialogFooter(props: { children: ReactNode }) { + return ( +
    +
    {props.children}
    +
    + ); +} + +DialogContent.displayName = "DialogContent"; + +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogClose = DialogPrimitive.Close; diff --git a/packages/ui/v2/Dropdown.tsx b/packages/ui/v2/Dropdown.tsx new file mode 100644 index 0000000000..476a1c9082 --- /dev/null +++ b/packages/ui/v2/Dropdown.tsx @@ -0,0 +1,94 @@ +import { CheckCircleIcon } from "@heroicons/react/outline"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { ComponentProps, forwardRef } from "react"; + +export const Dropdown = DropdownMenuPrimitive.Root; + +type DropdownMenuTriggerProps = ComponentProps; +export const DropdownMenuTrigger = forwardRef( + ({ className = "", ...props }, forwardedRef) => ( + + ) +); +DropdownMenuTrigger.displayName = "DropdownMenuTrigger"; + +export const DropdownMenuTriggerItem = DropdownMenuPrimitive.TriggerItem; + +type DropdownMenuContentProps = ComponentProps; +export const DropdownMenuContent = forwardRef( + ({ children, ...props }, forwardedRef) => { + return ( + + {children} + + ); + } +); +DropdownMenuContent.displayName = "DropdownMenuContent"; + +type DropdownMenuLabelProps = ComponentProps; +export const DropdownMenuLabel = (props: DropdownMenuLabelProps) => ( + +); + +type DropdownMenuItemProps = ComponentProps; +export const DropdownMenuItem = forwardRef( + ({ className = "", ...props }, forwardedRef) => ( + + ) +); +DropdownMenuItem.displayName = "DropdownMenuItem"; + +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +type DropdownMenuCheckboxItemProps = ComponentProps; +export const DropdownMenuCheckboxItem = forwardRef( + ({ children, ...props }, forwardedRef) => { + return ( + + {children} + + + + + ); + } +); +DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"; + +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +type DropdownMenuRadioItemProps = ComponentProps; +export const DropdownMenuRadioItem = forwardRef( + ({ children, ...props }, forwardedRef) => { + return ( + + {children} + + + + + ); + } +); +DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"; + +export const DropdownMenuSeparator = DropdownMenuPrimitive.Separator; + +export default Dropdown; diff --git a/packages/ui/v2/EmptyScreen.tsx b/packages/ui/v2/EmptyScreen.tsx new file mode 100644 index 0000000000..bfdc766bd4 --- /dev/null +++ b/packages/ui/v2/EmptyScreen.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Icon } from "react-feather"; + +import { SVGComponent } from "@calcom/types/SVGComponent"; + +import Button from "./Button"; + +export default function EmptyScreen({ + Icon, + headline, + description, + buttonText, + buttonOnClick, +}: { + Icon: SVGComponent | Icon; + headline: string; + description: string | React.ReactElement; + buttonText?: string; + buttonOnClick?: (event: React.MouseEvent) => void; +}) { + return ( + <> +
    +
    + +
    +
    +

    {headline}

    +

    + {description} +

    + {buttonOnClick && buttonText && } +
    +
    + + ); +} diff --git a/packages/ui/v2/Loader.tsx b/packages/ui/v2/Loader.tsx new file mode 100644 index 0000000000..29f0147d85 --- /dev/null +++ b/packages/ui/v2/Loader.tsx @@ -0,0 +1,7 @@ +export default function Loader() { + return ( +
    + +
    + ); +} diff --git a/packages/ui/v2/PageHeader.tsx b/packages/ui/v2/PageHeader.tsx new file mode 100644 index 0000000000..2cc440e512 --- /dev/null +++ b/packages/ui/v2/PageHeader.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; + +import Badge, { badgeClassNameByVariant } from "./Badge"; + +type Props = { + title: string; + description?: string; + badgeText?: string; + badgeVariant?: keyof typeof badgeClassNameByVariant; + infoIcon?: string; + rightAlignedComponent?: ReactNode; +}; + +function PageHeader({ title, description, rightAlignedComponent, badgeText, badgeVariant }: Props) { + return ( +
    +
    +
    +

    {title}

    + {badgeText && badgeVariant && {badgeText}} +
    +

    {description}

    +
    +
    {rightAlignedComponent && rightAlignedComponent}
    +
    + ); +} + +export default PageHeader; diff --git a/apps/web/components/Swatch.tsx b/packages/ui/v2/Swatch.tsx similarity index 92% rename from apps/web/components/Swatch.tsx rename to packages/ui/v2/Swatch.tsx index 3cf3970fa5..79f2beb1cb 100644 --- a/apps/web/components/Swatch.tsx +++ b/packages/ui/v2/Swatch.tsx @@ -1,4 +1,4 @@ -import classNames from "@lib/classNames"; +import classNames from "@calcom/lib/classNames"; export type SwatchProps = { size?: "base" | "sm" | "lg"; diff --git a/packages/ui/v2/Switch.tsx b/packages/ui/v2/Switch.tsx new file mode 100644 index 0000000000..d8568a2a9f --- /dev/null +++ b/packages/ui/v2/Switch.tsx @@ -0,0 +1,40 @@ +import { useId } from "@radix-ui/react-id"; +import * as Label from "@radix-ui/react-label"; +import * as PrimitiveSwitch from "@radix-ui/react-switch"; +import React from "react"; + +import classNames from "@calcom/lib/classNames"; + +const Switch = ( + props: React.ComponentProps & { + label?: string; + } +) => { + const { label, ...primitiveProps } = props; + const id = useId(); + + return ( +
    + + + + {label && ( + + {label} + + )} +
    + ); +}; + +export default Switch; diff --git a/packages/ui/v2/Tooltip.tsx b/packages/ui/v2/Tooltip.tsx new file mode 100644 index 0000000000..a5d9e5058f --- /dev/null +++ b/packages/ui/v2/Tooltip.tsx @@ -0,0 +1,36 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import React from "react"; + +export function Tooltip({ + children, + content, + open, + defaultOpen, + onOpenChange, + ...props +}: { + children: React.ReactNode; + content: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + return ( + + {children} + + {content} + + + ); +} + +export default Tooltip; diff --git a/packages/ui/v2/banner.tsx b/packages/ui/v2/banner.tsx new file mode 100644 index 0000000000..3430034958 --- /dev/null +++ b/packages/ui/v2/banner.tsx @@ -0,0 +1,60 @@ +import { MouseEvent } from "react"; +import { Icon } from "react-feather"; + +import classNames from "@calcom/lib/classNames"; + +import Button from "./Button"; + +const stylesByVariant = { + neutral: { background: "bg-gray-100 ", text: "!text-gray-800", hover: "hover:!bg-gray-200" }, + warning: { background: "bg-orange-100 ", text: "!text-orange-800", hover: "hover:!bg-orange-200" }, + error: { background: "bg-red-100 ", text: "!text-red-800", hover: "hover:!bg-red-200" }, +}; + +export type BannerProps = { + title: string; + description?: string; + variant: keyof typeof stylesByVariant; + errorMessage?: string; + Icon?: Icon; + onDismiss: (event: MouseEvent) => void; + onAction?: (event: MouseEvent) => void; + actionText?: string; +} & JSX.IntrinsicElements["div"]; + +const Banner = (props: BannerProps) => { + const { variant, errorMessage, title, description, ...rest } = props; + const buttonStyle = classNames(stylesByVariant[variant].text, stylesByVariant[variant].hover); + return ( +
    +
    +
    {props.Icon && }
    +
    +

    {title}

    + {description &&

    {description}

    } + {props.variant === "error" &&

    {errorMessage}

    } +
    +
    +
    + {props.actionText && ( + + )} + +
    +
    + ); +}; + +export default Banner; diff --git a/packages/ui/v2/booker/DatePicker.tsx b/packages/ui/v2/booker/DatePicker.tsx new file mode 100644 index 0000000000..acedb16688 --- /dev/null +++ b/packages/ui/v2/booker/DatePicker.tsx @@ -0,0 +1,176 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; + +import dayjs, { Dayjs } from "@calcom/dayjs"; +import classNames from "@calcom/lib/classNames"; +import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; +import { weekdayNames } from "@calcom/lib/weekday"; +import { SkeletonText } from "@calcom/ui/skeleton"; + +export type DatePickerProps = { + /** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ + weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** Fires whenever a selected date is changed. */ + onChange: (date: Dayjs) => void; + /** Fires when the month is changed. */ + onMonthChange?: (date: Dayjs) => void; + /** which date is currently selected (not tracked from here) */ + selected?: Dayjs; + /** defaults to current date. */ + minDate?: Dayjs; + /** Furthest date selectable in the future, default = UNLIMITED */ + maxDate?: Dayjs; + /** locale, any IETF language tag, e.g. "hu-HU" - defaults to Browser settings */ + locale: string; + /** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */ + excludedDates?: string[]; + /** defaults to all, which dates are bookable (inverse of excludedDates) */ + includedDates?: string[]; + /** allows adding classes to the container */ + className?: string; + /** Shows a small loading spinner next to the month name */ + isLoading?: boolean; +}; + +export const Day = ({ + date, + active, + ...props +}: JSX.IntrinsicElements["button"] & { active: boolean; date: Dayjs }) => { + return ( + + ); +}; + +const Days = ({ + // minDate, + excludedDates = [], + includedDates, + browsingDate, + weekStart, + DayComponent = Day, + selected, + ...props +}: Omit & { + DayComponent?: React.FC>; + browsingDate: Dayjs; + weekStart: number; +}) => { + // Create placeholder elements for empty days in first week + const weekdayOfFirst = browsingDate.day(); + + const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null); + for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) { + const date = browsingDate.set("date", day); + days.push(date); + } + return ( + <> + {days.map((day, idx) => ( +
    + {day === null ? ( +
    + ) : props.isLoading ? ( + + ) : ( + { + props.onChange(day); + window.scrollTo({ + top: 360, + behavior: "smooth", + }); + }} + disabled={ + (includedDates && !includedDates.includes(yyyymmdd(day))) || + excludedDates.includes(yyyymmdd(day)) + } + active={selected ? yyyymmdd(selected) === yyyymmdd(day) : false} + /> + )} +
    + ))} + + ); +}; + +const DatePicker = ({ + weekStart = 0, + className, + locale, + selected, + onMonthChange, + ...passThroughProps +}: DatePickerProps & Partial>) => { + const browsingDate = passThroughProps.browsingDate || dayjs().startOf("month"); + + const changeMonth = (newMonth: number) => { + if (onMonthChange) { + onMonthChange(browsingDate.add(newMonth, "month")); + } + }; + + return ( +
    +
    + + {browsingDate ? ( + <> + {browsingDate.format("MMMM")}{" "} + {browsingDate.format("YYYY")} + + ) : ( + + )} + +
    + + +
    +
    +
    + {weekdayNames(locale, weekStart, "short").map((weekDay) => ( +
    + {weekDay} +
    + ))} +
    +
    + +
    +
    + ); +}; + +export default DatePicker; diff --git a/packages/ui/v2/colorpicker.tsx b/packages/ui/v2/colorpicker.tsx new file mode 100644 index 0000000000..baa2da9d7c --- /dev/null +++ b/packages/ui/v2/colorpicker.tsx @@ -0,0 +1,102 @@ +import { useCallback, useRef, useState } from "react"; +import { useEffect } from "react"; +import { HexColorInput, HexColorPicker } from "react-colorful"; + +import { isValidHexCode, fallBackHex } from "@calcom/lib/CustomBranding"; +import Swatch from "@calcom/ui/v2/Swatch"; + +type Handler = (event: MouseEvent | Event) => void; +function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + T extends HTMLElement | void = void +>( + eventName: KW | KH, + handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void, + element?: React.RefObject +) { + // Create a ref that stores handler + const savedHandler = useRef(); + useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current || window; + if (!(targetElement && targetElement.addEventListener)) { + return; + } + // Update saved handler if necessary + if (savedHandler.current !== handler) { + savedHandler.current = handler; + } + // Create event listener that calls handler function stored in ref + const eventListener: typeof handler = (event) => { + // eslint-disable-next-line no-extra-boolean-cast + if (!!savedHandler?.current) { + savedHandler.current(event); + } + }; + targetElement.addEventListener(eventName, eventListener); + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, eventListener); + }; + }, [eventName, element, handler]); +} + +function useOnClickOutside( + ref: React.RefObject, + handler: Handler, + mouseEvent: "mousedown" | "mouseup" = "mousedown" +): void { + useEventListener(mouseEvent, (event) => { + const el = ref?.current; + // Do nothing if clicking ref's element or descendent elements + if (!el || el.contains(event.target as Node)) { + return; + } + handler(event); + }); +} +export type ColorPickerProps = { + defaultValue: string; + onChange: (text: string) => void; +}; + +const ColorPicker = (props: ColorPickerProps) => { + const init = !isValidHexCode(props.defaultValue) + ? fallBackHex(props.defaultValue, false) + : props.defaultValue; + const [color, setColor] = useState(init); + const [isOpen, toggle] = useState(false); + const popover = useRef() as React.MutableRefObject; + const close = useCallback(() => toggle(false), []); + useOnClickOutside(popover, close); + return ( +
    + toggle(!isOpen)} /> + + {isOpen && ( +
    + { + setColor(val); + props.onChange(val); + }} + /> +
    + )} + { + setColor(val); + props.onChange(val); + }} + type="text" + /> +
    + ); +}; + +export default ColorPicker; diff --git a/packages/ui/v2/form/Checkbox.tsx b/packages/ui/v2/form/Checkbox.tsx new file mode 100644 index 0000000000..e83fc4c805 --- /dev/null +++ b/packages/ui/v2/form/Checkbox.tsx @@ -0,0 +1,73 @@ +import React, { forwardRef, InputHTMLAttributes } from "react"; + +import classNames from "@calcom/lib/classNames"; + +type Props = InputHTMLAttributes & { + label?: React.ReactNode; + description: string; + descriptionAsLabel?: boolean; + informationIconText?: string; + error?: boolean; +}; + +const CheckboxField = forwardRef( + ({ label, description, error, informationIconText, disabled, ...rest }, ref) => { + const descriptionAsLabel = !label || rest.descriptionAsLabel; + return ( +
    + {label && ( +
    + {React.createElement( + descriptionAsLabel ? "div" : "label", + { + className: classNames("flex text-sm font-medium text-gray-900"), + ...(!descriptionAsLabel + ? { + htmlFor: rest.id, + } + : {}), + }, + label + )} +
    + )} +
    +
    + {React.createElement( + descriptionAsLabel ? "label" : "div", + { + className: classNames( + "relative flex items-start", + !error && descriptionAsLabel ? "text-gray-900" : "text-neutral-900", + error && "text-red-800" + ), + }, + <> +
    + +
    + {description} + + )} + {/* {informationIconText && } */} +
    +
    +
    + ); + } +); + +CheckboxField.displayName = "CheckboxField"; + +export default CheckboxField; diff --git a/packages/ui/v2/form/DatePicker.tsx b/packages/ui/v2/form/DatePicker.tsx new file mode 100644 index 0000000000..d577aaba41 --- /dev/null +++ b/packages/ui/v2/form/DatePicker.tsx @@ -0,0 +1,33 @@ +import "react-calendar/dist/Calendar.css"; +import "react-date-picker/dist/DatePicker.css"; +import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle"; +import { Calendar } from "react-feather"; + +import classNames from "@calcom/lib/classNames"; + +type Props = { + date: Date; + onDatesChange?: ((date: Date) => void) | undefined; + className?: string; + disabled?: boolean; + minDate?: Date; +}; + +const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => { + return ( + } + value={date} + minDate={minDate} + disabled={disabled} + onChange={onDatesChange} + /> + ); +}; + +export default DatePicker; diff --git a/packages/ui/v2/form/FormStep.tsx b/packages/ui/v2/form/FormStep.tsx new file mode 100644 index 0000000000..0e22ac57df --- /dev/null +++ b/packages/ui/v2/form/FormStep.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import classNames from "@calcom/lib/classNames"; + +type Props = { + steps: number; + currentStep: number; +}; + +// It might be worth passing this label string from outside the component so we can translate it? +function FormStep({ currentStep, steps }: Props) { + return ( +
    +

    + Step {currentStep} of {steps} +

    +
    + {[...Array(steps)].map((_, j) => { + console.log({ j, currentStep }); + return ( +
    = j ? "bg-black" : "bg-gray-400" + )} + key={j} + /> + ); + })} +
    +
    + ); +} + +export default FormStep; diff --git a/packages/ui/v2/form/Select.tsx b/packages/ui/v2/form/Select.tsx new file mode 100644 index 0000000000..bce4d84775 --- /dev/null +++ b/packages/ui/v2/form/Select.tsx @@ -0,0 +1,112 @@ +import { useSelect } from "downshift"; +import { useState } from "react"; + +import { classNames } from "@calcom/lib"; + +type IItem = + | { + itemType: "ListItem"; + title: string; + description: string; + value: string; + } + | { + itemType: "Title"; + title: string; + value: string; + } + | { + itemType: "NameCard"; + img: string; + value: string; + name: string; + } + | { + itemType: "Spacer"; + }; +interface SelectProps { + items: IItem[]; +} + +function itemToString(item: IItem | null) { + return item && item.itemType !== "Spacer" && item?.value ? item.value : ""; +} + +function Select({ items }: SelectProps) { + const [selectedItems, setSelectedItems] = useState([]); + const { + isOpen, + selectedItem, + getToggleButtonProps, + getLabelProps, + getMenuProps, + highlightedIndex, + getItemProps, + } = useSelect({ + items, + itemToString, + selectedItem: null, + onSelectedItemChange: ({ selectedItem }) => { + if (!selectedItem) { + return; + } + + const index = selectedItems.indexOf(selectedItem); + + if (index > 0) { + setSelectedItems([...selectedItems.slice(0, index), ...selectedItems.slice(index + 1)]); + } else if (index === 0) { + setSelectedItems([...selectedItems.slice(1)]); + } else { + setSelectedItems([...selectedItems, selectedItem]); + } + }, + }); + const buttonText = selectedItems.length ? `${selectedItems.length} elements selected` : "Elements"; + + return ( +
    +
    + + +
    +
      + {isOpen && + items.map((item, index) => { + if (item.itemType === "Spacer") return; // Return a spacer that isnt selectable + return ( +
    • + null} + /> +
      + {item.value} + {/* {{item.description}} */} +
      +
    • + ); + })} +
    +
    + ); +} + +export default Select; diff --git a/packages/ui/v2/form/fields.tsx b/packages/ui/v2/form/fields.tsx new file mode 100644 index 0000000000..dfd7b789d3 --- /dev/null +++ b/packages/ui/v2/form/fields.tsx @@ -0,0 +1,294 @@ +import { useId } from "@radix-ui/react-id"; +import React, { forwardRef, ReactElement, ReactNode, Ref } from "react"; +import { AlertCircle, Icon } from "react-feather"; +import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form"; + +import classNames from "@calcom/lib/classNames"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/ui/v2/notfications"; + +import { Alert } from "../Alert"; + +type InputProps = Omit & { name: string }; + +export const Input = forwardRef(function Input(props, ref) { + return ( + + ); +}); + +export function Label(props: JSX.IntrinsicElements["label"]) { + return ( + + ); +} + +export function InputLeading(props: JSX.IntrinsicElements["div"]) { + return ( + + {props.children} + + ); +} + +type InputFieldProps = { + label?: ReactNode; + hint?: ReactNode; + addOnLeading?: ReactNode; + addOnSuffix?: ReactNode; + addOnFilled?: boolean; + error?: string; + labelSrOnly?: boolean; + containerClassName?: string; +} & React.ComponentProps & { + labelProps?: React.ComponentProps; + }; + +const InputField = forwardRef(function InputField(props, ref) { + const id = useId(); + const { t } = useLocale(); + const methods = useFormContext(); + const { + label = t(props.name), + labelProps, + /** Prevents displaying untranslated placeholder keys */ + placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder" + ? t(props.name + "_placeholder") + : "", + className, + addOnLeading, + addOnSuffix, + addOnFilled = true, + hint, + labelSrOnly, + containerClassName, + ...passThrough + } = props; + + return ( +
    + {!!props.name && ( + + )} + {addOnLeading || addOnSuffix ? ( +
    +
    +
    + {addOnLeading || addOnSuffix} +
    +
    + +
    + ) : ( + + )} + {hint && ( +
    + + {hint} +
    + )} + {methods?.formState?.errors[props.name] && ( + + )} +
    + ); +}); + +export const TextField = forwardRef(function TextField(props, ref) { + return ; +}); + +export const PasswordField = forwardRef(function PasswordField( + props, + ref +) { + return ; +}); + +export const EmailInput = forwardRef(function EmailInput(props, ref) { + return ( + + ); +}); + +export const EmailField = forwardRef(function EmailField(props, ref) { + return ( + + ); +}); + +type TextAreaProps = Omit & { name: string }; + +export const TextArea = forwardRef(function TextAreaInput(props, ref) { + return ( +