├── screenshot.png ├── .huskyrc.json ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── preview.png ├── robots.txt ├── manifest.json └── index.html ├── src ├── tests │ ├── slices │ │ ├── utils │ │ │ ├── createAction.js │ │ │ └── createMockStore.js │ │ └── databases │ │ │ ├── reducer.spec.js │ │ │ └── actions.spec.js │ └── components │ │ ├── InlineInput.spec.jsx │ │ └── Sidebar.spec.jsx ├── utils │ ├── config.js │ ├── devices.js │ └── demoState.js ├── App.css ├── components │ ├── MetaInputs │ │ ├── Text │ │ │ ├── Display.jsx │ │ │ ├── filterMethods.jsx │ │ │ └── index.jsx │ │ ├── utils │ │ │ ├── createOption.js │ │ │ ├── PropertyOptionsSelect.jsx │ │ │ ├── SelectWithOptionManager.jsx │ │ │ └── OptionManagerModal.jsx │ │ ├── index.js │ │ ├── Select │ │ │ ├── Display.jsx │ │ │ ├── filterMethods.jsx │ │ │ └── index.jsx │ │ └── MultiSelect │ │ │ ├── Display.jsx │ │ │ ├── filterMethods.jsx │ │ │ └── index.jsx │ ├── Database │ │ ├── Views │ │ │ ├── index.js │ │ │ ├── utils │ │ │ │ ├── applySequence.js │ │ │ │ ├── filterPages.js │ │ │ │ ├── groupPages.js │ │ │ │ ├── SortableList.jsx │ │ │ │ ├── Card.jsx │ │ │ │ └── SortableBoard.jsx │ │ │ ├── List.jsx │ │ │ └── Board.jsx │ │ ├── PropertyForm.jsx │ │ ├── GroupByDropdown.jsx │ │ ├── FiltersDropdown.jsx │ │ ├── ViewSelect.jsx │ │ ├── ViewManagerModal.jsx │ │ ├── PropertiesDropdown.jsx │ │ ├── FilterInput.jsx │ │ └── Database.jsx │ ├── Page │ │ ├── PageHeader.jsx │ │ ├── PageMeta.jsx │ │ └── Page.jsx │ ├── MardownEditor.jsx │ ├── InlineInput.jsx │ └── Sidebar.jsx ├── reducers │ └── index.js ├── index.css ├── setupTests.js ├── store.js ├── index.jsx ├── App.jsx ├── slices │ ├── pages.js │ ├── properties.js │ ├── views.js │ └── databases.js ├── logo.svg └── serviceWorker.js ├── .lintstagedrc.json ├── .editorconfig ├── .eslintrc ├── .gitignore ├── config-overrides.js ├── LICENSE ├── package.json ├── .github └── workflows │ └── workflow.yml └── README.md /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/notence/HEAD/screenshot.png -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/notence/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/notence/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/notence/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/notence/HEAD/public/preview.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/tests/slices/utils/createAction.js: -------------------------------------------------------------------------------- 1 | export default (type, payload) => ({ type, payload }); 2 | -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | googleAnalyticsId: process.env.REACT_APP_GOOGLE_ANALYTICS_ID, 3 | }; 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.+(js|jsx)": ["eslint --fix", "git add"], 3 | "*.+(json|css|md)": ["prettier --write", "git add"] 4 | } 5 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import "~antd/dist/antd.css"; 2 | 3 | /* Prevent covering Ant Modal */ 4 | .ant-dropdown { 5 | z-index: 1000; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Text/Display.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const Display = ({ value }) => value; 4 | Display.propTypes = { 5 | value: PropTypes.string.isRequired, 6 | }; 7 | 8 | export default Display; 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "airbnb", 5 | "prettier", 6 | "prettier/react" 7 | ], 8 | "plugins": [ 9 | "prettier" 10 | ], 11 | "rules": { 12 | "prettier/prettier": "error" 13 | } 14 | } -------------------------------------------------------------------------------- /src/components/Database/Views/index.js: -------------------------------------------------------------------------------- 1 | import ListView from "./List"; 2 | import BoardView from "./Board"; 3 | 4 | const views = { 5 | ListView, 6 | BoardView, 7 | }; 8 | 9 | export const getView = (type) => views[type]; 10 | export const getViewNames = () => Object.keys(views); 11 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import databases from "../slices/databases"; 3 | import views from "../slices/views"; 4 | import pages from "../slices/pages"; 5 | import properties from "../slices/properties"; 6 | 7 | export default combineReducers({ 8 | databases, 9 | views, 10 | pages, 11 | properties, 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/applySequence.js: -------------------------------------------------------------------------------- 1 | const findPage = (pages, pageId) => pages.find((page) => page.id === pageId); 2 | 3 | export default (pages, sequence) => { 4 | const sequentialPages = sequence 5 | .map((pageId) => findPage(pages, pageId)) 6 | .filter((page) => page !== undefined); 7 | const excludedPages = pages.filter((page) => sequence.indexOf(page.id) === -1); 8 | 9 | return [...sequentialPages, ...excludedPages]; 10 | }; 11 | -------------------------------------------------------------------------------- /src/tests/slices/utils/createMockStore.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import configureMockStore from "redux-mock-store"; 3 | import thunk from "redux-thunk"; 4 | 5 | const middlewares = [thunk]; 6 | const mockStore = configureMockStore(middlewares); 7 | 8 | export default (overwrite) => { 9 | const initialState = { databases: {}, views: {}, pages: {}, properties: {}, ...overwrite }; 10 | return mockStore(initialState); 11 | }; 12 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, import/no-extraneous-dependencies */ 2 | 3 | const rewireReactHotLoader = require("react-app-rewire-hot-loader"); 4 | 5 | module.exports = function override(config, env) { 6 | config = rewireReactHotLoader(config, env); 7 | 8 | if (process.env.NODE_ENV === "development") { 9 | config.resolve.alias = { 10 | ...config.resolve.alias, 11 | "react-dom": "@hot-loader/react-dom", 12 | }; 13 | } 14 | 15 | return config; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/MetaInputs/utils/createOption.js: -------------------------------------------------------------------------------- 1 | import shortid from "shortid"; 2 | 3 | const colorMap = [ 4 | "magenta", 5 | "red", 6 | "volcano", 7 | "orange", 8 | "gold", 9 | "lime", 10 | "green", 11 | "cyan", 12 | "blue", 13 | "geekblue", 14 | "purple", 15 | ]; 16 | 17 | const getRandomColor = () => colorMap[Math.floor(Math.random() * colorMap.length)]; 18 | 19 | export default (name) => { 20 | return { 21 | id: shortid.generate(), 22 | name, 23 | color: getRandomColor(), 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/devices.js: -------------------------------------------------------------------------------- 1 | // https://ant.design/components/layout/#breakpoint-width 2 | const breakpoints = { 3 | xs: "480px", 4 | sm: "576px", 5 | md: "768px", 6 | lg: "992px", 7 | xl: "1200px", 8 | xxl: "1600px", 9 | }; 10 | 11 | export default { 12 | xs: `(min-width: ${breakpoints.xs})`, 13 | sm: `(min-width: ${breakpoints.sm})`, 14 | md: `(min-width: ${breakpoints.md})`, 15 | lg: `(min-width: ${breakpoints.lg})`, 16 | xl: `(min-width: ${breakpoints.xl})`, 17 | xxl: `(min-width: ${breakpoints.xxl})`, 18 | }; 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Text/filterMethods.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Input } from "antd"; 4 | 5 | const contains = (value, args) => value.search(args[0]) > -1; 6 | contains.ArgsInput = ({ args, onChange }) => { 7 | const handleChange = (event) => onChange([event.target.value]); 8 | 9 | return ; 10 | }; 11 | contains.ArgsInput.propTypes = { 12 | args: PropTypes.arrayOf(PropTypes.string).isRequired, 13 | onChange: PropTypes.func.isRequired, 14 | }; 15 | 16 | export default { 17 | contains, 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/MetaInputs/index.js: -------------------------------------------------------------------------------- 1 | import Text from "./Text"; 2 | import Select from "./Select"; 3 | import MultiSelect from "./MultiSelect"; 4 | 5 | const metaInputs = { 6 | Text, 7 | Select, 8 | MultiSelect, 9 | }; 10 | 11 | export const getInputNames = () => Object.keys(metaInputs); 12 | export const getInput = (type) => metaInputs[type]; 13 | export const getDefaultValue = (type) => getInput(type).defaultValue; 14 | export const getDefaultAdditional = (type) => getInput(type).defaultAdditional || {}; 15 | export const getDisplay = (type) => getInput(type).Display; 16 | export const getFilterMethods = (type) => getInput(type).filterMethods || {}; 17 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Select/Display.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Tag } from "antd"; 4 | 5 | const Display = ({ 6 | property: { 7 | additional: { options }, 8 | }, 9 | value, 10 | }) => { 11 | const selectedOption = options.find((option) => value === option.id); 12 | if (!selectedOption) { 13 | return null; 14 | } 15 | 16 | return {selectedOption.name}; 17 | }; 18 | Display.propTypes = { 19 | value: PropTypes.string.isRequired, 20 | property: PropTypes.shape({ 21 | additional: PropTypes.object, 22 | }).isRequired, 23 | }; 24 | 25 | export default Display; 26 | -------------------------------------------------------------------------------- /src/components/MetaInputs/MultiSelect/Display.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Tag } from "antd"; 4 | 5 | const Display = ({ 6 | property: { 7 | additional: { options }, 8 | }, 9 | value, 10 | }) => { 11 | const selectedOptions = options.filter((option) => value.indexOf(option.id) > -1); 12 | 13 | return selectedOptions.map((option) => ( 14 | 15 | {option.name} 16 | 17 | )); 18 | }; 19 | Display.propTypes = { 20 | value: PropTypes.arrayOf(PropTypes.string).isRequired, 21 | property: PropTypes.shape({ 22 | additional: PropTypes.object, 23 | }).isRequired, 24 | }; 25 | 26 | export default Display; 27 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Select/filterMethods.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import PropertyOptionsSelect from "../utils/PropertyOptionsSelect"; 4 | 5 | const contains = (value, args) => value === args[0]; 6 | contains.ArgsInput = ({ property, args, onChange }) => { 7 | const handleChange = (value) => onChange([value]); 8 | 9 | return ; 10 | }; 11 | contains.ArgsInput.propTypes = { 12 | args: PropTypes.arrayOf(PropTypes.string).isRequired, 13 | property: PropTypes.shape({ 14 | additional: PropTypes.object, 15 | }).isRequired, 16 | onChange: PropTypes.func.isRequired, 17 | }; 18 | 19 | export default { 20 | contains, 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/MetaInputs/MultiSelect/filterMethods.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import PropertyOptionsSelect from "../utils/PropertyOptionsSelect"; 4 | 5 | const contains = (value, args) => value.indexOf(args[0]) > -1; 6 | contains.ArgsInput = ({ property, args, onChange }) => { 7 | const handleChange = (value) => onChange([value]); 8 | 9 | return ; 10 | }; 11 | contains.ArgsInput.propTypes = { 12 | args: PropTypes.arrayOf(PropTypes.string).isRequired, 13 | property: PropTypes.shape({ 14 | additional: PropTypes.object, 15 | }).isRequired, 16 | onChange: PropTypes.func.isRequired, 17 | }; 18 | 19 | export default { 20 | contains, 21 | }; 22 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | 7 | // https://jestjs.io/docs/en/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 8 | Object.defineProperty(window, "matchMedia", { 9 | writable: true, 10 | value: jest.fn().mockImplementation((query) => ({ 11 | matches: false, 12 | media: query, 13 | onchange: null, 14 | addListener: jest.fn(), // deprecated 15 | removeListener: jest.fn(), // deprecated 16 | addEventListener: jest.fn(), 17 | removeEventListener: jest.fn(), 18 | dispatchEvent: jest.fn(), 19 | })), 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/filterPages.js: -------------------------------------------------------------------------------- 1 | import { getFilterMethods } from "../../../MetaInputs"; 2 | import { getMetaValue } from "../../../../slices/pages"; 3 | 4 | const applyFilterRules = (page, filters, properties) => { 5 | return filters.reduce((included, { propertyId, method, args }) => { 6 | if (!propertyId || !method) { 7 | return included; 8 | } 9 | 10 | const filterProperty = properties.find((property) => property.id === propertyId); 11 | const filterMethod = getFilterMethods(filterProperty.type)[method]; 12 | const propertyValue = getMetaValue(page.meta, filterProperty); 13 | 14 | return included && filterMethod(propertyValue, args); 15 | }, true); 16 | }; 17 | 18 | export default (pages, filters, properties) => { 19 | return pages.filter((page) => applyFilterRules(page, filters, properties)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { persistStore, persistReducer } from "redux-persist"; 3 | import storage from "redux-persist/lib/storage"; 4 | 5 | import rootReducer from "./reducers"; 6 | import demoState from "./utils/demoState"; 7 | 8 | const persistConfig = { 9 | key: "root", 10 | storage, 11 | }; 12 | 13 | const persistedReducer = persistReducer(persistConfig, rootReducer); 14 | 15 | export const store = configureStore({ 16 | reducer: persistedReducer, 17 | preloadedState: demoState, 18 | }); 19 | export const persistor = persistStore(store); 20 | 21 | if (module.hot) { 22 | module.hot.accept("./reducers", () => { 23 | // eslint-disable-next-line global-require 24 | const nextRootReducer = require("./reducers").default; 25 | store.replaceReducer(persistReducer(persistConfig, nextRootReducer)); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Page/PageHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import InlineInput from "../InlineInput"; 4 | import PageMeta from "./PageMeta"; 5 | 6 | function PageHeader({ title, meta, onTitleChange, onMetaChange, onAdditionalChange }) { 7 | return ( 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | } 15 | 16 | PageHeader.propTypes = { 17 | title: PropTypes.string.isRequired, 18 | meta: PropTypes.arrayOf(PropTypes.object).isRequired, 19 | onTitleChange: PropTypes.func.isRequired, 20 | onMetaChange: PropTypes.func.isRequired, 21 | onAdditionalChange: PropTypes.func.isRequired, 22 | }; 23 | 24 | export default PageHeader; 25 | -------------------------------------------------------------------------------- /src/components/MetaInputs/utils/PropertyOptionsSelect.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Select } from "antd"; 4 | 5 | const { Option } = Select; 6 | 7 | const PropertyOptionsSelect = ({ 8 | property: { 9 | additional: { options }, 10 | }, 11 | onChange, 12 | value, 13 | }) => { 14 | return ( 15 | 22 | ); 23 | }; 24 | PropertyOptionsSelect.defaultProps = { 25 | value: null, 26 | }; 27 | PropertyOptionsSelect.propTypes = { 28 | property: PropTypes.shape({ 29 | additional: PropTypes.object, 30 | }).isRequired, 31 | onChange: PropTypes.func.isRequired, 32 | value: PropTypes.string, 33 | }; 34 | 35 | export default PropertyOptionsSelect; 36 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/groupPages.js: -------------------------------------------------------------------------------- 1 | import { getMetaValue } from "../../../../slices/pages"; 2 | 3 | export default (property, pages) => { 4 | if (!property) { 5 | return null; 6 | } 7 | 8 | const { options } = property.additional; 9 | const pageGroups = options.reduce((groups, option) => { 10 | return { 11 | ...groups, 12 | [option.id]: { 13 | name: option.name, 14 | items: [], 15 | }, 16 | }; 17 | }, {}); 18 | 19 | pageGroups.ungrouped = { 20 | name: "No Status", 21 | items: [], 22 | }; 23 | 24 | pages.reduce((groups, page) => { 25 | const groupId = getMetaValue(page.meta, property) || "ungrouped"; 26 | if (groups[groupId]) { 27 | groups[groupId].items.push(page); 28 | } else { 29 | // Handle non-existent group id (the option has been deleted). 30 | groups.ungrouped.items.push(page); 31 | } 32 | 33 | return groups; 34 | }, pageGroups); 35 | 36 | return pageGroups; 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Text/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Input } from "antd"; 5 | import Display from "./Display"; 6 | import filterMethods from "./filterMethods"; 7 | 8 | const TextInput = styled(Input)` 9 | border: none; 10 | border-bottom: 1px dashed black; 11 | border-radius: 0; 12 | 13 | &:focus, 14 | &:hover { 15 | border-color: black; 16 | } 17 | `; 18 | 19 | function Text({ value, onChange }) { 20 | const handleChange = (event) => onChange(event.target.value); 21 | 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | Text.propTypes = { 30 | value: PropTypes.string.isRequired, 31 | onChange: PropTypes.func.isRequired, 32 | }; 33 | 34 | Text.defaultValue = ""; 35 | Text.Display = Display; 36 | Text.filterMethods = filterMethods; 37 | 38 | export default Text; 39 | -------------------------------------------------------------------------------- /src/tests/components/InlineInput.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent } from "@testing-library/react"; 3 | import InlineInput from "../../components/InlineInput"; 4 | 5 | it("should render self", async () => { 6 | const value = "123"; 7 | const tagName = "h1"; 8 | 9 | const { getByTestId } = render( 10 | {}} /> 11 | ); 12 | const inputElement = getByTestId("input"); 13 | 14 | expect(inputElement.innerText).toBe(value); 15 | expect(inputElement.tagName).toBe("H1"); 16 | }); 17 | 18 | it("should call onChange when user types some text", async () => { 19 | const expected = "kanahei"; 20 | const handleChange = jest.fn(); 21 | 22 | const { getByTestId } = render(); 23 | const inputElement = getByTestId("input"); 24 | 25 | inputElement.innerText = expected; 26 | fireEvent.input(inputElement); 27 | 28 | expect(handleChange.mock.calls[0][0]).toBe(expected); 29 | }); 30 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import { PersistGate } from "redux-persist/integration/react"; 5 | import ReactGA from "react-ga"; 6 | import { store, persistor } from "./store"; 7 | import "./index.css"; 8 | import App from "./App"; 9 | import * as serviceWorker from "./serviceWorker"; 10 | import config from "./utils/config"; 11 | 12 | if (config.googleAnalyticsId) { 13 | ReactGA.initialize(config.googleAnalyticsId); 14 | ReactGA.pageview(window.location.pathname + window.location.search); 15 | } 16 | 17 | ReactDOM.render( 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById("root") 24 | ); 25 | 26 | // If you want your app to work offline and load faster, you can change 27 | // unregister() to register() below. Note this comes with some pitfalls. 28 | // Learn more about service workers: https://bit.ly/CRA-PWA 29 | serviceWorker.unregister(); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yong-Yuan Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/MardownEditor.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import React, { useRef } from "react"; 4 | import PropTypes from "prop-types"; 5 | import "codemirror/lib/codemirror.css"; 6 | import "@toast-ui/editor/dist/toastui-editor.css"; 7 | import { Editor } from "@toast-ui/react-editor"; 8 | 9 | export default function MarkdownEditor({ value, onChange, height, previewStyle }) { 10 | const editor = useRef(null); 11 | 12 | const handleChange = () => { 13 | onChange(editor.current.getInstance().getMarkdown()); 14 | }; 15 | 16 | return ( 17 | 26 | ); 27 | } 28 | 29 | MarkdownEditor.defaultProps = { 30 | height: "auto", 31 | previewStyle: "vertical", 32 | }; 33 | 34 | MarkdownEditor.propTypes = { 35 | value: PropTypes.string.isRequired, 36 | onChange: PropTypes.func.isRequired, 37 | height: PropTypes.string, 38 | previewStyle: PropTypes.string, 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/MetaInputs/Select/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import SelectWithOptionManager from "../utils/SelectWithOptionManager"; 4 | import Display from "./Display"; 5 | import filterMethods from "./filterMethods"; 6 | 7 | function Select({ value, onChange, additional, additional: { options }, onAdditionalChange }) { 8 | const isValueValid = options.findIndex((option) => option.id === value) > -1; 9 | 10 | return ( 11 | 17 | ); 18 | } 19 | 20 | Select.propTypes = { 21 | value: PropTypes.string.isRequired, 22 | onChange: PropTypes.func.isRequired, 23 | additional: PropTypes.shape({ 24 | options: PropTypes.array, 25 | }).isRequired, 26 | onAdditionalChange: PropTypes.func.isRequired, 27 | }; 28 | 29 | Select.defaultValue = ""; 30 | Select.defaultAdditional = { 31 | options: [], 32 | }; 33 | Select.Display = Display; 34 | Select.filterMethods = filterMethods; 35 | 36 | export default Select; 37 | -------------------------------------------------------------------------------- /src/components/InlineInput.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function InlineInput({ tagName, className, value, onChange, multiLine }) { 5 | const input = useRef(null); 6 | 7 | const handleChange = () => { 8 | onChange(input.current.innerText); 9 | }; 10 | 11 | const handleKeyPress = (event) => { 12 | if (event.key === "Enter" && multiLine === false) { 13 | event.preventDefault(); 14 | } 15 | }; 16 | 17 | useEffect(() => { 18 | if (value !== input.current.innerText) { 19 | input.current.innerText = value; 20 | } 21 | }, [value]); 22 | 23 | return React.createElement(tagName, { 24 | ref: input, 25 | contentEditable: true, 26 | onKeyPress: handleKeyPress, 27 | onInput: handleChange, 28 | className, 29 | "data-testid": "input", 30 | }); 31 | } 32 | 33 | InlineInput.defaultProps = { 34 | tagName: "div", 35 | multiLine: false, 36 | className: "", 37 | }; 38 | 39 | InlineInput.propTypes = { 40 | value: PropTypes.string.isRequired, 41 | onChange: PropTypes.func.isRequired, 42 | tagName: PropTypes.string, 43 | multiLine: PropTypes.bool, 44 | className: PropTypes.string, 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/MetaInputs/MultiSelect/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import SelectWithOptionManager from "../utils/SelectWithOptionManager"; 4 | import Display from "./Display"; 5 | import filterMethods from "./filterMethods"; 6 | 7 | function MultiSelect({ value, onChange, additional, additional: { options }, onAdditionalChange }) { 8 | const isValid = (optionId) => options.findIndex((option) => option.id === optionId) > -1; 9 | const validValue = value.filter(isValid); 10 | 11 | return ( 12 | 19 | ); 20 | } 21 | 22 | MultiSelect.propTypes = { 23 | value: PropTypes.arrayOf(PropTypes.string).isRequired, 24 | onChange: PropTypes.func.isRequired, 25 | additional: PropTypes.shape({ 26 | options: PropTypes.array, 27 | }).isRequired, 28 | onAdditionalChange: PropTypes.func.isRequired, 29 | }; 30 | 31 | MultiSelect.defaultValue = []; 32 | MultiSelect.defaultAdditional = { 33 | options: [], 34 | }; 35 | MultiSelect.Display = Display; 36 | MultiSelect.filterMethods = filterMethods; 37 | 38 | export default MultiSelect; 39 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { hot } from "react-hot-loader/root"; 4 | import styled from "styled-components"; 5 | import { Layout, Empty } from "antd"; 6 | import "./App.css"; 7 | // eslint-disable-next-line import/no-named-as-default 8 | import Sidebar from "./components/Sidebar"; 9 | import Database from "./components/Database/Database"; 10 | 11 | const { Content } = Layout; 12 | 13 | const AppLayout = styled(Layout)` 14 | height: 100vh; 15 | `; 16 | 17 | const AppContent = styled(Content)` 18 | background-color: white; 19 | `; 20 | 21 | const EmptyView = styled(Empty)` 22 | margin-top: 12em; 23 | `; 24 | 25 | function App() { 26 | const [currentDatabaseId, setCurrentDatabaseId] = useState(null); 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | {currentDatabaseId ? ( 34 | 35 | ) : ( 36 | 37 | )} 38 | 39 | 40 |
41 | ); 42 | } 43 | 44 | export default process.env.NODE_ENV === "development" ? hot(App) : App; 45 | -------------------------------------------------------------------------------- /src/slices/pages.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import { createSlice } from "@reduxjs/toolkit"; 4 | import shortid from "shortid"; 5 | import { getDefaultValue } from "../components/MetaInputs"; 6 | 7 | const initialState = {}; 8 | 9 | const slice = createSlice({ 10 | name: "pages", 11 | initialState, 12 | reducers: { 13 | create: (state, { payload: { id, title, meta = {}, content = "" } }) => { 14 | state[id] = { 15 | id, 16 | title, 17 | meta, 18 | content, 19 | }; 20 | }, 21 | updateMeta: (state, { payload: { pageId, propertyId, value } }) => { 22 | state[pageId].meta[propertyId] = value; 23 | }, 24 | updateContent: (state, { payload: { pageId, content } }) => { 25 | state[pageId].content = content; 26 | }, 27 | updateTitle: (state, { payload: { pageId, title } }) => { 28 | state[pageId].title = title; 29 | }, 30 | remove(state, { payload: { pageId } }) { 31 | delete state[pageId]; 32 | }, 33 | }, 34 | }); 35 | 36 | export const { create, updateMeta, updateContent, updateTitle, remove } = slice.actions; 37 | 38 | export default slice.reducer; 39 | 40 | export const createPage = ({ title, id, meta }) => (dispatch) => { 41 | const page = { 42 | title, 43 | meta, 44 | id: id || shortid.generate(), 45 | }; 46 | 47 | dispatch(create(page)); 48 | }; 49 | 50 | export const getMetaValue = (meta, property) => 51 | meta[property.id] !== undefined ? meta[property.id] : getDefaultValue(property.type); 52 | -------------------------------------------------------------------------------- /src/components/Page/PageMeta.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { getInput } from "../MetaInputs"; 5 | import devices from "../../utils/devices"; 6 | 7 | const Meta = styled.div` 8 | display: flex; 9 | align-items: flex-end; 10 | margin-bottom: 3px; 11 | 12 | .meta-name { 13 | width: 80px; 14 | margin-right: 10px; 15 | font-size: 1.15em; 16 | 17 | @media screen and ${devices.lg} { 18 | width: 150px; 19 | } 20 | } 21 | 22 | .meta-input { 23 | flex: 1; 24 | } 25 | `; 26 | 27 | export default function PageMeta({ meta, onMetaChange, onAdditionalChange }) { 28 | const list = meta.map(({ property: { id, name, additional, type }, value }) => { 29 | const handleChange = (newValue) => onMetaChange(id, newValue); 30 | const handleAdditionChange = (additionalChange) => onAdditionalChange(id, additionalChange); 31 | 32 | const MetaInput = getInput(type); 33 | 34 | return ( 35 | 36 |
{name}
37 |
38 | 44 |
45 | 46 | ); 47 | }); 48 | 49 | return
{list}
; 50 | } 51 | 52 | PageMeta.propTypes = { 53 | meta: PropTypes.arrayOf(PropTypes.object).isRequired, 54 | onMetaChange: PropTypes.func.isRequired, 55 | onAdditionalChange: PropTypes.func.isRequired, 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/SortableList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; 4 | 5 | export default function SortableList({ items, onSort, children }) { 6 | const handleDragEnd = (result) => { 7 | if (!result.destination) { 8 | return; 9 | } 10 | 11 | const [startIndex, endIndex] = [result.source.index, result.destination.index]; 12 | onSort({ startIndex, endIndex }); 13 | }; 14 | 15 | const renderListItem = (item) => (draggableProvided) => { 16 | const restProps = { 17 | innerRef: draggableProvided.innerRef, 18 | ...draggableProvided.draggableProps, 19 | ...draggableProvided.dragHandleProps, 20 | }; 21 | 22 | return children(item, restProps); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | {(provided) => ( 29 | // eslint-disable-next-line react/jsx-props-no-spreading 30 |
31 | {items.map((item, index) => ( 32 | 33 | {renderListItem(item)} 34 | 35 | ))} 36 | {provided.placeholder} 37 |
38 | )} 39 |
40 |
41 | ); 42 | } 43 | 44 | SortableList.propTypes = { 45 | children: PropTypes.func.isRequired, 46 | onSort: PropTypes.func.isRequired, 47 | items: PropTypes.arrayOf(PropTypes.object).isRequired, 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Database/PropertyForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Form, Input, Button, Select } from "antd"; 4 | import { getInputNames } from "../MetaInputs"; 5 | 6 | const { Option } = Select; 7 | 8 | function PropertyForm({ onFinish }) { 9 | const form = useRef(null); 10 | 11 | const handleFormFinish = (value) => { 12 | onFinish(value); 13 | form.current.resetFields(); 14 | }; 15 | 16 | return ( 17 |
18 | 23 | 24 | 25 | 26 | 31 | 38 | 39 | 40 | 46 | 49 | 50 |
51 | ); 52 | } 53 | 54 | PropertyForm.propTypes = { 55 | onFinish: PropTypes.func.isRequired, 56 | }; 57 | 58 | export default PropertyForm; 59 | -------------------------------------------------------------------------------- /src/components/Database/GroupByDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Button, Menu, Dropdown } from "antd"; 5 | import { CheckOutlined } from "@ant-design/icons"; 6 | 7 | const PropertyItem = styled(Menu.Item)` 8 | display: flex; 9 | align-items: center; 10 | min-width: 130px; 11 | 12 | .name { 13 | margin-right: auto; 14 | padding-right: 1em; 15 | } 16 | `; 17 | 18 | function GroupByDropdown({ properties, groupBy: { propertyId }, onGroupByChange }) { 19 | const [dropdownVisible, setDropdownVisible] = useState(false); 20 | 21 | const selectTypeProprties = properties.filter((property) => property.type === "Select"); 22 | const propertyList = selectTypeProprties.map((property) => ( 23 | onGroupByChange(property.id)}> 24 | {property.name} 25 | {property.id === propertyId && } 26 | 27 | )); 28 | 29 | const menu = {propertyList}; 30 | 31 | return ( 32 | 38 | 41 | 42 | ); 43 | } 44 | 45 | GroupByDropdown.propTypes = { 46 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 47 | groupBy: PropTypes.shape({ 48 | propertyId: PropTypes.string, 49 | }).isRequired, 50 | onGroupByChange: PropTypes.func.isRequired, 51 | }; 52 | 53 | export default GroupByDropdown; 54 | -------------------------------------------------------------------------------- /src/components/Database/FiltersDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button, Menu, Dropdown } from "antd"; 4 | import { PlusOutlined } from "@ant-design/icons"; 5 | import FilterInput from "./FilterInput"; 6 | 7 | function FiltersDropdown({ properties, filters, onFilterCreate, onFilterChange, onFilterDelete }) { 8 | const [dropdownVisible, setDropdownVisible] = useState(false); 9 | 10 | const filterList = filters.map((filter) => ( 11 | onFilterChange(filter.id, newFilter)} 15 | onDelete={() => onFilterDelete(filter.id)} 16 | properties={properties} 17 | /> 18 | )); 19 | 20 | const menu = ( 21 | 22 | {filterList} 23 | 24 | 25 | 26 | 29 | 30 | 31 | ); 32 | 33 | return ( 34 | 40 | 43 | 44 | ); 45 | } 46 | 47 | FiltersDropdown.propTypes = { 48 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 49 | filters: PropTypes.arrayOf(PropTypes.object).isRequired, 50 | onFilterCreate: PropTypes.func.isRequired, 51 | onFilterChange: PropTypes.func.isRequired, 52 | onFilterDelete: PropTypes.func.isRequired, 53 | }; 54 | 55 | export default FiltersDropdown; 56 | -------------------------------------------------------------------------------- /src/slices/properties.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import { createSlice } from "@reduxjs/toolkit"; 4 | import shortid from "shortid"; 5 | import { getDefaultAdditional } from "../components/MetaInputs"; 6 | import { deleteFilter } from "./views"; 7 | 8 | const initialState = {}; 9 | 10 | const slice = createSlice({ 11 | name: "properties", 12 | initialState, 13 | reducers: { 14 | create: (state, { payload: { id, name, type = "Text", additional = {} } }) => { 15 | state[id] = { 16 | id, 17 | name, 18 | type, 19 | additional, 20 | }; 21 | }, 22 | updateAdditional: (state, { payload: { propertyId, additionalChange } }) => { 23 | state[propertyId].additional = { 24 | ...state[propertyId].additional, 25 | ...additionalChange, 26 | }; 27 | }, 28 | remove: (state, { payload: { propertyId } }) => { 29 | delete state[propertyId]; 30 | }, 31 | }, 32 | }); 33 | 34 | export const { create, updateAdditional, remove } = slice.actions; 35 | 36 | export default slice.reducer; 37 | 38 | export const createProperty = ({ name, type, id }) => (dispatch) => { 39 | const additional = getDefaultAdditional(type); 40 | 41 | const property = { 42 | name, 43 | type, 44 | id: id || shortid.generate(), 45 | additional, 46 | }; 47 | 48 | dispatch(create(property)); 49 | }; 50 | 51 | export const removeProperty = (propertyId) => (dispatch, getState) => { 52 | const { views } = getState(); 53 | 54 | Object.keys(views).forEach((viewId) => { 55 | views[viewId].filters.forEach((filter) => { 56 | if (filter.propertyId === propertyId) { 57 | dispatch(deleteFilter({ viewId, filterId: filter.id })); 58 | } 59 | }); 60 | }); 61 | 62 | dispatch(remove({ propertyId })); 63 | }; 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notence", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^4.1.0", 7 | "@reduxjs/toolkit": "^1.3.6", 8 | "@testing-library/jest-dom": "^4.2.4", 9 | "@testing-library/react": "^9.5.0", 10 | "@testing-library/user-event": "^7.2.1", 11 | "@toast-ui/react-editor": "^2.1.1", 12 | "antd": "^4.2.0", 13 | "prop-types": "^15.7.2", 14 | "react": "^16.13.1", 15 | "react-beautiful-dnd": "^13.0.0", 16 | "react-dom": "^16.13.1", 17 | "react-ga": "^2.7.0", 18 | "react-redux": "^7.2.0", 19 | "react-scripts": "3.4.1", 20 | "redux": "^4.0.5", 21 | "redux-persist": "^6.0.0", 22 | "redux-thunk": "^2.3.0", 23 | "shortid": "^2.2.15", 24 | "styled-components": "^5.1.0" 25 | }, 26 | "scripts": { 27 | "start": "react-app-rewired start", 28 | "build": "react-app-rewired build", 29 | "test": "react-app-rewired test", 30 | "eject": "react-scripts eject", 31 | "lint": "eslint --ext .jsx,.js src/" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@hot-loader/react-dom": "^16.13.0", 47 | "eslint-config-airbnb": "^18.1.0", 48 | "eslint-config-prettier": "^6.11.0", 49 | "eslint-plugin-prettier": "^3.1.3", 50 | "husky": "^4.2.5", 51 | "lint-staged": "^10.2.2", 52 | "prettier": "^2.0.5", 53 | "react-app-rewire-hot-loader": "^2.0.1", 54 | "react-app-rewired": "^2.1.6", 55 | "react-hot-loader": "^4.12.21", 56 | "redux-mock-store": "^1.5.4" 57 | }, 58 | "homepage": "/notence" 59 | } 60 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | Notence 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/Card.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { DeleteOutlined } from "@ant-design/icons"; 5 | 6 | const CardWrapper = styled.div` 7 | position: relative; 8 | display: flex; 9 | flex-wrap: wrap; 10 | align-items: center; 11 | padding: 8px 16px; 12 | border-bottom: 1px solid #eee; 13 | word-break: break-all; 14 | user-select: none; 15 | 16 | .title { 17 | margin: 0; 18 | margin-right: auto; 19 | padding-right: 1em; 20 | font-size: 14px; 21 | } 22 | 23 | .property-list { 24 | display: flex; 25 | flex-wrap: wrap; 26 | align-items: center; 27 | 28 | .property { 29 | margin: 0 6px; 30 | } 31 | } 32 | 33 | .delete-btn { 34 | position: absolute; 35 | top: 10px; 36 | right: 8px; 37 | 38 | cursor: pointer; 39 | user-select: none; 40 | } 41 | `; 42 | 43 | const Card = ({ title, properties, onDelete, innerRef, ...rest }) => { 44 | const handleClick = (event) => { 45 | event.stopPropagation(); 46 | 47 | onDelete(); 48 | }; 49 | 50 | const propertyList = ( 51 |
52 | {properties.map(({ Display, property, value }) => ( 53 |
54 | 55 |
56 | ))} 57 |
58 | ); 59 | 60 | return ( 61 | // eslint-disable-next-line react/jsx-props-no-spreading 62 | 63 |

{title}

64 | 65 | {propertyList} 66 | 67 | 68 |
69 | ); 70 | }; 71 | 72 | Card.defaultProps = { 73 | innerRef: null, 74 | }; 75 | 76 | Card.propTypes = { 77 | title: PropTypes.string.isRequired, 78 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 79 | onDelete: PropTypes.func.isRequired, 80 | innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]), 81 | }; 82 | 83 | export default Card; 84 | -------------------------------------------------------------------------------- /src/components/Database/ViewSelect.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { SettingOutlined } from "@ant-design/icons"; 5 | import { Select as AntSelect, Divider, Button } from "antd"; 6 | import ViewManagerModal from "./ViewManagerModal"; 7 | 8 | const { Option } = AntSelect; 9 | 10 | const ViewsSelect = styled(AntSelect)` 11 | width: 130px; 12 | `; 13 | 14 | function ViewSelect({ views, currentViewId, onChange, onCreate, onDelete, onRename }) { 15 | const [modalVisible, setModalVisible] = useState(false); 16 | const openModal = () => setModalVisible(true); 17 | const closeModal = () => setModalVisible(false); 18 | 19 | const handleViewDelete = (viewId) => { 20 | if (viewId === currentViewId) { 21 | const otherView = views.find((view) => view.id !== viewId); 22 | onChange(otherView.id); 23 | } 24 | 25 | onDelete(viewId); 26 | }; 27 | 28 | return ( 29 | ( 34 |
35 | {menu} 36 | 37 | 38 | 41 | 42 | 50 |
51 | )} 52 | > 53 | {views.map((view) => ( 54 | 57 | ))} 58 |
59 | ); 60 | } 61 | 62 | ViewSelect.propTypes = { 63 | views: PropTypes.arrayOf(PropTypes.object).isRequired, 64 | currentViewId: PropTypes.string.isRequired, 65 | onChange: PropTypes.func.isRequired, 66 | onCreate: PropTypes.func.isRequired, 67 | onDelete: PropTypes.func.isRequired, 68 | onRename: PropTypes.func.isRequired, 69 | }; 70 | 71 | export default ViewSelect; 72 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 12 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | - name: Install Dependencies 18 | run: npm ci 19 | - name: Run eslint 20 | run: npm run lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js 12 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 12 31 | - name: Install Dependencies 32 | run: npm ci 33 | - name: Run test 34 | run: npm run test 35 | 36 | build: 37 | if: github.ref == 'refs/heads/master' 38 | runs-on: ubuntu-latest 39 | needs: [test, lint] 40 | env: 41 | REACT_APP_GOOGLE_ANALYTICS_ID: ${{secrets.google_analytics_id}} 42 | 43 | steps: 44 | - uses: actions/checkout@v2 45 | - name: Use Node.js 12 46 | uses: actions/setup-node@v1 47 | with: 48 | node-version: 12 49 | - name: Install Dependencies 50 | run: npm ci 51 | - name: Build demo site 52 | run: npm run build 53 | - name: Upload demo site 54 | uses: actions/upload-artifact@v1 55 | with: 56 | name: demo-site 57 | path: build 58 | 59 | deploy: 60 | if: github.ref == 'refs/heads/master' 61 | runs-on: ubuntu-latest 62 | needs: build 63 | env: 64 | DEPLOY_TOKEN: ${{secrets.deploy_token}} 65 | USER_NAME: yoychen 66 | USER_EMAIL: yui12327@gmail.com 67 | PUBLISH_DIR: ./demo-site 68 | 69 | steps: 70 | - name: Download demo site 71 | uses: actions/download-artifact@v1 72 | with: 73 | name: demo-site 74 | - name: Deploy demo site 75 | run: | 76 | cd $PUBLISH_DIR 77 | git init 78 | git config --local user.email $USER_EMAIL 79 | git config --local user.name $USER_NAME 80 | git remote add origin https://$DEPLOY_TOKEN@github.com/$GITHUB_REPOSITORY.git 81 | git checkout -b gh-pages 82 | git add --all 83 | git commit -m "Deploy to GitHub Pages" 84 | git push origin gh-pages -f 85 | -------------------------------------------------------------------------------- /src/tests/slices/databases/reducer.spec.js: -------------------------------------------------------------------------------- 1 | import reducer, { create, rename, popView, popPage, popProperty } from "../../../slices/databases"; 2 | import createAction from "../utils/createAction"; 3 | 4 | test("create", () => { 5 | const state = {}; 6 | const database = { 7 | id: "123", 8 | name: "tutu", 9 | }; 10 | 11 | const nextState = reducer( 12 | state, 13 | createAction(create.type, { 14 | ...database, 15 | }) 16 | ); 17 | 18 | expect(nextState).toEqual({ 19 | [database.id]: { 20 | ...database, 21 | pages: [], 22 | views: [], 23 | properties: [], 24 | }, 25 | }); 26 | }); 27 | 28 | test("rename", () => { 29 | const databaseId = "321"; 30 | const newName = "kanahei"; 31 | 32 | const state = { 33 | [databaseId]: { 34 | name: "tutu", 35 | }, 36 | }; 37 | 38 | const nextState = reducer( 39 | state, 40 | createAction(rename.type, { 41 | databaseId, 42 | newName, 43 | }) 44 | ); 45 | 46 | expect(nextState[databaseId].name).toEqual(newName); 47 | }); 48 | 49 | test("popView", () => { 50 | const databaseId = "321"; 51 | const viewId = "456"; 52 | 53 | const state = { 54 | [databaseId]: { 55 | views: ["123", viewId, "789"], 56 | }, 57 | }; 58 | 59 | const nextState = reducer( 60 | state, 61 | createAction(popView.type, { 62 | databaseId, 63 | viewId, 64 | }) 65 | ); 66 | 67 | expect(nextState[databaseId].views).toEqual(["123", "789"]); 68 | }); 69 | 70 | test("popPage", () => { 71 | const databaseId = "321"; 72 | const pageId = "456"; 73 | 74 | const state = { 75 | [databaseId]: { 76 | pages: ["123", pageId, "789"], 77 | }, 78 | }; 79 | 80 | const nextState = reducer( 81 | state, 82 | createAction(popPage.type, { 83 | databaseId, 84 | pageId, 85 | }) 86 | ); 87 | 88 | expect(nextState[databaseId].pages).toEqual(["123", "789"]); 89 | }); 90 | 91 | test("popProperty", () => { 92 | const databaseId = "321"; 93 | const propertyId = "456"; 94 | 95 | const state = { 96 | [databaseId]: { 97 | properties: ["123", propertyId, "789"], 98 | }, 99 | }; 100 | 101 | const nextState = reducer( 102 | state, 103 | createAction(popProperty.type, { 104 | databaseId, 105 | propertyId, 106 | }) 107 | ); 108 | 109 | expect(nextState[databaseId].properties).toEqual(["123", "789"]); 110 | }); 111 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/MetaInputs/utils/SelectWithOptionManager.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Select as AntSelect, Divider, Button } from "antd"; 5 | import { SettingOutlined } from "@ant-design/icons"; 6 | import OptionManagerModal from "./OptionManagerModal"; 7 | 8 | const { Option } = AntSelect; 9 | 10 | const SelectInput = styled(AntSelect)` 11 | width: 100%; 12 | 13 | &:not(.ant-select-customize-input) .ant-select-selector { 14 | border: none; 15 | border-bottom: 1px dashed black; 16 | border-radius: 0; 17 | 18 | &:focus, 19 | &:hover { 20 | border-color: black; 21 | } 22 | } 23 | `; 24 | 25 | function Select({ mode, value, onChange, additional: { options }, onAdditionalChange }) { 26 | const [optionManagerVisible, setOptionManagerVisible] = useState(false); 27 | const openModal = () => setOptionManagerVisible(true); 28 | const closeModal = () => setOptionManagerVisible(false); 29 | 30 | const updateOptions = (newOptions) => { 31 | onAdditionalChange({ options: newOptions }); 32 | }; 33 | 34 | const filterOption = (inputValue, option) => option.children.search(inputValue) > -1; 35 | 36 | return ( 37 | ( 46 |
47 | {menu} 48 | 49 | 50 | 53 | 54 | 60 |
61 | )} 62 | > 63 | {options.map(({ name, id }) => ( 64 | 67 | ))} 68 |
69 | ); 70 | } 71 | 72 | Select.defaultProps = { 73 | mode: undefined, 74 | }; 75 | 76 | Select.propTypes = { 77 | mode: PropTypes.string, 78 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]).isRequired, 79 | onChange: PropTypes.func.isRequired, 80 | additional: PropTypes.shape({ 81 | options: PropTypes.array, 82 | }).isRequired, 83 | onAdditionalChange: PropTypes.func.isRequired, 84 | }; 85 | 86 | export default Select; 87 | -------------------------------------------------------------------------------- /src/components/MetaInputs/utils/OptionManagerModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Button, Modal as AntModal, Input, Tag } from "antd"; 5 | import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; 6 | import createOption from "./createOption"; 7 | 8 | const OptionForm = styled.form` 9 | display: flex; 10 | align-items: center; 11 | `; 12 | 13 | const OptionItem = styled.div` 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | padding: 8px 24px; 18 | 19 | &:hover { 20 | background: #e7e7e78c; 21 | } 22 | `; 23 | 24 | const DeleteBtn = styled(DeleteOutlined)` 25 | cursor: pointer; 26 | `; 27 | 28 | const Modal = styled(AntModal)` 29 | .ant-modal-body { 30 | padding: 0; 31 | } 32 | `; 33 | 34 | function OptionManagerModal({ visible, onCancel, options, onChange }) { 35 | const optionInput = useRef(null); 36 | 37 | const addOption = () => { 38 | const optionName = optionInput.current.state.value 39 | ? optionInput.current.state.value.trim() 40 | : ""; 41 | const hasDuplicated = !!options.find((option) => option.name === optionName); 42 | 43 | if (optionName === "" || hasDuplicated) { 44 | return; 45 | } 46 | 47 | const newOption = createOption(optionName); 48 | onChange([...options, newOption]); 49 | optionInput.current.state.value = ""; 50 | }; 51 | 52 | const removeOption = (optionId) => { 53 | const newOptions = options.filter((option) => option.id !== optionId); 54 | 55 | onChange(newOptions); 56 | }; 57 | 58 | const handleSubmit = (event) => { 59 | event.preventDefault(); 60 | addOption(); 61 | }; 62 | 63 | return ( 64 | 70 | 71 | 74 | 75 | } 76 | > 77 | {options.map(({ name, id }) => ( 78 | 79 | {name} removeOption(id)} /> 80 | 81 | ))} 82 | 83 | ); 84 | } 85 | 86 | OptionManagerModal.propTypes = { 87 | visible: PropTypes.bool.isRequired, 88 | onCancel: PropTypes.func.isRequired, 89 | options: PropTypes.arrayOf(PropTypes.object).isRequired, 90 | onChange: PropTypes.func.isRequired, 91 | }; 92 | 93 | export default OptionManagerModal; 94 | -------------------------------------------------------------------------------- /src/components/Page/Page.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import styled from "styled-components"; 5 | import PageHeader from "./PageHeader"; 6 | import MardownEditor from "../MardownEditor"; 7 | import { updateContent, updateTitle, updateMeta, getMetaValue } from "../../slices/pages"; 8 | import { updateAdditional } from "../../slices/properties"; 9 | import devices from "../../utils/devices"; 10 | 11 | const PageWrapper = styled.div` 12 | @media screen and ${devices.lg} { 13 | padding: 20px; 14 | } 15 | `; 16 | 17 | const PageContent = styled.div` 18 | margin-top: 35px; 19 | `; 20 | 21 | function Page({ 22 | title, 23 | meta, 24 | content, 25 | onContentChange, 26 | onTitleChange, 27 | onMetaChange, 28 | onAdditionalChange, 29 | }) { 30 | const isLg = window.matchMedia(devices.lg).matches; 31 | const previewStyle = isLg ? "vertical" : "tab"; 32 | 33 | return ( 34 | 35 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | Page.propTypes = { 51 | title: PropTypes.string.isRequired, 52 | meta: PropTypes.arrayOf(PropTypes.object).isRequired, 53 | content: PropTypes.string.isRequired, 54 | onContentChange: PropTypes.func.isRequired, 55 | onTitleChange: PropTypes.func.isRequired, 56 | onMetaChange: PropTypes.func.isRequired, 57 | onAdditionalChange: PropTypes.func.isRequired, 58 | }; 59 | 60 | const getMeta = (state, pageId, properties) => { 61 | const { meta } = state.pages[pageId]; 62 | 63 | return properties.map((property) => ({ 64 | property, 65 | value: getMetaValue(meta, property), 66 | })); 67 | }; 68 | 69 | const mapStateToProps = (state, { pageId, properties }) => ({ 70 | title: state.pages[pageId].title, 71 | meta: getMeta(state, pageId, properties), 72 | content: state.pages[pageId].content, 73 | }); 74 | 75 | const mapDispatchToProps = (dispatch, { pageId }) => { 76 | return { 77 | onContentChange: (content) => dispatch(updateContent({ pageId, content })), 78 | onTitleChange: (title) => dispatch(updateTitle({ pageId, title })), 79 | onMetaChange: (propertyId, value) => dispatch(updateMeta({ pageId, propertyId, value })), 80 | onAdditionalChange: (propertyId, additionalChange) => 81 | dispatch(updateAdditional({ propertyId, additionalChange })), 82 | }; 83 | }; 84 | 85 | export default connect(mapStateToProps, mapDispatchToProps)(Page); 86 | -------------------------------------------------------------------------------- /src/components/Database/Views/utils/SortableBoard.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Button } from "antd"; 5 | import { PlusOutlined } from "@ant-design/icons"; 6 | import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; 7 | 8 | const BoardWrapper = styled.div` 9 | display: flex; 10 | overflow: auto; 11 | `; 12 | 13 | const Board = styled.div` 14 | display: flex; 15 | flex-direction: column; 16 | flex-shrink: 0; 17 | width: 220px; 18 | 19 | & + & { 20 | margin-left: 1em; 21 | } 22 | `; 23 | 24 | const BoardTitle = styled.div` 25 | margin: 0 0.25em 1em; 26 | user-select: none; 27 | `; 28 | 29 | const BoardContent = styled.div` 30 | flex: 1; 31 | padding-bottom: 1.5em; 32 | `; 33 | 34 | const AddBtn = styled(Button)` 35 | width: 100%; 36 | padding: 0.25em; 37 | border: none; 38 | text-align: left; 39 | `; 40 | 41 | export default function SortableBoard({ groups, onChange, onItemCreate, children }) { 42 | const handleDragEnd = (result) => { 43 | if (!result.destination) { 44 | return; 45 | } 46 | 47 | onChange(result); 48 | }; 49 | 50 | const renderItem = (item) => (draggableProvided) => { 51 | const restProps = { 52 | innerRef: draggableProvided.innerRef, 53 | ...draggableProvided.draggableProps, 54 | ...draggableProvided.dragHandleProps, 55 | }; 56 | 57 | return children(item, restProps); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | {Object.entries(groups).map(([groupId, group]) => ( 64 | 65 | {group.name} 66 | 67 | 68 | {(provided) => ( 69 | // eslint-disable-next-line react/jsx-props-no-spreading 70 | 71 | {group.items.map((item, index) => ( 72 | 73 | {renderItem(item)} 74 | 75 | ))} 76 | {provided.placeholder} 77 | 78 | onItemCreate(groupId)} icon={}> 79 | New 80 | 81 | 82 | )} 83 | 84 | 85 | ))} 86 | 87 | 88 | ); 89 | } 90 | 91 | SortableBoard.propTypes = { 92 | children: PropTypes.func.isRequired, 93 | onChange: PropTypes.func.isRequired, 94 | onItemCreate: PropTypes.func.isRequired, 95 | groups: PropTypes.objectOf(PropTypes.object).isRequired, 96 | }; 97 | -------------------------------------------------------------------------------- /src/components/Database/ViewManagerModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Select as AntSelect, Button, Modal as AntModal } from "antd"; 5 | import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; 6 | import InlineInput from "../InlineInput"; 7 | import { getViewNames } from "./Views"; 8 | 9 | const { Option } = AntSelect; 10 | 11 | const Select = styled(AntSelect)` 12 | flex: 1; 13 | `; 14 | 15 | const ViewForm = styled.form` 16 | display: flex; 17 | align-items: center; 18 | `; 19 | 20 | const ViewItem = styled.div` 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | padding: 8px 24px; 25 | 26 | &:hover { 27 | background: #e7e7e78c; 28 | } 29 | `; 30 | 31 | const NameInput = styled(InlineInput)` 32 | flex: 1; 33 | `; 34 | 35 | const DeleteBtn = styled(DeleteOutlined)` 36 | margin-left: 3px; 37 | cursor: pointer; 38 | `; 39 | 40 | const Modal = styled(AntModal)` 41 | .ant-modal-body { 42 | padding: 0; 43 | } 44 | `; 45 | 46 | function ViewManagerModal({ visible, onCancel, views, onCreate, onDelete, onRename }) { 47 | const [viewType, setViewType] = useState(null); 48 | 49 | const handleSubmit = (event) => { 50 | event.preventDefault(); 51 | 52 | if (!viewType) { 53 | return; 54 | } 55 | 56 | onCreate({ name: "Untitled", type: viewType }); 57 | setViewType(null); 58 | }; 59 | 60 | const showDeleteBtn = () => views.length > 1; 61 | 62 | return ( 63 | 69 | 76 | 79 | 80 | } 81 | > 82 | {views.map(({ name, id }) => ( 83 | 84 | onRename(id, value)} /> 85 | {showDeleteBtn() && onDelete(id)} />} 86 | 87 | ))} 88 | 89 | ); 90 | } 91 | 92 | ViewManagerModal.propTypes = { 93 | visible: PropTypes.bool.isRequired, 94 | onCancel: PropTypes.func.isRequired, 95 | views: PropTypes.arrayOf(PropTypes.object).isRequired, 96 | onCreate: PropTypes.func.isRequired, 97 | onDelete: PropTypes.func.isRequired, 98 | onRename: PropTypes.func.isRequired, 99 | }; 100 | 101 | export default ViewManagerModal; 102 | -------------------------------------------------------------------------------- /src/tests/components/Sidebar.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent } from "@testing-library/react"; 3 | import { Sidebar } from "../../components/Sidebar"; 4 | 5 | const createDatabases = () => ({ 6 | 123: { 7 | id: "123", 8 | name: "tutu", 9 | }, 10 | 456: { 11 | id: "456", 12 | name: "kanahei", 13 | }, 14 | 789: { 15 | id: "789", 16 | name: "piske", 17 | }, 18 | }); 19 | 20 | it("should render self", async () => { 21 | const databases = createDatabases(); 22 | const currentDatabaseId = "123"; 23 | 24 | const { getAllByTestId } = render( 25 | {}} 28 | onDatabaseDelete={() => {}} 29 | onChange={() => {}} 30 | currentDatabaseId={currentDatabaseId} 31 | /> 32 | ); 33 | const databaseItems = getAllByTestId(/database-item/); 34 | 35 | expect(databaseItems.length).toBe(3); 36 | expect(databaseItems[0]).toHaveClass("active"); 37 | }); 38 | 39 | it("should call onDatabaseCreate", async () => { 40 | const databases = createDatabases(); 41 | const handleDatabaseCreate = jest.fn(); 42 | 43 | const { getByTestId } = render( 44 | {}} 48 | onChange={() => {}} 49 | currentDatabaseId={null} 50 | /> 51 | ); 52 | const addBtn = getByTestId("add-btn"); 53 | 54 | fireEvent.click(addBtn); 55 | 56 | expect(handleDatabaseCreate.mock.calls.length).toBe(1); 57 | }); 58 | 59 | it("should call onDatabaseDelete", async () => { 60 | const databases = createDatabases(); 61 | const handleDatabaseDelete = jest.fn(); 62 | 63 | const { getByTestId } = render( 64 | {}} 67 | onDatabaseDelete={handleDatabaseDelete} 68 | onChange={() => {}} 69 | currentDatabaseId={null} 70 | /> 71 | ); 72 | const deleteBtn = getByTestId("789-delete-btn"); 73 | 74 | fireEvent.click(deleteBtn); 75 | 76 | await new Promise((resolve) => setTimeout(resolve)); 77 | expect(handleDatabaseDelete.mock.calls.length).toBe(1); 78 | expect(handleDatabaseDelete.mock.calls[0][0]).toBe("789"); 79 | }); 80 | 81 | it("should call onChange", async () => { 82 | const databases = createDatabases(); 83 | const handleChange = jest.fn(); 84 | 85 | const { getByTestId } = render( 86 | {}} 89 | onDatabaseDelete={() => {}} 90 | onChange={handleChange} 91 | currentDatabaseId={null} 92 | /> 93 | ); 94 | const targetItem = getByTestId("456-database-item"); 95 | 96 | fireEvent.click(targetItem); 97 | 98 | expect(handleChange.mock.calls.length).toBe(1); 99 | expect(handleChange.mock.calls[0][0]).toBe("456"); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/Database/PropertiesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Modal, Switch, Button, Menu, Dropdown } from "antd"; 5 | import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; 6 | import PropertyForm from "./PropertyForm"; 7 | 8 | const PropertyItem = styled(Menu.Item)` 9 | display: flex; 10 | align-items: center; 11 | 12 | .name { 13 | margin-right: auto; 14 | padding-right: 1em; 15 | } 16 | `; 17 | 18 | const DeleteBtn = styled(DeleteOutlined)` 19 | margin-left: 1em; 20 | cursor: pointer; 21 | user-select: none; 22 | `; 23 | 24 | function PropertiesDropdown({ 25 | showProperties, 26 | properties, 27 | onPropertyCreate, 28 | onPropertyDelete, 29 | onPropertyToggle, 30 | }) { 31 | const [dropdownVisible, setDropdownVisible] = useState(false); 32 | const [modalVisible, setModalVisible] = useState(false); 33 | 34 | const inShowProperties = (propertyId) => showProperties.indexOf(propertyId) > -1; 35 | 36 | const openModal = () => setModalVisible(true); 37 | const closeModal = () => setModalVisible(false); 38 | 39 | const handlePropertyFormFinish = (property) => { 40 | onPropertyCreate(property); 41 | closeModal(); 42 | }; 43 | 44 | const createPropertyModal = ( 45 | 46 | 47 | 48 | ); 49 | 50 | const propertyList = properties.map((property) => ( 51 | 52 | {property.name} 53 | onPropertyToggle(property.id)} 56 | checked={inShowProperties(property.id)} 57 | /> 58 | onPropertyDelete(property.id)} /> 59 | 60 | )); 61 | 62 | const menu = ( 63 | 64 | {propertyList} 65 | 66 | 67 | 68 | 71 | 72 | {createPropertyModal} 73 | 74 | 75 | ); 76 | 77 | return ( 78 | 84 | 87 | 88 | ); 89 | } 90 | 91 | PropertiesDropdown.propTypes = { 92 | showProperties: PropTypes.arrayOf(PropTypes.string).isRequired, 93 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 94 | onPropertyCreate: PropTypes.func.isRequired, 95 | onPropertyDelete: PropTypes.func.isRequired, 96 | onPropertyToggle: PropTypes.func.isRequired, 97 | }; 98 | 99 | export default PropertiesDropdown; 100 | -------------------------------------------------------------------------------- /src/slices/views.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import { createSlice } from "@reduxjs/toolkit"; 4 | import shortid from "shortid"; 5 | 6 | const initialState = {}; 7 | 8 | const slice = createSlice({ 9 | name: "views", 10 | initialState, 11 | reducers: { 12 | create: ( 13 | state, 14 | { 15 | payload: { 16 | id, 17 | name, 18 | type = "ListView", 19 | filters = [], 20 | showProperties = [], 21 | sorts = [], 22 | sequence = [], 23 | groupBy = {}, 24 | }, 25 | } 26 | ) => { 27 | state[id] = { 28 | id, 29 | name, 30 | type, 31 | filters, 32 | showProperties, 33 | sorts, 34 | sequence, 35 | groupBy, 36 | }; 37 | }, 38 | toggleShowProperty(state, { payload: { viewId, propertyId } }) { 39 | const { showProperties } = state[viewId]; 40 | 41 | const index = showProperties.indexOf(propertyId); 42 | if (index > -1) { 43 | showProperties.splice(index, 1); 44 | } else { 45 | showProperties.push(propertyId); 46 | } 47 | }, 48 | addFilter(state, { payload: { viewId, filter } }) { 49 | state[viewId].filters.push(filter); 50 | }, 51 | updateFilter(state, { payload: { viewId, filterId, newFilter } }) { 52 | const { filters } = state[viewId]; 53 | const index = filters.findIndex((filter) => filter.id === filterId); 54 | 55 | filters[index] = { ...filters[index], ...newFilter }; 56 | }, 57 | deleteFilter(state, { payload: { viewId, filterId } }) { 58 | const { filters } = state[viewId]; 59 | const index = filters.findIndex((filter) => filter.id === filterId); 60 | filters.splice(index, 1); 61 | }, 62 | updateSequence(state, { payload: { viewId, newSequence } }) { 63 | state[viewId].sequence = newSequence; 64 | }, 65 | rename(state, { payload: { viewId, newName } }) { 66 | state[viewId].name = newName; 67 | }, 68 | remove(state, { payload: { viewId } }) { 69 | delete state[viewId]; 70 | }, 71 | updateGroupBy(state, { payload: { viewId, propertyId } }) { 72 | state[viewId].groupBy.propertyId = propertyId; 73 | }, 74 | }, 75 | }); 76 | 77 | export const { 78 | create, 79 | toggleShowProperty, 80 | addFilter, 81 | updateFilter, 82 | deleteFilter, 83 | updateSequence, 84 | rename, 85 | remove, 86 | updateGroupBy, 87 | } = slice.actions; 88 | 89 | export default slice.reducer; 90 | 91 | export const createView = ({ name, id, type }) => (dispatch) => { 92 | const view = { 93 | name, 94 | type, 95 | id: id || shortid.generate(), 96 | }; 97 | 98 | dispatch(create(view)); 99 | }; 100 | 101 | export const createFilter = (viewId) => (dispatch) => { 102 | const filter = { 103 | id: shortid.generate(), 104 | propertyId: null, 105 | method: null, 106 | args: [], 107 | }; 108 | 109 | dispatch(addFilter({ viewId, filter })); 110 | }; 111 | -------------------------------------------------------------------------------- /src/components/Database/FilterInput.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Select as AntSelect } from "antd"; 5 | import { DeleteOutlined } from "@ant-design/icons"; 6 | import { getFilterMethods } from "../MetaInputs"; 7 | 8 | const { Option } = AntSelect; 9 | 10 | const InputWrapper = styled.div` 11 | display: flex; 12 | align-items: center; 13 | padding: 6px 12px; 14 | 15 | &:hover { 16 | background-color: #f5f5f5; 17 | } 18 | `; 19 | 20 | const Select = styled(AntSelect)` 21 | min-width: 100px; 22 | 23 | & + & { 24 | margin-left: 3px; 25 | } 26 | `; 27 | 28 | const ArgsInputWrapper = styled.div` 29 | margin-left: 3px; 30 | `; 31 | 32 | const DeleteBtn = styled(DeleteOutlined)` 33 | margin-left: 6px; 34 | cursor: pointer; 35 | `; 36 | 37 | function FilterInput({ 38 | properties, 39 | filter, 40 | filter: { propertyId, method, args }, 41 | onChange, 42 | onDelete, 43 | }) { 44 | const selectedProperty = properties.find((property) => property.id === propertyId); 45 | const filterMethods = selectedProperty ? getFilterMethods(selectedProperty.type) : {}; 46 | const ArgsInput = method && filterMethods[method].ArgsInput; 47 | 48 | const handlePropertyChange = (selectedPropertyId) => { 49 | onChange({ 50 | ...filter, 51 | propertyId: selectedPropertyId, 52 | method: null, 53 | args: [], 54 | }); 55 | }; 56 | 57 | const handleMethodChange = (selectedMethod) => { 58 | onChange({ 59 | ...filter, 60 | method: selectedMethod, 61 | args: [], 62 | }); 63 | }; 64 | 65 | const handleArgsChange = (newArgs) => { 66 | onChange({ 67 | ...filter, 68 | args: newArgs, 69 | }); 70 | }; 71 | 72 | return ( 73 | 74 | 81 | 82 | 89 | 90 | {ArgsInput && ( 91 | 92 | 93 | 94 | )} 95 | 96 | 97 | 98 | ); 99 | } 100 | 101 | FilterInput.propTypes = { 102 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 103 | filter: PropTypes.shape({ 104 | propertyId: PropTypes.string, 105 | method: PropTypes.string, 106 | args: PropTypes.array, 107 | }).isRequired, 108 | onChange: PropTypes.func.isRequired, 109 | onDelete: PropTypes.func.isRequired, 110 | }; 111 | 112 | export default FilterInput; 113 | -------------------------------------------------------------------------------- /src/components/Database/Views/List.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Button, Empty } from "antd"; 5 | import { getDisplay } from "../../MetaInputs"; 6 | import Card from "./utils/Card"; 7 | import SortableList from "./utils/SortableList"; 8 | import filterPages from "./utils/filterPages"; 9 | import applySequence from "./utils/applySequence"; 10 | 11 | const CreateBtn = styled(Button)` 12 | margin-top: 1.25em; 13 | `; 14 | 15 | export default function ListView({ 16 | dataSource, 17 | onPageCreate, 18 | onPageDelete, 19 | onPageSelect, 20 | onSequenceChange, 21 | filters, 22 | showProperties, 23 | sequence, 24 | properties, 25 | }) { 26 | const createEmptyPage = () => { 27 | onPageCreate({ title: "Untitled" }); 28 | }; 29 | 30 | const getProperties = (pageMeta) => 31 | properties 32 | .filter((property) => showProperties.indexOf(property.id) > -1) 33 | .filter((property) => pageMeta[property.id]) 34 | .map((property) => ({ 35 | Display: getDisplay(property.type), 36 | property, 37 | value: pageMeta[property.id], 38 | })); 39 | 40 | const pages = useMemo( 41 | () => applySequence(filterPages(dataSource, filters, properties), sequence), 42 | [dataSource, filters, properties, sequence] 43 | ); 44 | 45 | const updateSequence = ({ startIndex, endIndex }) => { 46 | const newSequence = pages.map((page) => page.id); 47 | 48 | const droppedPageId = newSequence[startIndex]; 49 | newSequence.splice(startIndex, 1); 50 | newSequence.splice(endIndex, 0, droppedPageId); 51 | 52 | onSequenceChange(newSequence); 53 | }; 54 | 55 | return ( 56 |
57 | 58 | {(page, restProps) => ( 59 | onPageSelect(page.id)} 63 | title={page.title} 64 | properties={getProperties(page.meta)} 65 | onDelete={() => onPageDelete(page.id)} 66 | /> 67 | )} 68 | 69 | 70 | {pages.length === 0 ? ( 71 | 72 | 73 | 74 | ) : ( 75 | 76 | Create Page 77 | 78 | )} 79 |
80 | ); 81 | } 82 | 83 | ListView.propTypes = { 84 | dataSource: PropTypes.arrayOf(PropTypes.object).isRequired, 85 | filters: PropTypes.arrayOf(PropTypes.object).isRequired, 86 | showProperties: PropTypes.arrayOf(PropTypes.string).isRequired, 87 | sorts: PropTypes.arrayOf(PropTypes.object).isRequired, 88 | sequence: PropTypes.arrayOf(PropTypes.string).isRequired, 89 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 90 | onPageCreate: PropTypes.func.isRequired, 91 | onPageDelete: PropTypes.func.isRequired, 92 | onPageSelect: PropTypes.func.isRequired, 93 | onSequenceChange: PropTypes.func.isRequired, 94 | }; 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notence 2 | 3 | 📝 An open source personal note-taking app inspired by notion.so. 4 | 5 | - Create different databases to manage your notes. 6 | - Customize the properties to your pages and manage them in an organized manner. 7 | - Apply different views and filter rules to visualize your notes. 8 | 9 | [![](./screenshot.png)](https://yoychen.github.io/notence/) 10 | 11 | ## Development 12 | 13 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 14 | 15 | ### Available Scripts 16 | 17 | In the project directory, you can run: 18 | 19 | #### `npm start` 20 | 21 | Runs the app in the development mode.
22 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 23 | 24 | The page will reload if you make edits.
25 | You will also see any lint errors in the console. 26 | 27 | #### `npm test` 28 | 29 | Launches the test runner in the interactive watch mode.
30 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 31 | 32 | #### `npm run build` 33 | 34 | Builds the app for production to the `build` folder.
35 | It correctly bundles React in production mode and optimizes the build for the best performance. 36 | 37 | The build is minified and the filenames include the hashes.
38 | Your app is ready to be deployed! 39 | 40 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 41 | 42 | #### `npm run eject` 43 | 44 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 45 | 46 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 47 | 48 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 49 | 50 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 51 | 52 | ### Learn More 53 | 54 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 55 | 56 | To learn React, check out the [React documentation](https://reactjs.org/). 57 | 58 | #### Code Splitting 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 61 | 62 | #### Analyzing the Bundle Size 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 65 | 66 | #### Making a Progressive Web App 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 69 | 70 | #### Advanced Configuration 71 | 72 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 73 | 74 | #### Deployment 75 | 76 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 77 | 78 | #### `npm run build` fails to minify 79 | 80 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 81 | -------------------------------------------------------------------------------- /src/utils/demoState.js: -------------------------------------------------------------------------------- 1 | export default JSON.parse( 2 | '{"databases":{"1-g4iuCCq3":{"id":"1-g4iuCCq3","name":"LeetCode","pages":["JHq3sz9fY","oT5YokPfI","NE--6cgPn","sNIOfoLQyZ","odNpsOYBb","zo6RJJ6Fpc","JxHUbsnCa","muSkQqHCd"],"views":["grr6ItSlk","bGb-p3iVi"],"properties":["AImUldOoO","PRUrjxacd"]},"vmIcVa3xjx":{"id":"vmIcVa3xjx","name":"Project","pages":["DiieJ3UBt","sN3TlcZ1p","FCcheO_i0","F4j2YDGxiR","O1nHYDmCY","e8YI4rOrP"],"views":["trIf0qZZ4"],"properties":["5RmBhUzk9","X6axz6AQS"]}},"views":{"grr6ItSlk":{"id":"grr6ItSlk","name":"default","type":"ListView","filters":[],"showProperties":["AImUldOoO","PRUrjxacd"],"sorts":[],"sequence":["muSkQqHCd","JHq3sz9fY","oT5YokPfI","NE--6cgPn","sNIOfoLQyZ","odNpsOYBb","zo6RJJ6Fpc","JxHUbsnCa"],"groupBy":{}},"bGb-p3iVi":{"id":"bGb-p3iVi","name":"difficulty","type":"BoardView","filters":[],"showProperties":["AImUldOoO"],"sorts":[],"sequence":[],"groupBy":{"propertyId":"PRUrjxacd"}},"trIf0qZZ4":{"id":"trIf0qZZ4","name":"default","type":"BoardView","filters":[],"showProperties":["X6axz6AQS"],"sorts":[],"sequence":["DiieJ3UBt","sN3TlcZ1p","O1nHYDmCY","F4j2YDGxiR","FCcheO_i0"],"groupBy":{"propertyId":"5RmBhUzk9"}}},"pages":{"JHq3sz9fY":{"id":"JHq3sz9fY","title":"416. Partition Equal Subset Sum","meta":{"AImUldOoO":["jimBgkr6D"],"PRUrjxacd":"dNT4q4vBC"},"content":""},"oT5YokPfI":{"id":"oT5YokPfI","title":"322. Coin Change","meta":{"AImUldOoO":["jimBgkr6D"],"PRUrjxacd":"dNT4q4vBC"},"content":""},"NE--6cgPn":{"id":"NE--6cgPn","title":"124. Binary Tree Maximum Path Sum","meta":{"AImUldOoO":["f2aK45eFL","z7oTFkFaI"],"PRUrjxacd":"Ay4yhB8p_"},"content":""},"sNIOfoLQyZ":{"id":"sNIOfoLQyZ","title":"494. Target Sum","meta":{"AImUldOoO":["jimBgkr6D","z7oTFkFaI"],"PRUrjxacd":"dNT4q4vBC"},"content":""},"odNpsOYBb":{"id":"odNpsOYBb","title":"1. Two Sum","meta":{"PRUrjxacd":"eb8YjSvw4"},"content":""},"zo6RJJ6Fpc":{"id":"zo6RJJ6Fpc","title":"3. Longest Substring Without Repeating Characters","meta":{"AImUldOoO":["ltDzlu3J5"],"PRUrjxacd":"dNT4q4vBC"},"content":""},"JxHUbsnCa":{"id":"JxHUbsnCa","title":"33. Search in Rotated Sorted Array","meta":{"AImUldOoO":["dAq5ch8fj"],"PRUrjxacd":"dNT4q4vBC"},"content":""},"DiieJ3UBt":{"id":"DiieJ3UBt","title":"Story 1","meta":{"5RmBhUzk9":"6WkrSQXiD","X6axz6AQS":["D9k7k5Isf","Op0OIV35d"]},"content":""},"sN3TlcZ1p":{"id":"sN3TlcZ1p","title":"Task 3","meta":{"5RmBhUzk9":"6WkrSQXiD","X6axz6AQS":["x3ABqy8xh"]},"content":""},"FCcheO_i0":{"id":"FCcheO_i0","title":"Task 5","meta":{"5RmBhUzk9":"0yWNy9Oyo","X6axz6AQS":["o4mCknmdp"]},"content":""},"F4j2YDGxiR":{"id":"F4j2YDGxiR","title":"Task 1","meta":{"5RmBhUzk9":"VDIX3VY46","X6axz6AQS":["D9k7k5Isf"]},"content":""},"O1nHYDmCY":{"id":"O1nHYDmCY","title":"Task 2","meta":{"5RmBhUzk9":"VDIX3VY46","X6axz6AQS":["D9k7k5Isf"]},"content":""},"e8YI4rOrP":{"id":"e8YI4rOrP","title":"Task 6","meta":{"5RmBhUzk9":"VDIX3VY46","X6axz6AQS":["s5jDjfvv0"]},"content":""},"muSkQqHCd":{"id":"muSkQqHCd","title":"⭐ Star me on GitHub ✨","meta":{"PRUrjxacd":"eb8YjSvw4"},"content":"[https://github.com/yoychen/notence](https://github.com/yoychen/notence)"}},"properties":{"AImUldOoO":{"id":"AImUldOoO","name":"Algorithm","type":"MultiSelect","additional":{"options":[{"id":"jimBgkr6D","name":"Dynamic Programming","color":"green"},{"id":"z7oTFkFaI","name":"Depth-first Search","color":"volcano"},{"id":"f2aK45eFL","name":"Tree","color":"blue"},{"id":"ltDzlu3J5","name":"Two Pointers","color":"red"},{"id":"dAq5ch8fj","name":"Binary Search","color":"magenta"}]}},"PRUrjxacd":{"id":"PRUrjxacd","name":"Difficulty","type":"Select","additional":{"options":[{"id":"eb8YjSvw4","name":"Easy","color":"green"},{"id":"dNT4q4vBC","name":"Medium","color":"volcano"},{"id":"Ay4yhB8p_","name":"Hard","color":"red"}]}},"5RmBhUzk9":{"id":"5RmBhUzk9","name":"Status","type":"Select","additional":{"options":[{"id":"6WkrSQXiD","name":"To Do","color":"blue"},{"id":"VDIX3VY46","name":"In Progress","color":"gold"},{"id":"0yWNy9Oyo","name":"Done","color":"geekblue"}]}},"X6axz6AQS":{"id":"X6axz6AQS","name":"Labels","type":"MultiSelect","additional":{"options":[{"id":"o4mCknmdp","name":"Bug","color":"red"},{"id":"x3ABqy8xh","name":"Documentation","color":"blue"},{"id":"D9k7k5Isf","name":"Feature","color":"green"},{"id":"s5jDjfvv0","name":"Enhancement","color":"orange"},{"id":"Op0OIV35d","name":"Epic","color":"gold"}]}}}}' 3 | ); 4 | -------------------------------------------------------------------------------- /src/slices/databases.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | import { createSlice } from "@reduxjs/toolkit"; 4 | import shortid from "shortid"; 5 | import { createView, remove as removeView, updateGroupBy } from "./views"; 6 | import { createPage, remove as removePage } from "./pages"; 7 | import { createProperty, removeProperty } from "./properties"; 8 | 9 | const initialState = {}; 10 | 11 | const slice = createSlice({ 12 | name: "databases", 13 | initialState, 14 | reducers: { 15 | create: (state, { payload: { id, name, pages = [], views = [], properties = [] } }) => { 16 | state[id] = { 17 | id, 18 | name, 19 | pages, 20 | views, 21 | properties, 22 | }; 23 | }, 24 | addPage: (state, { payload: { databaseId, pageId } }) => { 25 | state[databaseId].pages.push(pageId); 26 | }, 27 | addProperty: (state, { payload: { databaseId, propertyId } }) => { 28 | state[databaseId].properties.push(propertyId); 29 | }, 30 | remove: (state, { payload: { databaseId } }) => { 31 | delete state[databaseId]; 32 | }, 33 | rename: (state, { payload: { databaseId, newName } }) => { 34 | state[databaseId].name = newName; 35 | }, 36 | addView: (state, { payload: { databaseId, viewId } }) => { 37 | state[databaseId].views.push(viewId); 38 | }, 39 | popView: (state, { payload: { databaseId, viewId } }) => { 40 | const { views } = state[databaseId]; 41 | const index = views.indexOf(viewId); 42 | views.splice(index, 1); 43 | }, 44 | popPage: (state, { payload: { databaseId, pageId } }) => { 45 | const { pages } = state[databaseId]; 46 | const index = pages.indexOf(pageId); 47 | pages.splice(index, 1); 48 | }, 49 | popProperty: (state, { payload: { databaseId, propertyId } }) => { 50 | const { properties } = state[databaseId]; 51 | const index = properties.indexOf(propertyId); 52 | properties.splice(index, 1); 53 | }, 54 | }, 55 | }); 56 | 57 | export const { 58 | create, 59 | addPage, 60 | addProperty, 61 | remove, 62 | rename, 63 | addView, 64 | popView, 65 | popPage, 66 | popProperty, 67 | } = slice.actions; 68 | 69 | export default slice.reducer; 70 | 71 | export const createDatabase = ({ name }) => (dispatch) => { 72 | const defaultView = { 73 | name: "default", 74 | id: shortid.generate(), 75 | }; 76 | dispatch(createView(defaultView)); 77 | 78 | const database = { 79 | name, 80 | id: shortid.generate(), 81 | views: [defaultView.id], 82 | }; 83 | dispatch(create(database)); 84 | }; 85 | 86 | export const createPageInDatabase = (databaseId, { title, meta }) => (dispatch) => { 87 | const page = { 88 | title, 89 | meta, 90 | id: shortid.generate(), 91 | }; 92 | dispatch(createPage(page)); 93 | 94 | dispatch(addPage({ databaseId, pageId: page.id })); 95 | }; 96 | 97 | export const deletePageInDatabase = (databaseId, pageId) => (dispatch) => { 98 | dispatch(removePage({ pageId })); 99 | dispatch(popPage({ databaseId, pageId })); 100 | }; 101 | 102 | export const createViewInDatabase = (databaseId, { name, type }) => (dispatch) => { 103 | const view = { 104 | name, 105 | type, 106 | id: shortid.generate(), 107 | }; 108 | dispatch(createView(view)); 109 | 110 | dispatch(addView({ databaseId, viewId: view.id })); 111 | }; 112 | 113 | export const deleteViewInDatabase = (databaseId, viewId) => (dispatch) => { 114 | dispatch(removeView({ viewId })); 115 | dispatch(popView({ databaseId, viewId })); 116 | }; 117 | 118 | export const createPropertyInDatabase = (databaseId, { name, type, id }) => (dispatch) => { 119 | const property = { 120 | name, 121 | type, 122 | id: id || shortid.generate(), 123 | }; 124 | dispatch(createProperty(property)); 125 | 126 | dispatch(addProperty({ databaseId, propertyId: property.id })); 127 | }; 128 | 129 | export const deletePropertyInDatabase = (databaseId, propertyId) => (dispatch) => { 130 | dispatch(removeProperty(propertyId)); 131 | dispatch(popProperty({ databaseId, propertyId })); 132 | }; 133 | 134 | export const initGroupBy = (databaseId, viewId) => (dispatch) => { 135 | const property = { 136 | name: "Status", 137 | type: "Select", 138 | id: shortid.generate(), 139 | }; 140 | dispatch(createPropertyInDatabase(databaseId, property)); 141 | 142 | dispatch(updateGroupBy({ viewId, propertyId: property.id })); 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import styled from "styled-components"; 5 | import { Layout } from "antd"; 6 | import { AppstoreAddOutlined, DeleteOutlined } from "@ant-design/icons"; 7 | import { createDatabase, remove } from "../slices/databases"; 8 | import devices from "../utils/devices"; 9 | 10 | const { Sider } = Layout; 11 | 12 | const SideBarWrapper = styled.div` 13 | width: 200px; 14 | height: 100%; 15 | padding: 25px 20px; 16 | background-color: rgb(247, 246, 243); 17 | 18 | .title { 19 | display: flex; 20 | align-items: center; 21 | font-size: 1.25em; 22 | .add-btn { 23 | margin-left: auto; 24 | font-size: 13px; 25 | color: gray; 26 | cursor: pointer; 27 | user-select: none; 28 | 29 | &:hover { 30 | color: black; 31 | } 32 | } 33 | } 34 | `; 35 | 36 | const DatabaseList = styled.div` 37 | .item { 38 | display: flex; 39 | align-items: center; 40 | padding: 4px 2px; 41 | border-radius: 1px; 42 | word-break: break-all; 43 | cursor: pointer; 44 | user-select: none; 45 | 46 | .delete-btn { 47 | margin-left: auto; 48 | cursor: pointer; 49 | visibility: hidden; 50 | } 51 | 52 | &:hover, 53 | &:focus, 54 | &.active { 55 | background-color: rgb(233, 232, 229); 56 | outline: none; 57 | 58 | .delete-btn { 59 | visibility: visible; 60 | } 61 | } 62 | } 63 | `; 64 | 65 | export function Sidebar({ 66 | databases, 67 | currentDatabaseId, 68 | onChange, 69 | onDatabaseCreate, 70 | onDatabaseDelete, 71 | }) { 72 | const isActive = (databaseId) => currentDatabaseId === databaseId; 73 | 74 | const handleDeleteBtnClick = (event, databaseId) => { 75 | event.stopPropagation(); 76 | 77 | if (isActive(databaseId)) { 78 | onChange(null); 79 | } 80 | setTimeout(() => { 81 | onDatabaseDelete(databaseId); 82 | }); 83 | }; 84 | 85 | const [collapsed, setCollapsed] = useState(false); 86 | 87 | const collapseSider = () => { 88 | const isLg = window.matchMedia(devices.lg).matches; 89 | if (isLg) { 90 | return; 91 | } 92 | 93 | setCollapsed(true); 94 | }; 95 | 96 | return ( 97 | 98 | 99 |

100 | Databases 101 | onDatabaseCreate("Untitled")} 105 | /> 106 |

107 | 108 | 109 | {Object.keys(databases).map((databaseId) => { 110 | const handleItemSelect = () => { 111 | onChange(databaseId); 112 | collapseSider(); 113 | }; 114 | 115 | return ( 116 |
125 | {databases[databaseId].name} 126 | handleDeleteBtnClick(event, databaseId)} 129 | className="delete-btn" 130 | /> 131 |
132 | ); 133 | })} 134 |
135 |
136 |
137 | ); 138 | } 139 | 140 | Sidebar.defaultProps = { 141 | currentDatabaseId: null, 142 | }; 143 | 144 | Sidebar.propTypes = { 145 | databases: PropTypes.objectOf(PropTypes.object).isRequired, 146 | onDatabaseCreate: PropTypes.func.isRequired, 147 | onDatabaseDelete: PropTypes.func.isRequired, 148 | onChange: PropTypes.func.isRequired, 149 | currentDatabaseId: PropTypes.string, 150 | }; 151 | 152 | const mapStateToProps = (state) => ({ 153 | databases: state.databases, 154 | }); 155 | 156 | const mapDispatchToProps = (dispatch) => { 157 | return { 158 | onDatabaseCreate: (name) => dispatch(createDatabase({ name })), 159 | onDatabaseDelete: (databaseId) => dispatch(remove({ databaseId })), 160 | }; 161 | }; 162 | 163 | export default connect(mapStateToProps, mapDispatchToProps)(Sidebar); 164 | -------------------------------------------------------------------------------- /src/components/Database/Views/Board.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import { Button, Alert } from "antd"; 5 | import { PlusOutlined } from "@ant-design/icons"; 6 | import { getDisplay } from "../../MetaInputs"; 7 | import Card from "./utils/Card"; 8 | import SortableBoard from "./utils/SortableBoard"; 9 | import filterPages from "./utils/filterPages"; 10 | import applySequence from "./utils/applySequence"; 11 | import groupPages from "./utils/groupPages"; 12 | 13 | const CreatePropertyBtn = styled(Button)` 14 | margin-top: 0.5em; 15 | `; 16 | 17 | const BoardCard = styled(Card)` 18 | margin-bottom: 8px; 19 | padding: 7px 12px; 20 | border: 1px solid #dfdfdf; 21 | border-radius: 2px; 22 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); 23 | 24 | .property-list { 25 | display: block; 26 | width: 100%; 27 | margin-top: 3px; 28 | 29 | .property { 30 | margin: 0; 31 | } 32 | } 33 | `; 34 | 35 | export default function BoardView({ 36 | dataSource, 37 | onPageCreate, 38 | onPageDelete, 39 | onPageSelect, 40 | onPageMetaChange, 41 | onSequenceChange, 42 | onGroupByInit, 43 | filters, 44 | showProperties, 45 | sequence, 46 | properties, 47 | groupBy: { propertyId }, 48 | }) { 49 | const createPage = (groupId) => { 50 | onPageCreate({ 51 | title: "Untitled", 52 | meta: { 53 | [propertyId]: groupId, 54 | }, 55 | }); 56 | }; 57 | 58 | const getProperties = (pageMeta) => 59 | properties 60 | .filter((property) => showProperties.indexOf(property.id) > -1) 61 | .filter((property) => pageMeta[property.id]) 62 | .map((property) => ({ 63 | Display: getDisplay(property.type), 64 | property, 65 | value: pageMeta[property.id], 66 | })); 67 | 68 | const pages = useMemo( 69 | () => applySequence(filterPages(dataSource, filters, properties), sequence), 70 | [dataSource, filters, properties, sequence] 71 | ); 72 | 73 | const propertyToGroupBy = properties.find((property) => property.id === propertyId); 74 | 75 | const pageGroups = useMemo(() => groupPages(propertyToGroupBy, pages), [ 76 | propertyToGroupBy, 77 | pages, 78 | ]); 79 | 80 | if (!propertyToGroupBy) { 81 | return ( 82 |
83 | 84 | } type="link" onClick={onGroupByInit}> 85 | Create property 86 | 87 |
88 | ); 89 | } 90 | 91 | const handleBoardChange = ({ source, destination }) => { 92 | const [sourceGroupId, sourceIndex] = [source.droppableId, source.index]; 93 | const [destinationGroupId, destinationIndex] = [destination.droppableId, destination.index]; 94 | 95 | const targetPage = pageGroups[sourceGroupId].items[sourceIndex]; 96 | pageGroups[sourceGroupId].items.splice(sourceIndex, 1); 97 | pageGroups[destinationGroupId].items.splice(destinationIndex, 0, targetPage); 98 | 99 | const newSequence = Object.values(pageGroups).reduce((seq, group) => { 100 | seq.push(...group.items.map((page) => page.id)); 101 | return seq; 102 | }, []); 103 | 104 | onSequenceChange(newSequence); 105 | onPageMetaChange(targetPage.id, propertyToGroupBy.id, destinationGroupId); 106 | }; 107 | 108 | return ( 109 |
110 | 111 | {(page, restProps) => ( 112 | onPageSelect(page.id)} 116 | title={page.title} 117 | properties={getProperties(page.meta)} 118 | onDelete={() => onPageDelete(page.id)} 119 | /> 120 | )} 121 | 122 |
123 | ); 124 | } 125 | 126 | BoardView.propTypes = { 127 | dataSource: PropTypes.arrayOf(PropTypes.object).isRequired, 128 | filters: PropTypes.arrayOf(PropTypes.object).isRequired, 129 | showProperties: PropTypes.arrayOf(PropTypes.string).isRequired, 130 | sorts: PropTypes.arrayOf(PropTypes.object).isRequired, 131 | sequence: PropTypes.arrayOf(PropTypes.string).isRequired, 132 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 133 | groupBy: PropTypes.shape({ 134 | propertyId: PropTypes.string, 135 | }).isRequired, 136 | onPageCreate: PropTypes.func.isRequired, 137 | onPageDelete: PropTypes.func.isRequired, 138 | onPageSelect: PropTypes.func.isRequired, 139 | onPageMetaChange: PropTypes.func.isRequired, 140 | onSequenceChange: PropTypes.func.isRequired, 141 | onGroupByInit: PropTypes.func.isRequired, 142 | }; 143 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // This optional code is used to register a service worker. 4 | // register() is not called by default. 5 | 6 | // This lets the app load faster on subsequent visits in production, and gives 7 | // it offline capabilities. However, it also means that developers (and users) 8 | // will only see deployed updates on subsequent visits to a page, after all the 9 | // existing tabs open on the page have been closed, since previously cached 10 | // resources are updated in the background. 11 | 12 | // To learn more about the benefits of this model and instructions on how to 13 | // opt-in, read https://bit.ly/CRA-PWA 14 | 15 | const isLocalhost = Boolean( 16 | window.location.hostname === "localhost" || 17 | // [::1] is the IPv6 localhost address. 18 | window.location.hostname === "[::1]" || 19 | // 127.0.0.0/8 are considered localhost for IPv4. 20 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | "This web app is being served cache-first by a service " + 46 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then((registration) => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === "installed") { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | "New content is available and will be used when all " + 74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log("Content is cached for offline use."); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch((error) => { 97 | console.error("Error during service worker registration:", error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { "Service-Worker": "script" }, 105 | }) 106 | .then((response) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get("content-type"); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf("javascript") === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log("No internet connection found. App is running in offline mode."); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ("serviceWorker" in navigator) { 131 | navigator.serviceWorker.ready 132 | .then((registration) => { 133 | registration.unregister(); 134 | }) 135 | .catch((error) => { 136 | console.error(error.message); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/tests/slices/databases/actions.spec.js: -------------------------------------------------------------------------------- 1 | import shortid from "shortid"; 2 | import * as databasesActions from "../../../slices/databases"; 3 | import * as viewsActions from "../../../slices/views"; 4 | import * as pagesActions from "../../../slices/pages"; 5 | import * as propertiesActions from "../../../slices/properties"; 6 | import createMockStore from "../utils/createMockStore"; 7 | import createAction from "../utils/createAction"; 8 | import { getDefaultAdditional } from "../../../components/MetaInputs"; 9 | 10 | jest.mock("../../../components/MetaInputs"); 11 | const stubGetDefaultAdditional = (defaultAdditional) => 12 | getDefaultAdditional.mockReturnValue(defaultAdditional); 13 | 14 | jest.mock("shortid"); 15 | const stubShortid = (value) => shortid.generate.mockReturnValue(value); 16 | 17 | test("createDatabase", () => { 18 | const id = "123"; 19 | stubShortid(id); 20 | 21 | const database = { 22 | name: "test", 23 | }; 24 | 25 | const expectedActions = [ 26 | createAction(viewsActions.create.type, { 27 | id, 28 | name: "default", 29 | }), 30 | createAction(databasesActions.create.type, { 31 | id, 32 | name: database.name, 33 | views: [id], 34 | }), 35 | ]; 36 | 37 | const store = createMockStore(); 38 | store.dispatch(databasesActions.createDatabase(database)); 39 | 40 | expect(store.getActions()).toEqual(expectedActions); 41 | }); 42 | 43 | test("createPageInDatabase", () => { 44 | const databaseId = "123"; 45 | const page = { title: "tutu", meta: {} }; 46 | 47 | const pageId = "678"; 48 | stubShortid(pageId); 49 | 50 | const expectedActions = [ 51 | createAction(pagesActions.create.type, { 52 | ...page, 53 | id: pageId, 54 | }), 55 | createAction(databasesActions.addPage.type, { 56 | databaseId, 57 | pageId, 58 | }), 59 | ]; 60 | 61 | const store = createMockStore(); 62 | store.dispatch(databasesActions.createPageInDatabase(databaseId, page)); 63 | 64 | expect(store.getActions()).toEqual(expectedActions); 65 | }); 66 | 67 | test("deletePageInDatabase", () => { 68 | const databaseId = "123"; 69 | const pageId = "456"; 70 | 71 | const expectedActions = [ 72 | createAction(pagesActions.remove.type, { 73 | pageId, 74 | }), 75 | createAction(databasesActions.popPage.type, { 76 | databaseId, 77 | pageId, 78 | }), 79 | ]; 80 | 81 | const store = createMockStore(); 82 | store.dispatch(databasesActions.deletePageInDatabase(databaseId, pageId)); 83 | 84 | expect(store.getActions()).toEqual(expectedActions); 85 | }); 86 | 87 | test("createViewInDatabase", () => { 88 | const databaseId = "123"; 89 | const view = { name: "tutu", type: "List" }; 90 | 91 | const viewId = "678"; 92 | stubShortid(viewId); 93 | 94 | const expectedActions = [ 95 | createAction(viewsActions.create.type, { 96 | ...view, 97 | id: viewId, 98 | }), 99 | createAction(databasesActions.addView.type, { 100 | databaseId, 101 | viewId, 102 | }), 103 | ]; 104 | 105 | const store = createMockStore(); 106 | store.dispatch(databasesActions.createViewInDatabase(databaseId, view)); 107 | 108 | expect(store.getActions()).toEqual(expectedActions); 109 | }); 110 | 111 | test("deleteViewInDatabase", () => { 112 | const databaseId = "123"; 113 | const viewId = "678"; 114 | 115 | const expectedActions = [ 116 | createAction(viewsActions.remove.type, { 117 | viewId, 118 | }), 119 | createAction(databasesActions.popView.type, { 120 | databaseId, 121 | viewId, 122 | }), 123 | ]; 124 | 125 | const store = createMockStore(); 126 | store.dispatch(databasesActions.deleteViewInDatabase(databaseId, viewId)); 127 | 128 | expect(store.getActions()).toEqual(expectedActions); 129 | }); 130 | 131 | test("createPropertyInDatabase", () => { 132 | const databaseId = "123"; 133 | const property = { name: "tutu", type: "Select" }; 134 | 135 | const defaultAdditional = {}; 136 | stubGetDefaultAdditional(defaultAdditional); 137 | 138 | const propertyId = "678"; 139 | stubShortid(propertyId); 140 | 141 | const expectedActions = [ 142 | createAction(propertiesActions.create.type, { 143 | ...property, 144 | id: propertyId, 145 | additional: defaultAdditional, 146 | }), 147 | createAction(databasesActions.addProperty.type, { 148 | databaseId, 149 | propertyId, 150 | }), 151 | ]; 152 | 153 | const store = createMockStore(); 154 | store.dispatch(databasesActions.createPropertyInDatabase(databaseId, property)); 155 | 156 | expect(store.getActions()).toEqual(expectedActions); 157 | }); 158 | 159 | test("deletePropertyInDatabase", () => { 160 | const databaseId = "123"; 161 | const propertyId = "678"; 162 | 163 | const expectedActions = [ 164 | createAction(propertiesActions.remove.type, { 165 | propertyId, 166 | }), 167 | createAction(databasesActions.popProperty.type, { 168 | databaseId, 169 | propertyId, 170 | }), 171 | ]; 172 | 173 | const store = createMockStore(); 174 | store.dispatch(databasesActions.deletePropertyInDatabase(databaseId, propertyId)); 175 | 176 | expect(store.getActions()).toEqual(expectedActions); 177 | }); 178 | 179 | test("dispatch deletePropertyInDatabase when the property appears in some filter rules", () => { 180 | const databaseId = "123"; 181 | const propertyId = "678"; 182 | const viewId = "346"; 183 | const filterId = "876"; 184 | 185 | const views = { 186 | [viewId]: { 187 | id: viewId, 188 | filters: [ 189 | { 190 | id: filterId, 191 | propertyId, 192 | args: [], 193 | }, 194 | ], 195 | }, 196 | }; 197 | 198 | const expectedAction = createAction(viewsActions.deleteFilter.type, { 199 | viewId, 200 | filterId, 201 | }); 202 | 203 | const store = createMockStore({ views }); 204 | store.dispatch(databasesActions.deletePropertyInDatabase(databaseId, propertyId)); 205 | 206 | expect(store.getActions()[0]).toEqual(expectedAction); 207 | }); 208 | -------------------------------------------------------------------------------- /src/components/Database/Database.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import styled from "styled-components"; 5 | import { Modal, notification } from "antd"; 6 | import { InfoCircleTwoTone } from "@ant-design/icons"; 7 | import InlineInput from "../InlineInput"; 8 | import { getView } from "./Views"; 9 | import ViewSelect from "./ViewSelect"; 10 | import PropertiesDropdown from "./PropertiesDropdown"; 11 | import FiltersDropdown from "./FiltersDropdown"; 12 | import GroupByDropdown from "./GroupByDropdown"; 13 | import Page from "../Page/Page"; 14 | import { 15 | createPageInDatabase, 16 | deletePageInDatabase, 17 | createPropertyInDatabase, 18 | deletePropertyInDatabase, 19 | rename, 20 | createViewInDatabase, 21 | deleteViewInDatabase, 22 | initGroupBy, 23 | } from "../../slices/databases"; 24 | import { 25 | toggleShowProperty, 26 | createFilter, 27 | updateFilter, 28 | deleteFilter, 29 | updateSequence, 30 | rename as renameView, 31 | updateGroupBy, 32 | } from "../../slices/views"; 33 | import { updateMeta } from "../../slices/pages"; 34 | import devices from "../../utils/devices"; 35 | 36 | const DatabaseWrapper = styled.div` 37 | width: 100vw; 38 | padding: 35px 30px; 39 | padding-left: 55px; 40 | 41 | @media screen and ${devices.lg} { 42 | width: auto; 43 | padding: 35px 96px; 44 | } 45 | `; 46 | 47 | const Content = styled.div``; 48 | 49 | const Title = styled.h1` 50 | font-size: 2.25em; 51 | margin-bottom: 1em; 52 | `; 53 | 54 | const Toolbar = styled.div` 55 | display: flex; 56 | align-items: center; 57 | margin-bottom: 16px; 58 | 59 | .right { 60 | margin-left: auto; 61 | } 62 | `; 63 | 64 | const PageModal = styled(Modal)` 65 | min-width: 100%; 66 | 67 | @media screen and ${devices.lg} { 68 | min-width: 85vw; 69 | } 70 | `; 71 | 72 | function Database({ 73 | name, 74 | pages, 75 | views, 76 | properties, 77 | onPageCreate, 78 | onPageDelete, 79 | onPageMetaChange, 80 | onPropertyCreate, 81 | onPropertyToggle, 82 | onPropertyDelete, 83 | onFilterCreate, 84 | onFilterChange, 85 | onFilterDelete, 86 | onSequenceChange, 87 | onRename, 88 | onViewCreate, 89 | onViewDelete, 90 | onViewRename, 91 | onGroupByInit, 92 | onGroupByChange, 93 | }) { 94 | const [currentViewId, setCurrentViewId] = useState(views[0].id); 95 | const [selectedPageId, setSelectedPageId] = useState(null); 96 | const currentView = views.find((view) => view.id === currentViewId); 97 | const DataView = getView(currentView.type); 98 | 99 | const resetSelectedPageId = () => { 100 | setSelectedPageId(null); 101 | }; 102 | 103 | const handlePropertyToggle = (propertyId) => onPropertyToggle(currentViewId, propertyId); 104 | const handleFilterChange = (filterId, newFilter) => 105 | onFilterChange(currentViewId, filterId, newFilter); 106 | const handleFilterCreate = () => onFilterCreate(currentViewId); 107 | const handleFilterDelete = (filterId) => onFilterDelete(currentViewId, filterId); 108 | const handleSequenceChange = (newSequence) => onSequenceChange(currentViewId, newSequence); 109 | const handleGroupByInit = () => onGroupByInit(currentViewId); 110 | const handleGroupByChange = (propertyId) => onGroupByChange(currentViewId, propertyId); 111 | 112 | const handlePageCreate = (page) => { 113 | if (currentView.filters.length > 0) { 114 | notification.open({ 115 | icon: , 116 | message: "The new created page may not be able to show on current filter rules.", 117 | duration: 8, 118 | }); 119 | } 120 | onPageCreate(page); 121 | }; 122 | 123 | return ( 124 | 125 | 126 | <InlineInput onChange={onRename} value={name} /> 127 | 128 | 129 | 137 | 138 |
139 | 146 | 153 | {currentView.type === "BoardView" && ( 154 | 159 | )} 160 |
161 |
162 | 163 | 164 | 179 | 180 | 181 | {selectedPageId && } 182 | 183 | 184 |
185 | ); 186 | } 187 | 188 | Database.propTypes = { 189 | name: PropTypes.string.isRequired, 190 | pages: PropTypes.arrayOf(PropTypes.object).isRequired, 191 | views: PropTypes.arrayOf(PropTypes.object).isRequired, 192 | properties: PropTypes.arrayOf(PropTypes.object).isRequired, 193 | onPageCreate: PropTypes.func.isRequired, 194 | onPageDelete: PropTypes.func.isRequired, 195 | onPageMetaChange: PropTypes.func.isRequired, 196 | onPropertyCreate: PropTypes.func.isRequired, 197 | onPropertyToggle: PropTypes.func.isRequired, 198 | onPropertyDelete: PropTypes.func.isRequired, 199 | onFilterCreate: PropTypes.func.isRequired, 200 | onFilterChange: PropTypes.func.isRequired, 201 | onFilterDelete: PropTypes.func.isRequired, 202 | onSequenceChange: PropTypes.func.isRequired, 203 | onRename: PropTypes.func.isRequired, 204 | onViewCreate: PropTypes.func.isRequired, 205 | onViewDelete: PropTypes.func.isRequired, 206 | onViewRename: PropTypes.func.isRequired, 207 | onGroupByInit: PropTypes.func.isRequired, 208 | onGroupByChange: PropTypes.func.isRequired, 209 | }; 210 | 211 | const mapStateToProps = (state, { databaseId }) => ({ 212 | id: databaseId, 213 | name: state.databases[databaseId].name, 214 | pages: state.databases[databaseId].pages.map((id) => state.pages[id]), 215 | views: state.databases[databaseId].views.map((id) => state.views[id]), 216 | properties: state.databases[databaseId].properties.map((id) => state.properties[id]), 217 | }); 218 | 219 | const mapDispatchToProps = (dispatch, { databaseId }) => { 220 | return { 221 | onPageCreate: (page) => dispatch(createPageInDatabase(databaseId, page)), 222 | onPageDelete: (pageId) => dispatch(deletePageInDatabase(databaseId, pageId)), 223 | onPageMetaChange: (pageId, propertyId, value) => 224 | dispatch(updateMeta({ pageId, propertyId, value })), 225 | onPropertyCreate: (property) => dispatch(createPropertyInDatabase(databaseId, property)), 226 | onPropertyToggle: (viewId, propertyId) => dispatch(toggleShowProperty({ viewId, propertyId })), 227 | onPropertyDelete: (propertyId) => dispatch(deletePropertyInDatabase(databaseId, propertyId)), 228 | onFilterCreate: (viewId) => dispatch(createFilter(viewId)), 229 | onFilterChange: (viewId, filterId, newFilter) => 230 | dispatch(updateFilter({ viewId, filterId, newFilter })), 231 | onFilterDelete: (viewId, filterId) => dispatch(deleteFilter({ viewId, filterId })), 232 | onSequenceChange: (viewId, newSequence) => dispatch(updateSequence({ viewId, newSequence })), 233 | onRename: (newName) => dispatch(rename({ databaseId, newName })), 234 | onViewCreate: (view) => dispatch(createViewInDatabase(databaseId, view)), 235 | onViewDelete: (viewId) => dispatch(deleteViewInDatabase(databaseId, viewId)), 236 | onViewRename: (viewId, newName) => dispatch(renameView({ viewId, newName })), 237 | onGroupByInit: (viewId) => dispatch(initGroupBy(databaseId, viewId)), 238 | onGroupByChange: (viewId, propertyId) => dispatch(updateGroupBy({ viewId, propertyId })), 239 | }; 240 | }; 241 | 242 | export default connect(mapStateToProps, mapDispatchToProps)(Database); 243 | --------------------------------------------------------------------------------