mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
Merged 2.0.0-m3 into feature/global_config_v2_endpoint
This commit is contained in:
@@ -29,3 +29,8 @@ Desktop DF$
|
||||
# jrebel
|
||||
rebel.xml
|
||||
\.pyc
|
||||
# ui
|
||||
scm-ui/build
|
||||
scm-ui/coverage
|
||||
/?node_modules/
|
||||
|
||||
|
||||
4
Jenkinsfile
vendored
4
Jenkinsfile
vendored
@@ -1,7 +1,7 @@
|
||||
#!groovy
|
||||
|
||||
// Keep the version in sync with the one used in pom.xml in order to get correct syntax completion.
|
||||
@Library('github.com/cloudogu/ces-build-lib@9aadeeb')
|
||||
@Library('github.com/cloudogu/ces-build-lib@59d3e94')
|
||||
import com.cloudogu.ces.cesbuildlib.*
|
||||
|
||||
node() { // No specific label
|
||||
@@ -31,7 +31,7 @@ node() { // No specific label
|
||||
}
|
||||
|
||||
stage('Unit Test') {
|
||||
mvn 'test -Dsonia.scm.test.skip.hg=true'
|
||||
mvn 'test -Dsonia.scm.test.skip.hg=true -Dmaven.test.failure.ignore=true'
|
||||
}
|
||||
|
||||
stage('SonarQube') {
|
||||
|
||||
1
pom.xml
1
pom.xml
@@ -70,6 +70,7 @@
|
||||
<module>scm-test</module>
|
||||
<module>scm-plugins</module>
|
||||
<module>scm-dao-xml</module>
|
||||
<module>scm-ui</module>
|
||||
<module>scm-webapp</module>
|
||||
<module>scm-server</module>
|
||||
</modules>
|
||||
|
||||
@@ -18,6 +18,7 @@ public class VndMediaType {
|
||||
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
|
||||
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
|
||||
public static final String ME = PREFIX + "me" + SUFFIX;
|
||||
|
||||
public static final String CONFIG = PREFIX + "config" + SUFFIX;
|
||||
|
||||
|
||||
3
scm-ui/.eslintrc
Normal file
3
scm-ui/.eslintrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "react-app"
|
||||
}
|
||||
7
scm-ui/.flowconfig
Normal file
7
scm-ui/.flowconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
[ignore]
|
||||
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
|
||||
[options]
|
||||
15
scm-ui/.vscode/launch.json
vendored
Normal file
15
scm-ui/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/src"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
scm-ui/.vscode/settings.json
vendored
Normal file
10
scm-ui/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
// Set the default
|
||||
"editor.formatOnSave": false,
|
||||
// Enable per-language
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
|
||||
}
|
||||
97
scm-ui/.vscode/snippets/javascript.json
vendored
Normal file
97
scm-ui/.vscode/snippets/javascript.json
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"React default component": {
|
||||
"prefix": "rdc",
|
||||
"body": [
|
||||
"//@flow",
|
||||
"import React from \"react\";",
|
||||
"",
|
||||
"type Props = {};",
|
||||
"",
|
||||
"class $1 extends React.Component<Props> {",
|
||||
" render() {",
|
||||
" return <div />;",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"export default $1;"
|
||||
],
|
||||
"description": "React default component with props"
|
||||
},
|
||||
|
||||
"React styled component": {
|
||||
"prefix": "rsc",
|
||||
"body": [
|
||||
"//@flow",
|
||||
"import React from \"react\";",
|
||||
"import injectSheet from \"react-jss\";",
|
||||
"",
|
||||
"const styles = {};",
|
||||
"",
|
||||
"type Props = {",
|
||||
" classes: any",
|
||||
"};",
|
||||
"",
|
||||
"class $1 extends React.Component<Props> {",
|
||||
" render() {",
|
||||
" const { classes } = this.props;",
|
||||
" return <div />;",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"export default injectSheet(styles)($1);"
|
||||
],
|
||||
"description": "React default component with props"
|
||||
},
|
||||
|
||||
"React container component": {
|
||||
"prefix": "rcc",
|
||||
"body": [
|
||||
"//@flow",
|
||||
"import React from \"react\";",
|
||||
"import { connect } from \"react-redux\";",
|
||||
"",
|
||||
"type Props = {};",
|
||||
"",
|
||||
"class $1 extends React.Component<Props> {",
|
||||
" render() {",
|
||||
" return <div />;",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"const mapStateToProps = state => {",
|
||||
" return {};",
|
||||
"};",
|
||||
"",
|
||||
"const mapDispatchToProps = (dispatch) => {",
|
||||
" return {};",
|
||||
"};",
|
||||
"",
|
||||
"export default connect(",
|
||||
" mapStateToProps,",
|
||||
" mapDispatchToProps",
|
||||
")($1);"
|
||||
],
|
||||
"description": "React component which is connected to the store"
|
||||
},
|
||||
|
||||
"React component test": {
|
||||
"prefix": "rct",
|
||||
"body": [
|
||||
"//@flow",
|
||||
"import React from \"react\";",
|
||||
"import { configure, shallow } from \"enzyme\";",
|
||||
"import Adapter from \"enzyme-adapter-react-16\";",
|
||||
"",
|
||||
"import \"raf/polyfill\";",
|
||||
"",
|
||||
"configure({ adapter: new Adapter() });",
|
||||
"",
|
||||
"it(\"should render the component\", () => {",
|
||||
" $0",
|
||||
" //const label = shallow(<Label value=\"awesome\" />);",
|
||||
" //expect(label.text()).toBe(\"awesome\");",
|
||||
"});"
|
||||
],
|
||||
"description": "React component test with enzyme and jest"
|
||||
}
|
||||
}
|
||||
27
scm-ui/README.md
Normal file
27
scm-ui/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# scm-ui
|
||||
|
||||
## VSCode Plugins
|
||||
|
||||
* EditorConfig for VS Code
|
||||
* Flow Language Support
|
||||
* Prettier - Code formatter
|
||||
* Project Snippets
|
||||
* Debugger for Chrome
|
||||
|
||||
```bash
|
||||
code --install-extension EditorConfig.EditorConfig
|
||||
code --install-extension flowtype.flow-for-vscode
|
||||
code --install-extension esbenp.prettier-vscode
|
||||
code --install-extension rebornix.project-snippets
|
||||
|
||||
# debugging with chrome browser
|
||||
code --install-extension msjsdiag.debugger-for-chrome
|
||||
```
|
||||
|
||||
## Install pre-commit hook
|
||||
|
||||
```bash
|
||||
echo "" >> .hg/hgrc
|
||||
echo "[hooks]" >> .hg/hgrc
|
||||
echo "pre-commit = cd scm-ui && yarn run pre-commit" >> .hg/hgrc
|
||||
```
|
||||
128
scm-ui/flow-typed/npm/history_v4.x.x.js
vendored
Normal file
128
scm-ui/flow-typed/npm/history_v4.x.x.js
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
// flow-typed signature: eb8bd974b677b08dfca89de9ac05b60b
|
||||
// flow-typed version: 43b30482ac/history_v4.x.x/flow_>=v0.25.x
|
||||
|
||||
declare module "history/createBrowserHistory" {
|
||||
declare function Unblock(): void;
|
||||
|
||||
declare export type Action = "PUSH" | "REPLACE" | "POP";
|
||||
|
||||
declare export type BrowserLocation = {
|
||||
pathname: string,
|
||||
search: string,
|
||||
hash: string,
|
||||
// Browser and Memory specific
|
||||
state: string,
|
||||
key: string,
|
||||
};
|
||||
|
||||
declare export type BrowserHistory = {
|
||||
length: number,
|
||||
location: BrowserLocation,
|
||||
action: Action,
|
||||
push: (path: string, Array<mixed>) => void,
|
||||
replace: (path: string, Array<mixed>) => void,
|
||||
go: (n: number) => void,
|
||||
goBack: () => void,
|
||||
goForward: () => void,
|
||||
listen: Function,
|
||||
block: (message: string) => Unblock,
|
||||
block: ((location: BrowserLocation, action: Action) => string) => Unblock,
|
||||
push: (path: string) => void,
|
||||
replace: (path: string) => void,
|
||||
};
|
||||
|
||||
declare type HistoryOpts = {
|
||||
basename?: string,
|
||||
forceRefresh?: boolean,
|
||||
getUserConfirmation?: (
|
||||
message: string,
|
||||
callback: (willContinue: boolean) => void,
|
||||
) => void,
|
||||
};
|
||||
|
||||
declare export default (opts?: HistoryOpts) => BrowserHistory;
|
||||
}
|
||||
|
||||
declare module "history/createMemoryHistory" {
|
||||
declare function Unblock(): void;
|
||||
|
||||
declare export type Action = "PUSH" | "REPLACE" | "POP";
|
||||
|
||||
declare export type MemoryLocation = {
|
||||
pathname: string,
|
||||
search: string,
|
||||
hash: string,
|
||||
// Browser and Memory specific
|
||||
state: string,
|
||||
key: string,
|
||||
};
|
||||
|
||||
declare export type MemoryHistory = {
|
||||
length: number,
|
||||
location: MemoryLocation,
|
||||
action: Action,
|
||||
index: number,
|
||||
entries: Array<string>,
|
||||
push: (path: string, Array<mixed>) => void,
|
||||
replace: (path: string, Array<mixed>) => void,
|
||||
go: (n: number) => void,
|
||||
goBack: () => void,
|
||||
goForward: () => void,
|
||||
// Memory only
|
||||
canGo: (n: number) => boolean,
|
||||
listen: Function,
|
||||
block: (message: string) => Unblock,
|
||||
block: ((location: MemoryLocation, action: Action) => string) => Unblock,
|
||||
push: (path: string) => void,
|
||||
};
|
||||
|
||||
declare type HistoryOpts = {
|
||||
initialEntries?: Array<string>,
|
||||
initialIndex?: number,
|
||||
keyLength?: number,
|
||||
getUserConfirmation?: (
|
||||
message: string,
|
||||
callback: (willContinue: boolean) => void,
|
||||
) => void,
|
||||
};
|
||||
|
||||
declare export default (opts?: HistoryOpts) => MemoryHistory;
|
||||
}
|
||||
|
||||
declare module "history/createHashHistory" {
|
||||
declare function Unblock(): void;
|
||||
|
||||
declare export type Action = "PUSH" | "REPLACE" | "POP";
|
||||
|
||||
declare export type HashLocation = {
|
||||
pathname: string,
|
||||
search: string,
|
||||
hash: string,
|
||||
};
|
||||
|
||||
declare export type HashHistory = {
|
||||
length: number,
|
||||
location: HashLocation,
|
||||
action: Action,
|
||||
push: (path: string, Array<mixed>) => void,
|
||||
replace: (path: string, Array<mixed>) => void,
|
||||
go: (n: number) => void,
|
||||
goBack: () => void,
|
||||
goForward: () => void,
|
||||
listen: Function,
|
||||
block: (message: string) => Unblock,
|
||||
block: ((location: HashLocation, action: Action) => string) => Unblock,
|
||||
push: (path: string) => void,
|
||||
};
|
||||
|
||||
declare type HistoryOpts = {
|
||||
basename?: string,
|
||||
hashType: "slash" | "noslash" | "hashbang",
|
||||
getUserConfirmation?: (
|
||||
message: string,
|
||||
callback: (willContinue: boolean) => void,
|
||||
) => void,
|
||||
};
|
||||
|
||||
declare export default (opts?: HistoryOpts) => HashHistory;
|
||||
}
|
||||
597
scm-ui/flow-typed/npm/jest_v20.x.x.js
vendored
Normal file
597
scm-ui/flow-typed/npm/jest_v20.x.x.js
vendored
Normal file
@@ -0,0 +1,597 @@
|
||||
// flow-typed signature: 002f0912eb0f40f562c348561ea3d850
|
||||
// flow-typed version: a5bbe16c29/jest_v20.x.x/flow_>=v0.39.x
|
||||
|
||||
type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
|
||||
(...args: TArguments): TReturn,
|
||||
/**
|
||||
* An object for introspecting mock calls
|
||||
*/
|
||||
mock: {
|
||||
/**
|
||||
* An array that represents all calls that have been made into this mock
|
||||
* function. Each call is represented by an array of arguments that were
|
||||
* passed during the call.
|
||||
*/
|
||||
calls: Array<TArguments>,
|
||||
/**
|
||||
* An array that contains all the object instances that have been
|
||||
* instantiated from this mock function.
|
||||
*/
|
||||
instances: Array<TReturn>
|
||||
},
|
||||
/**
|
||||
* Resets all information stored in the mockFn.mock.calls and
|
||||
* mockFn.mock.instances arrays. Often this is useful when you want to clean
|
||||
* up a mock's usage data between two assertions.
|
||||
*/
|
||||
mockClear(): void,
|
||||
/**
|
||||
* Resets all information stored in the mock. This is useful when you want to
|
||||
* completely restore a mock back to its initial state.
|
||||
*/
|
||||
mockReset(): void,
|
||||
/**
|
||||
* Removes the mock and restores the initial implementation. This is useful
|
||||
* when you want to mock functions in certain test cases and restore the
|
||||
* original implementation in others. Beware that mockFn.mockRestore only
|
||||
* works when mock was created with jest.spyOn. Thus you have to take care of
|
||||
* restoration yourself when manually assigning jest.fn().
|
||||
*/
|
||||
mockRestore(): void,
|
||||
/**
|
||||
* Accepts a function that should be used as the implementation of the mock.
|
||||
* The mock itself will still record all calls that go into and instances
|
||||
* that come from itself -- the only difference is that the implementation
|
||||
* will also be executed when the mock is called.
|
||||
*/
|
||||
mockImplementation(
|
||||
fn: (...args: TArguments) => TReturn,
|
||||
): JestMockFn<TArguments, TReturn>,
|
||||
/**
|
||||
* Accepts a function that will be used as an implementation of the mock for
|
||||
* one call to the mocked function. Can be chained so that multiple function
|
||||
* calls produce different results.
|
||||
*/
|
||||
mockImplementationOnce(
|
||||
fn: (...args: TArguments) => TReturn,
|
||||
): JestMockFn<TArguments, TReturn>,
|
||||
/**
|
||||
* Just a simple sugar function for returning `this`
|
||||
*/
|
||||
mockReturnThis(): void,
|
||||
/**
|
||||
* Deprecated: use jest.fn(() => value) instead
|
||||
*/
|
||||
mockReturnValue(value: TReturn): JestMockFn<TArguments, TReturn>,
|
||||
/**
|
||||
* Sugar for only returning a value once inside your mock
|
||||
*/
|
||||
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>
|
||||
};
|
||||
|
||||
type JestAsymmetricEqualityType = {
|
||||
/**
|
||||
* A custom Jasmine equality tester
|
||||
*/
|
||||
asymmetricMatch(value: mixed): boolean
|
||||
};
|
||||
|
||||
type JestCallsType = {
|
||||
allArgs(): mixed,
|
||||
all(): mixed,
|
||||
any(): boolean,
|
||||
count(): number,
|
||||
first(): mixed,
|
||||
mostRecent(): mixed,
|
||||
reset(): void
|
||||
};
|
||||
|
||||
type JestClockType = {
|
||||
install(): void,
|
||||
mockDate(date: Date): void,
|
||||
tick(milliseconds?: number): void,
|
||||
uninstall(): void
|
||||
};
|
||||
|
||||
type JestMatcherResult = {
|
||||
message?: string | (() => string),
|
||||
pass: boolean
|
||||
};
|
||||
|
||||
type JestMatcher = (actual: any, expected: any) => JestMatcherResult;
|
||||
|
||||
type JestPromiseType = {
|
||||
/**
|
||||
* Use rejects to unwrap the reason of a rejected promise so any other
|
||||
* matcher can be chained. If the promise is fulfilled the assertion fails.
|
||||
*/
|
||||
rejects: JestExpectType,
|
||||
/**
|
||||
* Use resolves to unwrap the value of a fulfilled promise so any other
|
||||
* matcher can be chained. If the promise is rejected the assertion fails.
|
||||
*/
|
||||
resolves: JestExpectType
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin: jest-enzyme
|
||||
*/
|
||||
type EnzymeMatchersType = {
|
||||
toBeChecked(): void,
|
||||
toBeDisabled(): void,
|
||||
toBeEmpty(): void,
|
||||
toBeEmptyRender(): void,
|
||||
toBePresent(): void,
|
||||
toContainReact(element: React$Element<any>): void,
|
||||
toExist(): void,
|
||||
toHaveClassName(className: string): void,
|
||||
toHaveHTML(html: string): void,
|
||||
toHaveProp: ((propKey: string, propValue?: any) => void) & ((props: Object) => void),
|
||||
toHaveRef(refName: string): void,
|
||||
toHaveState: ((stateKey: string, stateValue?: any) => void) & ((state: Object) => void),
|
||||
toHaveStyle: ((styleKey: string, styleValue?: any) => void) & ((style: Object) => void),
|
||||
toHaveTagName(tagName: string): void,
|
||||
toHaveText(text: string): void,
|
||||
toIncludeText(text: string): void,
|
||||
toHaveValue(value: any): void,
|
||||
toMatchElement(element: React$Element<any>): void,
|
||||
toMatchSelector(selector: string): void
|
||||
};
|
||||
|
||||
// DOM testing library extensions https://github.com/kentcdodds/dom-testing-library#custom-jest-matchers
|
||||
type DomTestingLibraryType = {
|
||||
toBeInTheDOM(): void,
|
||||
toHaveTextContent(content: string): void,
|
||||
toHaveAttribute(name: string, expectedValue?: string): void
|
||||
};
|
||||
|
||||
type JestExpectType = {
|
||||
not: JestExpectType & EnzymeMatchersType & DomTestingLibraryType,
|
||||
/**
|
||||
* If you have a mock function, you can use .lastCalledWith to test what
|
||||
* arguments it was last called with.
|
||||
*/
|
||||
lastCalledWith(...args: Array<any>): void,
|
||||
/**
|
||||
* toBe just checks that a value is what you expect. It uses === to check
|
||||
* strict equality.
|
||||
*/
|
||||
toBe(value: any): void,
|
||||
/**
|
||||
* Use .toHaveBeenCalled to ensure that a mock function got called.
|
||||
*/
|
||||
toBeCalled(): void,
|
||||
/**
|
||||
* Use .toBeCalledWith to ensure that a mock function was called with
|
||||
* specific arguments.
|
||||
*/
|
||||
toBeCalledWith(...args: Array<any>): void,
|
||||
/**
|
||||
* Using exact equality with floating point numbers is a bad idea. Rounding
|
||||
* means that intuitive things fail.
|
||||
*/
|
||||
toBeCloseTo(num: number, delta: any): void,
|
||||
/**
|
||||
* Use .toBeDefined to check that a variable is not undefined.
|
||||
*/
|
||||
toBeDefined(): void,
|
||||
/**
|
||||
* Use .toBeFalsy when you don't care what a value is, you just want to
|
||||
* ensure a value is false in a boolean context.
|
||||
*/
|
||||
toBeFalsy(): void,
|
||||
/**
|
||||
* To compare floating point numbers, you can use toBeGreaterThan.
|
||||
*/
|
||||
toBeGreaterThan(number: number): void,
|
||||
/**
|
||||
* To compare floating point numbers, you can use toBeGreaterThanOrEqual.
|
||||
*/
|
||||
toBeGreaterThanOrEqual(number: number): void,
|
||||
/**
|
||||
* To compare floating point numbers, you can use toBeLessThan.
|
||||
*/
|
||||
toBeLessThan(number: number): void,
|
||||
/**
|
||||
* To compare floating point numbers, you can use toBeLessThanOrEqual.
|
||||
*/
|
||||
toBeLessThanOrEqual(number: number): void,
|
||||
/**
|
||||
* Use .toBeInstanceOf(Class) to check that an object is an instance of a
|
||||
* class.
|
||||
*/
|
||||
toBeInstanceOf(cls: Class<*>): void,
|
||||
/**
|
||||
* .toBeNull() is the same as .toBe(null) but the error messages are a bit
|
||||
* nicer.
|
||||
*/
|
||||
toBeNull(): void,
|
||||
/**
|
||||
* Use .toBeTruthy when you don't care what a value is, you just want to
|
||||
* ensure a value is true in a boolean context.
|
||||
*/
|
||||
toBeTruthy(): void,
|
||||
/**
|
||||
* Use .toBeUndefined to check that a variable is undefined.
|
||||
*/
|
||||
toBeUndefined(): void,
|
||||
/**
|
||||
* Use .toContain when you want to check that an item is in a list. For
|
||||
* testing the items in the list, this uses ===, a strict equality check.
|
||||
*/
|
||||
toContain(item: any): void,
|
||||
/**
|
||||
* Use .toContainEqual when you want to check that an item is in a list. For
|
||||
* testing the items in the list, this matcher recursively checks the
|
||||
* equality of all fields, rather than checking for object identity.
|
||||
*/
|
||||
toContainEqual(item: any): void,
|
||||
/**
|
||||
* Use .toEqual when you want to check that two objects have the same value.
|
||||
* This matcher recursively checks the equality of all fields, rather than
|
||||
* checking for object identity.
|
||||
*/
|
||||
toEqual(value: any): void,
|
||||
/**
|
||||
* Use .toHaveBeenCalled to ensure that a mock function got called.
|
||||
*/
|
||||
toHaveBeenCalled(): void,
|
||||
/**
|
||||
* Use .toHaveBeenCalledTimes to ensure that a mock function got called exact
|
||||
* number of times.
|
||||
*/
|
||||
toHaveBeenCalledTimes(number: number): void,
|
||||
/**
|
||||
* Use .toHaveBeenCalledWith to ensure that a mock function was called with
|
||||
* specific arguments.
|
||||
*/
|
||||
toHaveBeenCalledWith(...args: Array<any>): void,
|
||||
/**
|
||||
* Use .toHaveBeenLastCalledWith to ensure that a mock function was last called
|
||||
* with specific arguments.
|
||||
*/
|
||||
toHaveBeenLastCalledWith(...args: Array<any>): void,
|
||||
/**
|
||||
* Check that an object has a .length property and it is set to a certain
|
||||
* numeric value.
|
||||
*/
|
||||
toHaveLength(number: number): void,
|
||||
/**
|
||||
*
|
||||
*/
|
||||
toHaveProperty(propPath: string, value?: any): void,
|
||||
/**
|
||||
* Use .toMatch to check that a string matches a regular expression or string.
|
||||
*/
|
||||
toMatch(regexpOrString: RegExp | string): void,
|
||||
/**
|
||||
* Use .toMatchObject to check that a javascript object matches a subset of the properties of an object.
|
||||
*/
|
||||
toMatchObject(object: Object): void,
|
||||
/**
|
||||
* This ensures that a React component matches the most recent snapshot.
|
||||
*/
|
||||
toMatchSnapshot(name?: string): void,
|
||||
/**
|
||||
* Use .toThrow to test that a function throws when it is called.
|
||||
* If you want to test that a specific error gets thrown, you can provide an
|
||||
* argument to toThrow. The argument can be a string for the error message,
|
||||
* a class for the error, or a regex that should match the error.
|
||||
*
|
||||
* Alias: .toThrowError
|
||||
*/
|
||||
toThrow(message?: string | Error | RegExp): void,
|
||||
toThrowError(message?: string | Error | RegExp): void,
|
||||
/**
|
||||
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
|
||||
* matching the most recent snapshot when it is called.
|
||||
*/
|
||||
toThrowErrorMatchingSnapshot(): void
|
||||
};
|
||||
|
||||
type JestObjectType = {
|
||||
/**
|
||||
* Disables automatic mocking in the module loader.
|
||||
*
|
||||
* After this method is called, all `require()`s will return the real
|
||||
* versions of each module (rather than a mocked version).
|
||||
*/
|
||||
disableAutomock(): JestObjectType,
|
||||
/**
|
||||
* An un-hoisted version of disableAutomock
|
||||
*/
|
||||
autoMockOff(): JestObjectType,
|
||||
/**
|
||||
* Enables automatic mocking in the module loader.
|
||||
*/
|
||||
enableAutomock(): JestObjectType,
|
||||
/**
|
||||
* An un-hoisted version of enableAutomock
|
||||
*/
|
||||
autoMockOn(): JestObjectType,
|
||||
/**
|
||||
* Clears the mock.calls and mock.instances properties of all mocks.
|
||||
* Equivalent to calling .mockClear() on every mocked function.
|
||||
*/
|
||||
clearAllMocks(): JestObjectType,
|
||||
/**
|
||||
* Resets the state of all mocks. Equivalent to calling .mockReset() on every
|
||||
* mocked function.
|
||||
*/
|
||||
resetAllMocks(): JestObjectType,
|
||||
/**
|
||||
* Removes any pending timers from the timer system.
|
||||
*/
|
||||
clearAllTimers(): void,
|
||||
/**
|
||||
* The same as `mock` but not moved to the top of the expectation by
|
||||
* babel-jest.
|
||||
*/
|
||||
doMock(moduleName: string, moduleFactory?: any): JestObjectType,
|
||||
/**
|
||||
* The same as `unmock` but not moved to the top of the expectation by
|
||||
* babel-jest.
|
||||
*/
|
||||
dontMock(moduleName: string): JestObjectType,
|
||||
/**
|
||||
* Returns a new, unused mock function. Optionally takes a mock
|
||||
* implementation.
|
||||
*/
|
||||
fn<TArguments: $ReadOnlyArray<*>, TReturn>(
|
||||
implementation?: (...args: TArguments) => TReturn,
|
||||
): JestMockFn<TArguments, TReturn>,
|
||||
/**
|
||||
* Determines if the given function is a mocked function.
|
||||
*/
|
||||
isMockFunction(fn: Function): boolean,
|
||||
/**
|
||||
* Given the name of a module, use the automatic mocking system to generate a
|
||||
* mocked version of the module for you.
|
||||
*/
|
||||
genMockFromModule(moduleName: string): any,
|
||||
/**
|
||||
* Mocks a module with an auto-mocked version when it is being required.
|
||||
*
|
||||
* The second argument can be used to specify an explicit module factory that
|
||||
* is being run instead of using Jest's automocking feature.
|
||||
*
|
||||
* The third argument can be used to create virtual mocks -- mocks of modules
|
||||
* that don't exist anywhere in the system.
|
||||
*/
|
||||
mock(
|
||||
moduleName: string,
|
||||
moduleFactory?: any,
|
||||
options?: Object
|
||||
): JestObjectType,
|
||||
/**
|
||||
* Resets the module registry - the cache of all required modules. This is
|
||||
* useful to isolate modules where local state might conflict between tests.
|
||||
*/
|
||||
resetModules(): JestObjectType,
|
||||
/**
|
||||
* Exhausts the micro-task queue (usually interfaced in node via
|
||||
* process.nextTick).
|
||||
*/
|
||||
runAllTicks(): void,
|
||||
/**
|
||||
* Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(),
|
||||
* setInterval(), and setImmediate()).
|
||||
*/
|
||||
runAllTimers(): void,
|
||||
/**
|
||||
* Exhausts all tasks queued by setImmediate().
|
||||
*/
|
||||
runAllImmediates(): void,
|
||||
/**
|
||||
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
|
||||
* or setInterval() and setImmediate()).
|
||||
*/
|
||||
runTimersToTime(msToRun: number): void,
|
||||
/**
|
||||
* Executes only the macro-tasks that are currently pending (i.e., only the
|
||||
* tasks that have been queued by setTimeout() or setInterval() up to this
|
||||
* point)
|
||||
*/
|
||||
runOnlyPendingTimers(): void,
|
||||
/**
|
||||
* Explicitly supplies the mock object that the module system should return
|
||||
* for the specified module. Note: It is recommended to use jest.mock()
|
||||
* instead.
|
||||
*/
|
||||
setMock(moduleName: string, moduleExports: any): JestObjectType,
|
||||
/**
|
||||
* Indicates that the module system should never return a mocked version of
|
||||
* the specified module from require() (e.g. that it should always return the
|
||||
* real module).
|
||||
*/
|
||||
unmock(moduleName: string): JestObjectType,
|
||||
/**
|
||||
* Instructs Jest to use fake versions of the standard timer functions
|
||||
* (setTimeout, setInterval, clearTimeout, clearInterval, nextTick,
|
||||
* setImmediate and clearImmediate).
|
||||
*/
|
||||
useFakeTimers(): JestObjectType,
|
||||
/**
|
||||
* Instructs Jest to use the real versions of the standard timer functions.
|
||||
*/
|
||||
useRealTimers(): JestObjectType,
|
||||
/**
|
||||
* Creates a mock function similar to jest.fn but also tracks calls to
|
||||
* object[methodName].
|
||||
*/
|
||||
spyOn(object: Object, methodName: string): JestMockFn<any, any>
|
||||
};
|
||||
|
||||
type JestSpyType = {
|
||||
calls: JestCallsType
|
||||
};
|
||||
|
||||
/** Runs this function after every test inside this context */
|
||||
declare function afterEach(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
|
||||
/** Runs this function before every test inside this context */
|
||||
declare function beforeEach(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
|
||||
/** Runs this function after all tests have finished inside this context */
|
||||
declare function afterAll(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
|
||||
/** Runs this function before any tests have started inside this context */
|
||||
declare function beforeAll(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
|
||||
|
||||
/** A context for grouping tests together */
|
||||
declare var describe: {
|
||||
/**
|
||||
* Creates a block that groups together several related tests in one "test suite"
|
||||
*/
|
||||
(name: string, fn: () => void): void,
|
||||
|
||||
/**
|
||||
* Only run this describe block
|
||||
*/
|
||||
only(name: string, fn: () => void): void,
|
||||
|
||||
/**
|
||||
* Skip running this describe block
|
||||
*/
|
||||
skip(name: string, fn: () => void): void,
|
||||
};
|
||||
|
||||
|
||||
/** An individual test unit */
|
||||
declare var it: {
|
||||
/**
|
||||
* An individual test unit
|
||||
*
|
||||
* @param {string} Name of Test
|
||||
* @param {Function} Test
|
||||
* @param {number} Timeout for the test, in milliseconds.
|
||||
*/
|
||||
(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
|
||||
/**
|
||||
* Only run this test
|
||||
*
|
||||
* @param {string} Name of Test
|
||||
* @param {Function} Test
|
||||
* @param {number} Timeout for the test, in milliseconds.
|
||||
*/
|
||||
only(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
|
||||
/**
|
||||
* Skip running this test
|
||||
*
|
||||
* @param {string} Name of Test
|
||||
* @param {Function} Test
|
||||
* @param {number} Timeout for the test, in milliseconds.
|
||||
*/
|
||||
skip(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
|
||||
/**
|
||||
* Run the test concurrently
|
||||
*
|
||||
* @param {string} Name of Test
|
||||
* @param {Function} Test
|
||||
* @param {number} Timeout for the test, in milliseconds.
|
||||
*/
|
||||
concurrent(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
|
||||
};
|
||||
declare function fit(
|
||||
name: string,
|
||||
fn: (done: () => void) => ?Promise<mixed>,
|
||||
timeout?: number,
|
||||
): void;
|
||||
/** An individual test unit */
|
||||
declare var test: typeof it;
|
||||
/** A disabled group of tests */
|
||||
declare var xdescribe: typeof describe;
|
||||
/** A focused group of tests */
|
||||
declare var fdescribe: typeof describe;
|
||||
/** A disabled individual test */
|
||||
declare var xit: typeof it;
|
||||
/** A disabled individual test */
|
||||
declare var xtest: typeof it;
|
||||
|
||||
type JestPrettyFormatColors = {
|
||||
comment: { close: string, open: string },
|
||||
content: { close: string, open: string },
|
||||
prop: { close: string, open: string },
|
||||
tag: { close: string, open: string },
|
||||
value: { close: string, open: string },
|
||||
};
|
||||
|
||||
type JestPrettyFormatIndent = string => string;
|
||||
type JestPrettyFormatRefs = Array<any>;
|
||||
type JestPrettyFormatPrint = any => string;
|
||||
type JestPrettyFormatStringOrNull = string | null;
|
||||
|
||||
type JestPrettyFormatOptions = {|
|
||||
callToJSON: boolean,
|
||||
edgeSpacing: string,
|
||||
escapeRegex: boolean,
|
||||
highlight: boolean,
|
||||
indent: number,
|
||||
maxDepth: number,
|
||||
min: boolean,
|
||||
plugins: JestPrettyFormatPlugins,
|
||||
printFunctionName: boolean,
|
||||
spacing: string,
|
||||
theme: {|
|
||||
comment: string,
|
||||
content: string,
|
||||
prop: string,
|
||||
tag: string,
|
||||
value: string,
|
||||
|},
|
||||
|};
|
||||
|
||||
type JestPrettyFormatPlugin = {
|
||||
print: (
|
||||
val: any,
|
||||
serialize: JestPrettyFormatPrint,
|
||||
indent: JestPrettyFormatIndent,
|
||||
opts: JestPrettyFormatOptions,
|
||||
colors: JestPrettyFormatColors,
|
||||
) => string,
|
||||
test: any => boolean,
|
||||
};
|
||||
|
||||
type JestPrettyFormatPlugins = Array<JestPrettyFormatPlugin>;
|
||||
|
||||
/** The expect function is used every time you want to test a value */
|
||||
declare var expect: {
|
||||
/** The object that you want to make assertions against */
|
||||
(value: any): JestExpectType & JestPromiseType & EnzymeMatchersType & DomTestingLibraryType,
|
||||
/** Add additional Jasmine matchers to Jest's roster */
|
||||
extend(matchers: { [name: string]: JestMatcher }): void,
|
||||
/** Add a module that formats application-specific data structures. */
|
||||
addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void,
|
||||
assertions(expectedAssertions: number): void,
|
||||
hasAssertions(): void,
|
||||
any(value: mixed): JestAsymmetricEqualityType,
|
||||
anything(): void,
|
||||
arrayContaining(value: Array<mixed>): void,
|
||||
objectContaining(value: Object): void,
|
||||
/** Matches any received string that contains the exact expected string. */
|
||||
stringContaining(value: string): void,
|
||||
stringMatching(value: string | RegExp): void
|
||||
};
|
||||
|
||||
// TODO handle return type
|
||||
// http://jasmine.github.io/2.4/introduction.html#section-Spies
|
||||
declare function spyOn(value: mixed, method: string): Object;
|
||||
|
||||
/** Holds all functions related to manipulating test runner */
|
||||
declare var jest: JestObjectType;
|
||||
|
||||
/**
|
||||
* The global Jasmine object, this is generally not exposed as the public API,
|
||||
* using features inside here could break in later versions of Jest.
|
||||
*/
|
||||
declare var jasmine: {
|
||||
DEFAULT_TIMEOUT_INTERVAL: number,
|
||||
any(value: mixed): JestAsymmetricEqualityType,
|
||||
anything(): void,
|
||||
arrayContaining(value: Array<mixed>): void,
|
||||
clock(): JestClockType,
|
||||
createSpy(name: string): JestSpyType,
|
||||
createSpyObj(
|
||||
baseName: string,
|
||||
methodNames: Array<string>
|
||||
): { [methodName: string]: JestSpyType },
|
||||
objectContaining(value: Object): void,
|
||||
stringMatching(value: string): void
|
||||
};
|
||||
76
scm-ui/package.json
Normal file
76
scm-ui/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"homepage": "/scm",
|
||||
"name": "scm-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"bulma": "^0.7.1",
|
||||
"classnames": "^2.2.5",
|
||||
"history": "^4.7.2",
|
||||
"i18next": "^11.4.0",
|
||||
"i18next-browser-languagedetector": "^2.2.2",
|
||||
"i18next-fetch-backend": "^0.1.0",
|
||||
"react": "^16.4.1",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-i18next": "^7.9.0",
|
||||
"react-jss": "^8.6.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"react-scripts": "1.1.4",
|
||||
"redux": "^4.0.0",
|
||||
"redux-devtools-extension": "^2.13.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build-css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/",
|
||||
"watch-css": "npm run build-css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive",
|
||||
"start-js": "react-scripts start",
|
||||
"start": "npm-run-all -p watch-css start-js",
|
||||
"build-js": "react-scripts build",
|
||||
"build": "npm-run-all build-css build-js",
|
||||
"test": "jest",
|
||||
"test-coverage": "jest --coverage",
|
||||
"test-ci": "jest --ci --coverage",
|
||||
"eject": "react-scripts eject",
|
||||
"flow": "flow",
|
||||
"pre-commit": "jest && flow && eslint src"
|
||||
},
|
||||
"proxy": {
|
||||
"/scm/api": {
|
||||
"target": "http://localhost:8081"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-react-16": "^1.1.1",
|
||||
"fetch-mock": "^6.5.0",
|
||||
"flow-bin": "^0.77.0",
|
||||
"flow-typed": "^2.5.1",
|
||||
"jest-junit": "^5.1.0",
|
||||
"node-sass-chokidar": "^1.3.0",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"prettier": "^1.13.7",
|
||||
"react-test-renderer": "^16.4.1",
|
||||
"redux-mock-store": "^1.5.3"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"coverageDirectory": "target/jest-reports/coverage",
|
||||
"coveragePathIgnorePatterns": [
|
||||
"src/tests/.*"
|
||||
],
|
||||
"reporters": [
|
||||
"default",
|
||||
"jest-junit"
|
||||
]
|
||||
},
|
||||
"jest-junit": {
|
||||
"output": "./target/jest-reports/TEST-all.xml"
|
||||
}
|
||||
}
|
||||
78
scm-ui/pom.xml
Normal file
78
scm-ui/pom.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<groupId>sonia.scm.clients</groupId>
|
||||
<artifactId>scm-ui</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<name>scm-ui</name>
|
||||
|
||||
<properties>
|
||||
<sonar.language>js</sonar.language>
|
||||
<sonar.sources>src</sonar.sources>
|
||||
<sonar.test.exclusions>**/*.test.js,src/tests/**</sonar.test.exclusions>
|
||||
<sonar.coverage.exclusions>**/*.test.js,src/tests/**</sonar.coverage.exclusions>
|
||||
<sonar.javascript.jstest.reportsPath>target/jest-reports</sonar.javascript.jstest.reportsPath>
|
||||
<sonar.javascript.lcov.reportPaths>target/jest-reports/coverage/lcov.info</sonar.javascript.lcov.reportPaths>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
<groupId>com.github.sdorra</groupId>
|
||||
<artifactId>buildfrontend-maven-plugin</artifactId>
|
||||
<version>2.0.1</version>
|
||||
<configuration>
|
||||
<node>
|
||||
<version>8.11.3</version>
|
||||
</node>
|
||||
<pkgManager>
|
||||
<type>YARN</type>
|
||||
<version>1.7.0</version>
|
||||
</pkgManager>
|
||||
<script>run</script>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>install</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>build</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>run</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<script>build</script>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>run</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<script>test-ci</script>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
BIN
scm-ui/public/favicon.ico
Normal file
BIN
scm-ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
40
scm-ui/public/index.html
Normal file
40
scm-ui/public/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>SCM-Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
40
scm-ui/public/locales/en/commons.json
Normal file
40
scm-ui/public/locales/en/commons.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"subtitle": "Please login to proceed.",
|
||||
"logo-alt": "SCM-Manager",
|
||||
"username-placeholder": "Your Username",
|
||||
"password-placeholder": "Your Password",
|
||||
"submit": "Login"
|
||||
},
|
||||
"logout": {
|
||||
"error": {
|
||||
"title": "Logout failed",
|
||||
"subtitle": "Something went wrong during logout"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"error": {
|
||||
"title": "Error",
|
||||
"subtitle": "Unknown error occurred"
|
||||
}
|
||||
},
|
||||
"error-notification": {
|
||||
"prefix": "Error"
|
||||
},
|
||||
"loading": {
|
||||
"alt": "Loading ..."
|
||||
},
|
||||
"logo": {
|
||||
"alt": "SCM-Manager"
|
||||
},
|
||||
"primary-navigation": {
|
||||
"repositories": "Repositories",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"paginator": {
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
}
|
||||
}
|
||||
7
scm-ui/public/locales/en/repositories.json
Normal file
7
scm-ui/public/locales/en/repositories.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"repositories": {
|
||||
"title": "Repositories",
|
||||
"subtitle": "Repositories will be shown here",
|
||||
"body": "Coming soon ..."
|
||||
}
|
||||
}
|
||||
52
scm-ui/public/locales/en/users.json
Normal file
52
scm-ui/public/locales/en/users.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"user": {
|
||||
"name": "Username",
|
||||
"displayName": "Display Name",
|
||||
"mail": "E-Mail",
|
||||
"password": "Password",
|
||||
"admin": "Admin",
|
||||
"active": "Active"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"subtitle": "Create, read, update and delete users"
|
||||
},
|
||||
"create-user-button": {
|
||||
"label": "Create"
|
||||
},
|
||||
"delete-user-button": {
|
||||
"label": "Delete",
|
||||
"confirm-alert": {
|
||||
"title": "Delete user",
|
||||
"message": "Do you really want to delete the user?",
|
||||
"submit": "Yes",
|
||||
"cancel": "No"
|
||||
}
|
||||
},
|
||||
"edit-user-button": {
|
||||
"label": "Edit"
|
||||
},
|
||||
"user-form": {
|
||||
"submit": "Submit"
|
||||
},
|
||||
"add-user": {
|
||||
"title": "Create User",
|
||||
"subtitle": "Create a new user"
|
||||
},
|
||||
"single-user": {
|
||||
"error-title": "Error",
|
||||
"error-subtitle": "Unknown user error",
|
||||
"navigation-label": "Navigation",
|
||||
"actions-label": "Actions",
|
||||
"information-label": "Information",
|
||||
"back-label": "Back"
|
||||
},
|
||||
"validation": {
|
||||
"mail-invalid": "This email is invalid",
|
||||
"name-invalid": "This name is invalid",
|
||||
"displayname-invalid": "This displayname is invalid",
|
||||
"password-invalid": "Password has to be between 6 and 32 characters",
|
||||
"passwordValidation-invalid": "Passwords have to be the same",
|
||||
"validatePassword": "Please validate password here"
|
||||
}
|
||||
}
|
||||
15
scm-ui/public/manifest.json
Normal file
15
scm-ui/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "SCM-Manager",
|
||||
"name": "SCM-Manager",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
116
scm-ui/src/apiclient.js
Normal file
116
scm-ui/src/apiclient.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// @flow
|
||||
|
||||
// get api base url from environment
|
||||
const apiUrl = process.env.API_URL || process.env.PUBLIC_URL || "/scm";
|
||||
|
||||
export const NOT_FOUND_ERROR = Error("not found");
|
||||
export const UNAUTHORIZED_ERROR = Error("unauthorized");
|
||||
|
||||
const fetchOptions: RequestOptions = {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Cache: "no-cache"
|
||||
}
|
||||
};
|
||||
|
||||
function handleStatusCode(response: Response) {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw UNAUTHORIZED_ERROR;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw NOT_FOUND_ERROR;
|
||||
}
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function createUrl(url: string) {
|
||||
if (url.indexOf("://") > 0) {
|
||||
return url;
|
||||
}
|
||||
let urlWithStartingSlash = url;
|
||||
if (url.indexOf("/") !== 0) {
|
||||
urlWithStartingSlash = "/" + urlWithStartingSlash;
|
||||
}
|
||||
return `${apiUrl}/api/rest/v2${urlWithStartingSlash}`;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
get(url: string): Promise<Response> {
|
||||
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
|
||||
}
|
||||
|
||||
post(url: string, payload: any) {
|
||||
return this.httpRequestWithJSONBody(url, payload, "POST");
|
||||
}
|
||||
|
||||
postWithContentType(url: string, payload: any, contentType: string) {
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
"POST",
|
||||
JSON.stringify(payload),
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
putWithContentType(url: string, payload: any, contentType: string) {
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
"PUT",
|
||||
JSON.stringify(payload),
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
delete(url: string): Promise<Response> {
|
||||
let options: RequestOptions = {
|
||||
method: "DELETE"
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
}
|
||||
|
||||
httpRequestWithJSONBody(
|
||||
url: string,
|
||||
payload: any,
|
||||
method: string
|
||||
): Promise<Response> {
|
||||
// let options: RequestOptions = {
|
||||
// method: method,
|
||||
// body: JSON.stringify(payload)
|
||||
// };
|
||||
// options = Object.assign(options, fetchOptions);
|
||||
// // $FlowFixMe
|
||||
// options.headers["Content-Type"] = "application/json";
|
||||
|
||||
// return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
method,
|
||||
JSON.stringify(payload),
|
||||
"application/json"
|
||||
).then(handleStatusCode);
|
||||
}
|
||||
|
||||
httpRequestWithContentType(
|
||||
url: string,
|
||||
method: string,
|
||||
payload: any,
|
||||
contentType: string
|
||||
): Promise<Response> {
|
||||
let options: RequestOptions = {
|
||||
method: method,
|
||||
body: payload
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
// $FlowFixMe
|
||||
options.headers["Content-Type"] = contentType;
|
||||
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export let apiClient = new ApiClient();
|
||||
15
scm-ui/src/apiclient.test.js
Normal file
15
scm-ui/src/apiclient.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import { createUrl } from "./apiclient";
|
||||
|
||||
describe("create url", () => {
|
||||
it("should not change absolute urls", () => {
|
||||
expect(createUrl("https://www.scm-manager.org")).toBe(
|
||||
"https://www.scm-manager.org"
|
||||
);
|
||||
});
|
||||
|
||||
it("should add prefix for api", () => {
|
||||
expect(createUrl("/users")).toBe("/scm/api/rest/v2/users");
|
||||
expect(createUrl("users")).toBe("/scm/api/rest/v2/users");
|
||||
});
|
||||
});
|
||||
25
scm-ui/src/components/ErrorNotification.js
Normal file
25
scm-ui/src/components/ErrorNotification.js
Normal file
@@ -0,0 +1,25 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import Notification from "./Notification";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
error?: Error
|
||||
};
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, error } = this.props;
|
||||
if (error) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {error.message}
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("commons")(ErrorNotification);
|
||||
27
scm-ui/src/components/ErrorPage.js
Normal file
27
scm-ui/src/components/ErrorPage.js
Normal file
@@ -0,0 +1,27 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import ErrorNotification from "./ErrorNotification";
|
||||
|
||||
type Props = {
|
||||
error: Error,
|
||||
title: string,
|
||||
subtitle: string
|
||||
};
|
||||
|
||||
class ErrorPage extends React.Component<Props> {
|
||||
render() {
|
||||
const { title, subtitle, error } = this.props;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="box column is-4 is-offset-4 container">
|
||||
<h1 className="title">{title}</h1>
|
||||
<p className="subtitle">{subtitle}</p>
|
||||
<ErrorNotification error={error} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorPage;
|
||||
45
scm-ui/src/components/Loading.js
Normal file
45
scm-ui/src/components/Loading.js
Normal file
@@ -0,0 +1,45 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import injectSheet from "react-jss";
|
||||
import Image from "../images/loading.svg";
|
||||
|
||||
const styles = {
|
||||
wrapper: {
|
||||
position: "relative"
|
||||
},
|
||||
loading: {
|
||||
width: "128px",
|
||||
height: "128px",
|
||||
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
|
||||
margin: "64px 0 0 -64px"
|
||||
},
|
||||
image: {
|
||||
width: "128px",
|
||||
height: "128px"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
class Loading extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, classes } = this.props;
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.loading}>
|
||||
<img className={classes.image} src={Image} alt={t("loading.alt")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(translate("commons")(Loading));
|
||||
17
scm-ui/src/components/Logo.js
Normal file
17
scm-ui/src/components/Logo.js
Normal file
@@ -0,0 +1,17 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import Image from "../images/logo.png";
|
||||
|
||||
type Props = {
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Logo extends React.Component<Props> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return <img src={Image} alt={t("logo.alt")} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("commons")(Logo);
|
||||
37
scm-ui/src/components/Notification.js
Normal file
37
scm-ui/src/components/Notification.js
Normal file
@@ -0,0 +1,37 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
type NotificationType = "primary" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
type Props = {
|
||||
type: NotificationType,
|
||||
onClose?: () => void,
|
||||
children?: React.Node
|
||||
};
|
||||
|
||||
class Notification extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
type: "info"
|
||||
};
|
||||
|
||||
renderCloseButton() {
|
||||
const { onClose } = this.props;
|
||||
if (onClose) {
|
||||
return <button className="delete" onClick={onClose} />;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
render() {
|
||||
const { type, children } = this.props;
|
||||
return (
|
||||
<div className={classNames("notification", "is-" + type)}>
|
||||
{this.renderCloseButton()}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Notification;
|
||||
120
scm-ui/src/components/Paginator.js
Normal file
120
scm-ui/src/components/Paginator.js
Normal file
@@ -0,0 +1,120 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { PagedCollection } from "../types/Collection";
|
||||
import { Button } from "./buttons";
|
||||
|
||||
type Props = {
|
||||
collection: PagedCollection,
|
||||
onPageChange: string => void,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Paginator extends React.Component<Props> {
|
||||
isLinkUnavailable(linkType: string) {
|
||||
return !this.props.collection || !this.props.collection._links[linkType];
|
||||
}
|
||||
|
||||
createAction = (linkType: string) => () => {
|
||||
const { collection, onPageChange } = this.props;
|
||||
const link = collection._links[linkType].href;
|
||||
onPageChange(link);
|
||||
};
|
||||
|
||||
renderFirstButton() {
|
||||
return this.renderPageButton(1, "first");
|
||||
}
|
||||
|
||||
renderPreviousButton() {
|
||||
const { t } = this.props;
|
||||
return this.renderButton(
|
||||
"pagination-previous",
|
||||
t("paginator.previous"),
|
||||
"prev"
|
||||
);
|
||||
}
|
||||
|
||||
renderNextButton() {
|
||||
const { t } = this.props;
|
||||
return this.renderButton("pagination-next", t("paginator.next"), "next");
|
||||
}
|
||||
|
||||
renderLastButton() {
|
||||
const { collection } = this.props;
|
||||
return this.renderPageButton(collection.pageTotal, "last");
|
||||
}
|
||||
|
||||
renderPageButton(page: number, linkType: string) {
|
||||
return this.renderButton("pagination-link", page.toString(), linkType);
|
||||
}
|
||||
|
||||
renderButton(className: string, label: string, linkType: string) {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
label={label}
|
||||
disabled={this.isLinkUnavailable(linkType)}
|
||||
action={this.createAction(linkType)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
seperator() {
|
||||
return <span className="pagination-ellipsis">…</span>;
|
||||
}
|
||||
|
||||
currentPage(page: number) {
|
||||
return (
|
||||
<Button
|
||||
className="pagination-link is-current"
|
||||
label={page}
|
||||
disabled={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
pageLinks() {
|
||||
const { collection } = this.props;
|
||||
|
||||
const links = [];
|
||||
const page = collection.page + 1;
|
||||
const pageTotal = collection.pageTotal;
|
||||
if (page > 1) {
|
||||
links.push(this.renderFirstButton());
|
||||
}
|
||||
if (page > 3) {
|
||||
links.push(this.seperator());
|
||||
}
|
||||
if (page > 2) {
|
||||
links.push(this.renderPageButton(page - 1, "prev"));
|
||||
}
|
||||
|
||||
links.push(this.currentPage(page));
|
||||
|
||||
if (page + 1 < pageTotal) {
|
||||
links.push(this.renderPageButton(page + 1, "next"));
|
||||
links.push(this.seperator());
|
||||
}
|
||||
if (page < pageTotal) {
|
||||
links.push(this.renderLastButton());
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<nav className="pagination is-centered" aria-label="pagination">
|
||||
{this.renderPreviousButton()}
|
||||
{this.renderNextButton()}
|
||||
<ul className="pagination-list">
|
||||
{this.pageLinks().map((link, index) => {
|
||||
return <li key={index}>{link}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("commons")(Paginator);
|
||||
253
scm-ui/src/components/Paginator.test.js
Normal file
253
scm-ui/src/components/Paginator.test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "../tests/enzyme";
|
||||
import "../tests/i18n";
|
||||
|
||||
import Paginator from "./Paginator";
|
||||
|
||||
describe("paginator rendering tests", () => {
|
||||
const dummyLink = {
|
||||
href: "https://dummy"
|
||||
};
|
||||
|
||||
it("should render all buttons but disabled, without links", () => {
|
||||
const collection = {
|
||||
page: 10,
|
||||
pageTotal: 20,
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(7);
|
||||
for (let button of buttons) {
|
||||
expect(button.props.disabled).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should render buttons for first page", () => {
|
||||
const collection = {
|
||||
page: 0,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
next: dummyLink,
|
||||
last: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(5);
|
||||
|
||||
// previous button
|
||||
expect(buttons.get(0).props.disabled).toBeTruthy();
|
||||
// last button
|
||||
expect(buttons.get(1).props.disabled).toBeFalsy();
|
||||
// first button
|
||||
const firstButton = buttons.get(2).props;
|
||||
expect(firstButton.disabled).toBeTruthy();
|
||||
expect(firstButton.label).toBe(1);
|
||||
|
||||
// next button
|
||||
const nextButton = buttons.get(3).props;
|
||||
expect(nextButton.disabled).toBeFalsy();
|
||||
expect(nextButton.label).toBe("2");
|
||||
|
||||
// last button
|
||||
const lastButton = buttons.get(4).props;
|
||||
expect(lastButton.disabled).toBeFalsy();
|
||||
expect(lastButton.label).toBe("148");
|
||||
});
|
||||
|
||||
it("should render buttons for second page", () => {
|
||||
const collection = {
|
||||
page: 1,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
prev: dummyLink,
|
||||
next: dummyLink,
|
||||
last: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(6);
|
||||
|
||||
// previous button
|
||||
expect(buttons.get(0).props.disabled).toBeFalsy();
|
||||
// last button
|
||||
expect(buttons.get(1).props.disabled).toBeFalsy();
|
||||
// first button
|
||||
const firstButton = buttons.get(2).props;
|
||||
expect(firstButton.disabled).toBeFalsy();
|
||||
expect(firstButton.label).toBe("1");
|
||||
|
||||
// current button
|
||||
const currentButton = buttons.get(3).props;
|
||||
expect(currentButton.disabled).toBeTruthy();
|
||||
expect(currentButton.label).toBe(2);
|
||||
|
||||
// next button
|
||||
const nextButton = buttons.get(4).props;
|
||||
expect(nextButton.disabled).toBeFalsy();
|
||||
expect(nextButton.label).toBe("3");
|
||||
|
||||
// last button
|
||||
const lastButton = buttons.get(5).props;
|
||||
expect(lastButton.disabled).toBeFalsy();
|
||||
expect(lastButton.label).toBe("148");
|
||||
});
|
||||
|
||||
it("should render buttons for last page", () => {
|
||||
const collection = {
|
||||
page: 147,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
prev: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(5);
|
||||
|
||||
// previous button
|
||||
expect(buttons.get(0).props.disabled).toBeFalsy();
|
||||
// last button
|
||||
expect(buttons.get(1).props.disabled).toBeTruthy();
|
||||
// first button
|
||||
const firstButton = buttons.get(2).props;
|
||||
expect(firstButton.disabled).toBeFalsy();
|
||||
expect(firstButton.label).toBe("1");
|
||||
|
||||
// next button
|
||||
const nextButton = buttons.get(3).props;
|
||||
expect(nextButton.disabled).toBeFalsy();
|
||||
expect(nextButton.label).toBe("147");
|
||||
|
||||
// last button
|
||||
const lastButton = buttons.get(4).props;
|
||||
expect(lastButton.disabled).toBeTruthy();
|
||||
expect(lastButton.label).toBe(148);
|
||||
});
|
||||
|
||||
it("should render buttons for penultimate page", () => {
|
||||
const collection = {
|
||||
page: 146,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
prev: dummyLink,
|
||||
next: dummyLink,
|
||||
last: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(6);
|
||||
|
||||
// previous button
|
||||
expect(buttons.get(0).props.disabled).toBeFalsy();
|
||||
// last button
|
||||
expect(buttons.get(1).props.disabled).toBeFalsy();
|
||||
|
||||
// first button
|
||||
const firstButton = buttons.get(2).props;
|
||||
expect(firstButton.disabled).toBeFalsy();
|
||||
expect(firstButton.label).toBe("1");
|
||||
|
||||
const currentButton = buttons.get(3).props;
|
||||
expect(currentButton.disabled).toBeFalsy();
|
||||
expect(currentButton.label).toBe("146");
|
||||
|
||||
// current button
|
||||
const nextButton = buttons.get(4).props;
|
||||
expect(nextButton.disabled).toBeTruthy();
|
||||
expect(nextButton.label).toBe(147);
|
||||
|
||||
// last button
|
||||
const lastButton = buttons.get(5).props;
|
||||
expect(lastButton.disabled).toBeFalsy();
|
||||
expect(lastButton.label).toBe("148");
|
||||
});
|
||||
|
||||
it("should render buttons for a page in the middle", () => {
|
||||
const collection = {
|
||||
page: 41,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
prev: dummyLink,
|
||||
next: dummyLink,
|
||||
last: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
const paginator = shallow(<Paginator collection={collection} />);
|
||||
const buttons = paginator.find("Button");
|
||||
expect(buttons.length).toBe(7);
|
||||
|
||||
// previous button
|
||||
expect(buttons.get(0).props.disabled).toBeFalsy();
|
||||
// next button
|
||||
expect(buttons.get(1).props.disabled).toBeFalsy();
|
||||
|
||||
// first button
|
||||
const firstButton = buttons.get(2).props;
|
||||
expect(firstButton.disabled).toBeFalsy();
|
||||
expect(firstButton.label).toBe("1");
|
||||
|
||||
// previous Button
|
||||
const previousButton = buttons.get(3).props;
|
||||
expect(previousButton.disabled).toBeFalsy();
|
||||
expect(previousButton.label).toBe("41");
|
||||
|
||||
// current button
|
||||
const currentButton = buttons.get(4).props;
|
||||
expect(currentButton.disabled).toBeTruthy();
|
||||
expect(currentButton.label).toBe(42);
|
||||
|
||||
// next button
|
||||
const nextButton = buttons.get(5).props;
|
||||
expect(nextButton.disabled).toBeFalsy();
|
||||
expect(nextButton.label).toBe("43");
|
||||
|
||||
// last button
|
||||
const lastButton = buttons.get(6).props;
|
||||
expect(lastButton.disabled).toBeFalsy();
|
||||
expect(lastButton.label).toBe("148");
|
||||
});
|
||||
|
||||
it("should call the function with the last previous url", () => {
|
||||
const collection = {
|
||||
page: 41,
|
||||
pageTotal: 148,
|
||||
_links: {
|
||||
first: dummyLink,
|
||||
prev: {
|
||||
href: "https://www.scm-manager.org"
|
||||
},
|
||||
next: dummyLink,
|
||||
last: dummyLink
|
||||
}
|
||||
};
|
||||
|
||||
let urlToOpen;
|
||||
const callMe = (url: string) => {
|
||||
urlToOpen = url;
|
||||
};
|
||||
|
||||
const paginator = mount(
|
||||
<Paginator collection={collection} onPageChange={callMe} />
|
||||
);
|
||||
paginator.find("Button.pagination-previous").simulate("click");
|
||||
|
||||
expect(urlToOpen).toBe("https://www.scm-manager.org");
|
||||
});
|
||||
});
|
||||
39
scm-ui/src/components/ProtectedRoute.js
Normal file
39
scm-ui/src/components/ProtectedRoute.js
Normal file
@@ -0,0 +1,39 @@
|
||||
//@flow
|
||||
import React, { Component } from "react";
|
||||
import { Route, Redirect, withRouter } from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
authenticated?: boolean,
|
||||
component: Component<any, any>
|
||||
};
|
||||
|
||||
class ProtectedRoute extends React.Component<Props> {
|
||||
renderRoute = (Component: any, authenticated?: boolean) => {
|
||||
return (routeProps: any) => {
|
||||
if (authenticated) {
|
||||
return <Component {...routeProps} />;
|
||||
} else {
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: "/login",
|
||||
state: { from: routeProps.location }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
const { component, authenticated, ...routeProps } = this.props;
|
||||
return (
|
||||
<Route
|
||||
{...routeProps}
|
||||
render={this.renderRoute(component, authenticated)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ProtectedRoute);
|
||||
11
scm-ui/src/components/buttons/AddButton.js
Normal file
11
scm-ui/src/components/buttons/AddButton.js
Normal file
@@ -0,0 +1,11 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class AddButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="default" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AddButton;
|
||||
64
scm-ui/src/components/buttons/Button.js
Normal file
64
scm-ui/src/components/buttons/Button.js
Normal file
@@ -0,0 +1,64 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export type ButtonProps = {
|
||||
label: string,
|
||||
loading?: boolean,
|
||||
disabled?: boolean,
|
||||
action?: () => void,
|
||||
link?: string,
|
||||
fullWidth?: boolean,
|
||||
className?: string
|
||||
};
|
||||
|
||||
type Props = ButtonProps & {
|
||||
type: string
|
||||
};
|
||||
|
||||
class Button extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
type: "default"
|
||||
};
|
||||
|
||||
renderButton = () => {
|
||||
const {
|
||||
label,
|
||||
loading,
|
||||
disabled,
|
||||
type,
|
||||
action,
|
||||
fullWidth,
|
||||
className
|
||||
} = this.props;
|
||||
const loadingClass = loading ? "is-loading" : "";
|
||||
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={action ? action : () => {}}
|
||||
className={classNames(
|
||||
"button",
|
||||
"is-" + type,
|
||||
loadingClass,
|
||||
fullWidthClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { link } = this.props;
|
||||
if (link) {
|
||||
return <Link to={link}>{this.renderButton()}</Link>;
|
||||
} else {
|
||||
return this.renderButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Button;
|
||||
11
scm-ui/src/components/buttons/DeleteButton.js
Normal file
11
scm-ui/src/components/buttons/DeleteButton.js
Normal file
@@ -0,0 +1,11 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class DeleteButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="warning" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default DeleteButton;
|
||||
11
scm-ui/src/components/buttons/EditButton.js
Normal file
11
scm-ui/src/components/buttons/EditButton.js
Normal file
@@ -0,0 +1,11 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class EditButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="default" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default EditButton;
|
||||
11
scm-ui/src/components/buttons/SubmitButton.js
Normal file
11
scm-ui/src/components/buttons/SubmitButton.js
Normal file
@@ -0,0 +1,11 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class SubmitButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="primary" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default SubmitButton;
|
||||
5
scm-ui/src/components/buttons/index.js
Normal file
5
scm-ui/src/components/buttons/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as AddButton } from "./AddButton";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as DeleteButton } from "./DeleteButton";
|
||||
export { default as EditButton } from "./EditButton";
|
||||
export { default as SubmitButton } from "./SubmitButton";
|
||||
34
scm-ui/src/components/forms/Checkbox.js
Normal file
34
scm-ui/src/components/forms/Checkbox.js
Normal file
@@ -0,0 +1,34 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
checked: boolean,
|
||||
onChange?: boolean => void
|
||||
};
|
||||
class Checkbox extends React.Component<Props> {
|
||||
onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(event.target.checked);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={this.props.checked}
|
||||
onChange={this.onCheckboxChange}
|
||||
/>
|
||||
{this.props.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Checkbox;
|
||||
70
scm-ui/src/components/forms/InputField.js
Normal file
70
scm-ui/src/components/forms/InputField.js
Normal file
@@ -0,0 +1,70 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
placeholder?: string,
|
||||
value?: string,
|
||||
type?: string,
|
||||
autofocus?: boolean,
|
||||
onChange: string => void,
|
||||
validationError: boolean,
|
||||
errorMessage: string
|
||||
};
|
||||
|
||||
class InputField extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
type: "text",
|
||||
placeholder: ""
|
||||
};
|
||||
|
||||
field: ?HTMLInputElement;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.autofocus && this.field) {
|
||||
this.field.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.props.onChange(event.target.value);
|
||||
};
|
||||
|
||||
renderLabel = () => {
|
||||
const label = this.props.label;
|
||||
if (label) {
|
||||
return <label className="label">{label}</label>;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, placeholder, value, validationError, errorMessage } = this.props;
|
||||
const errorView = validationError ? "is-danger" : "";
|
||||
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
|
||||
return (
|
||||
<div className="field">
|
||||
{this.renderLabel()}
|
||||
<div className="control">
|
||||
<input
|
||||
ref={input => {
|
||||
this.field = input;
|
||||
}}
|
||||
className={ classNames(
|
||||
"input",
|
||||
errorView
|
||||
)}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={this.handleInput}
|
||||
/>
|
||||
</div>
|
||||
{helper}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InputField;
|
||||
2
scm-ui/src/components/forms/index.js
Normal file
2
scm-ui/src/components/forms/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Checkbox } from "./Checkbox";
|
||||
export { default as InputField } from "./InputField";
|
||||
25
scm-ui/src/components/layout/Footer.js
Normal file
25
scm-ui/src/components/layout/Footer.js
Normal file
@@ -0,0 +1,25 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Me } from "../../types/Me";
|
||||
|
||||
type Props = {
|
||||
me?: Me
|
||||
};
|
||||
|
||||
class Footer extends React.Component<Props> {
|
||||
render() {
|
||||
const { me } = this.props;
|
||||
if (!me) {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container is-centered">
|
||||
<p className="has-text-centered">{me.displayName}</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
31
scm-ui/src/components/layout/Header.js
Normal file
31
scm-ui/src/components/layout/Header.js
Normal file
@@ -0,0 +1,31 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import Logo from "./../Logo";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node
|
||||
};
|
||||
|
||||
class Header extends React.Component<Props> {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
return (
|
||||
<section className="hero is-dark is-small">
|
||||
<div className="hero-body">
|
||||
<div className="container">
|
||||
<div className="columns is-vcentered">
|
||||
<div className="column">
|
||||
<Logo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-foot">
|
||||
<div className="container">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Header;
|
||||
49
scm-ui/src/components/layout/Page.js
Normal file
49
scm-ui/src/components/layout/Page.js
Normal file
@@ -0,0 +1,49 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import Loading from "./../Loading";
|
||||
import ErrorNotification from "./../ErrorNotification";
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
subtitle?: string,
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
children: React.Node
|
||||
};
|
||||
|
||||
class Page extends React.Component<Props> {
|
||||
render() {
|
||||
const { title, error } = this.props;
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h1 className="title">{title}</h1>
|
||||
{this.renderSubtitle()}
|
||||
<ErrorNotification error={error} />
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
renderSubtitle() {
|
||||
const { subtitle } = this.props;
|
||||
if (subtitle) {
|
||||
return <h2 className="subtitle">{subtitle}</h2>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { loading, children, error } = this.props;
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default Page;
|
||||
3
scm-ui/src/components/layout/index.js
Normal file
3
scm-ui/src/components/layout/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Footer } from "./Footer";
|
||||
export { default as Header } from "./Header";
|
||||
export { default as Page } from "./Page";
|
||||
102
scm-ui/src/components/modals/ConfirmAlert.css
Normal file
102
scm-ui/src/components/modals/ConfirmAlert.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/*modified from https://github.com/GA-MO/react-confirm-alert*/
|
||||
.react-confirm-alert-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: -webkit-flex;
|
||||
display: -moz-flex;
|
||||
display: -ms-flex;
|
||||
display: -o-flex;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
-ms-align-items: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
-webkit-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
|
||||
-moz-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
|
||||
-o-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
|
||||
animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
|
||||
}
|
||||
|
||||
.react-confirm-alert-body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
width: 400px;
|
||||
padding: 30px;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 20px 75px rgba(0, 0, 0, 0.13);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.react-confirm-alert-body > h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.react-confirm-alert-body > h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.react-confirm-alert-button-group {
|
||||
display: -webkit-flex;
|
||||
display: -moz-flex;
|
||||
display: -ms-flex;
|
||||
display: -o-flex;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.react-confirm-alert-button-group > button {
|
||||
outline: none;
|
||||
background: #333;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
padding: 6px 18px;
|
||||
color: #eee;
|
||||
margin-right: 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@-webkit-keyframes react-confirm-alert-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-moz-keyframes react-confirm-alert-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-o-keyframes react-confirm-alert-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes react-confirm-alert-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
63
scm-ui/src/components/modals/ConfirmAlert.js
Normal file
63
scm-ui/src/components/modals/ConfirmAlert.js
Normal file
@@ -0,0 +1,63 @@
|
||||
//modified from https://github.com/GA-MO/react-confirm-alert
|
||||
|
||||
import React from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
|
||||
type Props = {
|
||||
title:string,
|
||||
message: string,
|
||||
buttons: array,
|
||||
}
|
||||
|
||||
class ConfirmAlert extends React.Component<Props> {
|
||||
|
||||
handleClickButton = button => {
|
||||
if (button.onClick) button.onClick()
|
||||
this.close()
|
||||
}
|
||||
|
||||
close = () => {
|
||||
removeElementReconfirm()
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, message, buttons } = this.props;
|
||||
|
||||
return (
|
||||
<div className="react-confirm-alert-overlay">
|
||||
<div className="react-confirm-alert">
|
||||
{<div className="react-confirm-alert-body">
|
||||
{title && <h1>{title}</h1>}
|
||||
{message}
|
||||
<div className="react-confirm-alert-button-group">
|
||||
{buttons.map((button, i) => (
|
||||
<button key={i} onClick={() => this.handleClickButton(button)}>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createElementReconfirm (properties) {
|
||||
const divTarget = document.createElement('div')
|
||||
divTarget.id = 'react-confirm-alert'
|
||||
document.body.appendChild(divTarget)
|
||||
render(<ConfirmAlert {...properties} />, divTarget)
|
||||
}
|
||||
|
||||
function removeElementReconfirm () {
|
||||
const target = document.getElementById('react-confirm-alert')
|
||||
unmountComponentAtNode(target)
|
||||
target.parentNode.removeChild(target)
|
||||
}
|
||||
|
||||
export function confirmAlert (properties) {
|
||||
createElementReconfirm(properties)
|
||||
}
|
||||
|
||||
export default ConfirmAlert;
|
||||
1
scm-ui/src/components/modals/index.js
Normal file
1
scm-ui/src/components/modals/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ConfirmAlert } from "./ConfirmAlert";
|
||||
20
scm-ui/src/components/navigation/NavAction.js
Normal file
20
scm-ui/src/components/navigation/NavAction.js
Normal file
@@ -0,0 +1,20 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
action: () => void
|
||||
};
|
||||
|
||||
class NavAction extends React.Component<Props> {
|
||||
render() {
|
||||
const { label, action } = this.props;
|
||||
return (
|
||||
<li>
|
||||
<a onClick={action}>{label}</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NavAction;
|
||||
37
scm-ui/src/components/navigation/NavLink.js
Normal file
37
scm-ui/src/components/navigation/NavLink.js
Normal file
@@ -0,0 +1,37 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import { Route, Link } from "react-router-dom";
|
||||
|
||||
// TODO mostly copy of PrimaryNavigationLink
|
||||
|
||||
type Props = {
|
||||
to: string,
|
||||
label: string,
|
||||
activeOnlyWhenExact?: boolean
|
||||
};
|
||||
|
||||
class NavLink extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
activeOnlyWhenExact: true
|
||||
};
|
||||
|
||||
renderLink = (route: any) => {
|
||||
const { to, label } = this.props;
|
||||
return (
|
||||
<li>
|
||||
<Link className={route.match ? "is-active" : ""} to={to}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { to, activeOnlyWhenExact } = this.props;
|
||||
return (
|
||||
<Route path={to} exact={activeOnlyWhenExact} children={this.renderLink} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NavLink;
|
||||
14
scm-ui/src/components/navigation/Navigation.js
Normal file
14
scm-ui/src/components/navigation/Navigation.js
Normal file
@@ -0,0 +1,14 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node
|
||||
};
|
||||
|
||||
class Navigation extends React.Component<Props> {
|
||||
render() {
|
||||
return <aside className="menu">{this.props.children}</aside>;
|
||||
}
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
36
scm-ui/src/components/navigation/PrimaryNavigation.js
Normal file
36
scm-ui/src/components/navigation/PrimaryNavigation.js
Normal file
@@ -0,0 +1,36 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import PrimaryNavigationLink from "./PrimaryNavigationLink";
|
||||
|
||||
type Props = {
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class PrimaryNavigation extends React.Component<Props> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<nav className="tabs is-boxed">
|
||||
<ul>
|
||||
<PrimaryNavigationLink
|
||||
to="/"
|
||||
activeOnlyWhenExact={true}
|
||||
label={t("primary-navigation.repositories")}
|
||||
/>
|
||||
<PrimaryNavigationLink
|
||||
to="/users"
|
||||
match="/(user|users)"
|
||||
label={t("primary-navigation.users")}
|
||||
/>
|
||||
<PrimaryNavigationLink
|
||||
to="/logout"
|
||||
label={t("primary-navigation.logout")}
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("commons")(PrimaryNavigation);
|
||||
35
scm-ui/src/components/navigation/PrimaryNavigationLink.js
Normal file
35
scm-ui/src/components/navigation/PrimaryNavigationLink.js
Normal file
@@ -0,0 +1,35 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
import { Route, Link } from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
to: string,
|
||||
label: string,
|
||||
match?: string,
|
||||
activeOnlyWhenExact?: boolean
|
||||
};
|
||||
|
||||
class PrimaryNavigationLink extends React.Component<Props> {
|
||||
renderLink = (route: any) => {
|
||||
const { to, label } = this.props;
|
||||
return (
|
||||
<li className={route.match ? "is-active" : ""}>
|
||||
<Link to={to}>{label}</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { to, match, activeOnlyWhenExact } = this.props;
|
||||
const path = match ? match : to;
|
||||
return (
|
||||
<Route
|
||||
path={path}
|
||||
exact={activeOnlyWhenExact}
|
||||
children={this.renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrimaryNavigationLink;
|
||||
21
scm-ui/src/components/navigation/Section.js
Normal file
21
scm-ui/src/components/navigation/Section.js
Normal file
@@ -0,0 +1,21 @@
|
||||
//@flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
label: string,
|
||||
children?: React.Node
|
||||
};
|
||||
|
||||
class Section extends React.Component<Props> {
|
||||
render() {
|
||||
const { label, children } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<p className="menu-label">{label}</p>
|
||||
<ul className="menu-list">{children}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Section;
|
||||
8
scm-ui/src/components/navigation/index.js
Normal file
8
scm-ui/src/components/navigation/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
//primary Navigation
|
||||
export { default as PrimaryNavigation } from "./PrimaryNavigation";
|
||||
export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink";
|
||||
//secondary Navigation
|
||||
export { default as Navigation } from "./Navigation";
|
||||
export { default as Section } from "./Section";
|
||||
export { default as NavLink } from "./NavLink";
|
||||
export { default as NavAction } from "./NavAction";
|
||||
6439
scm-ui/src/containers/App.css
Normal file
6439
scm-ui/src/containers/App.css
Normal file
File diff suppressed because it is too large
Load Diff
92
scm-ui/src/containers/App.js
Normal file
92
scm-ui/src/containers/App.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { Component } from "react";
|
||||
import Main from "./Main";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import {
|
||||
fetchMe,
|
||||
isAuthenticated,
|
||||
getMe,
|
||||
isFetchMePending,
|
||||
getFetchMeFailure
|
||||
} from "../modules/auth";
|
||||
|
||||
import "./App.css";
|
||||
import "../components/modals/ConfirmAlert.css";
|
||||
import { PrimaryNavigation } from "../components/navigation";
|
||||
import Loading from "../components/Loading";
|
||||
import ErrorPage from "../components/ErrorPage";
|
||||
import { Footer, Header } from "../components/layout";
|
||||
|
||||
type Props = {
|
||||
me: Me,
|
||||
authenticated: boolean,
|
||||
error: Error,
|
||||
loading: boolean,
|
||||
|
||||
// dispatcher functions
|
||||
fetchMe: () => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class App extends Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchMe();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { me, loading, error, authenticated, t } = this.props;
|
||||
|
||||
let content;
|
||||
const navigation = authenticated ? <PrimaryNavigation /> : "";
|
||||
|
||||
if (loading) {
|
||||
content = <Loading />;
|
||||
} else if (error) {
|
||||
content = (
|
||||
<ErrorPage
|
||||
title={t("app.error.title")}
|
||||
subtitle={t("app.error.subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = <Main authenticated={authenticated} />;
|
||||
}
|
||||
return (
|
||||
<div className="App">
|
||||
<Header>{navigation}</Header>
|
||||
{content}
|
||||
<Footer me={me} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
fetchMe: () => dispatch(fetchMe())
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const me = getMe(state);
|
||||
const loading = isFetchMePending(state);
|
||||
const error = getFetchMeFailure(state);
|
||||
return {
|
||||
authenticated,
|
||||
me,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(App))
|
||||
);
|
||||
35
scm-ui/src/containers/App.scss
Normal file
35
scm-ui/src/containers/App.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@import "bulma/sass/utilities/initial-variables";
|
||||
@import "bulma/sass/utilities/functions";
|
||||
|
||||
$blue: #33B2E8;
|
||||
|
||||
// $footer-background-color
|
||||
|
||||
.is-ellipsis-overflow {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.has-rounded-border {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.is-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fitParent {
|
||||
// TODO get rid of important
|
||||
margin: 0 !important;
|
||||
// 3.8em for line-numbers
|
||||
padding: 0 0 0 3.8em !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: whitesmoke;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 6. Import the rest of Bulma
|
||||
@import "bulma/bulma";
|
||||
167
scm-ui/src/containers/Login.js
Normal file
167
scm-ui/src/containers/Login.js
Normal file
@@ -0,0 +1,167 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Redirect, withRouter } from "react-router-dom";
|
||||
import injectSheet from "react-jss";
|
||||
import { translate } from "react-i18next";
|
||||
import {
|
||||
login,
|
||||
isAuthenticated,
|
||||
isLoginPending,
|
||||
getLoginFailure
|
||||
} from "../modules/auth";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { InputField } from "../components/forms";
|
||||
import { SubmitButton } from "../components/buttons";
|
||||
|
||||
import classNames from "classnames";
|
||||
import Avatar from "../images/blib.jpg";
|
||||
import ErrorNotification from "../components/ErrorNotification";
|
||||
|
||||
const styles = {
|
||||
avatar: {
|
||||
marginTop: "-70px",
|
||||
paddingBottom: "20px"
|
||||
},
|
||||
avatarImage: {
|
||||
border: "1px solid lightgray",
|
||||
padding: "5px",
|
||||
background: "#fff",
|
||||
borderRadius: "50%",
|
||||
width: "128px",
|
||||
height: "128px"
|
||||
},
|
||||
avatarSpacing: {
|
||||
marginTop: "5rem"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
authenticated: boolean,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatcher props
|
||||
login: (username: string, password: string) => void,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
classes: any,
|
||||
from: any,
|
||||
location: any
|
||||
};
|
||||
|
||||
type State = {
|
||||
username: string,
|
||||
password: string
|
||||
};
|
||||
|
||||
class Login extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { username: "", password: "" };
|
||||
}
|
||||
|
||||
handleUsernameChange = (value: string) => {
|
||||
this.setState({ username: value });
|
||||
};
|
||||
|
||||
handlePasswordChange = (value: string) => {
|
||||
this.setState({ password: value });
|
||||
};
|
||||
|
||||
handleSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.login(this.state.username, this.state.password);
|
||||
}
|
||||
};
|
||||
|
||||
isValid() {
|
||||
return this.state.username && this.state.password;
|
||||
}
|
||||
|
||||
isInValid() {
|
||||
return !this.isValid();
|
||||
}
|
||||
|
||||
renderRedirect = () => {
|
||||
const { from } = this.props.location.state || { from: { pathname: "/" } };
|
||||
return <Redirect to={from} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticated, loading, error, t, classes } = this.props;
|
||||
|
||||
if (authenticated) {
|
||||
return this.renderRedirect();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hero has-background-light">
|
||||
<div className="hero-body">
|
||||
<div className="container has-text-centered">
|
||||
<div className="column is-4 is-offset-4">
|
||||
<h3 className="title">{t("login.title")}</h3>
|
||||
<p className="subtitle">{t("login.subtitle")}</p>
|
||||
<div className={classNames("box", classes.avatarSpacing)}>
|
||||
<figure className={classes.avatar}>
|
||||
<img
|
||||
className={classes.avatarImage}
|
||||
src={Avatar}
|
||||
alt={t("login.logo-alt")}
|
||||
/>
|
||||
</figure>
|
||||
<ErrorNotification error={error} />
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<InputField
|
||||
placeholder={t("login.username-placeholder")}
|
||||
autofocus={true}
|
||||
onChange={this.handleUsernameChange}
|
||||
/>
|
||||
<InputField
|
||||
placeholder={t("login.password-placeholder")}
|
||||
type="password"
|
||||
onChange={this.handlePasswordChange}
|
||||
/>
|
||||
<SubmitButton
|
||||
label={t("login.submit")}
|
||||
disabled={this.isInValid()}
|
||||
fullWidth={true}
|
||||
loading={loading}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const loading = isLoginPending(state);
|
||||
const error = getLoginFailure(state);
|
||||
return {
|
||||
authenticated,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
login: (username: string, password: string) =>
|
||||
dispatch(login(username, password))
|
||||
};
|
||||
};
|
||||
|
||||
const StyledLogin = injectSheet(styles)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(Login))
|
||||
);
|
||||
export default withRouter(StyledLogin);
|
||||
71
scm-ui/src/containers/Logout.js
Normal file
71
scm-ui/src/containers/Logout.js
Normal file
@@ -0,0 +1,71 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import {
|
||||
logout,
|
||||
isAuthenticated,
|
||||
isLogoutPending,
|
||||
getLogoutFailure
|
||||
} from "../modules/auth";
|
||||
import ErrorPage from "../components/ErrorPage";
|
||||
import Loading from "../components/Loading";
|
||||
|
||||
type Props = {
|
||||
authenticated: boolean,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatcher functions
|
||||
logout: () => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Logout extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.logout();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { authenticated, loading, error, t } = this.props;
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("logout.error.title")}
|
||||
subtitle={t("logout.error.subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else if (loading || authenticated) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const loading = isLogoutPending(state);
|
||||
const error = getLogoutFailure(state);
|
||||
return {
|
||||
authenticated,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
logout: () => dispatch(logout())
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("commons")(Logout));
|
||||
62
scm-ui/src/containers/Main.js
Normal file
62
scm-ui/src/containers/Main.js
Normal file
@@ -0,0 +1,62 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import { Route, withRouter } from "react-router";
|
||||
|
||||
import Repositories from "../repositories/containers/Repositories";
|
||||
import Users from "../users/containers/Users";
|
||||
import Login from "../containers/Login";
|
||||
import Logout from "../containers/Logout";
|
||||
|
||||
import { Switch } from "react-router-dom";
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
import AddUser from "../users/containers/AddUser";
|
||||
import SingleUser from "../users/containers/SingleUser";
|
||||
|
||||
type Props = {
|
||||
authenticated?: boolean
|
||||
};
|
||||
|
||||
class Main extends React.Component<Props> {
|
||||
render() {
|
||||
const { authenticated } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Switch>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/"
|
||||
component={Repositories}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route path="/logout" component={Logout} />
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/users"
|
||||
component={Users}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/users/add"
|
||||
component={AddUser}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/users/:page"
|
||||
component={Users}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/user/:name"
|
||||
component={SingleUser}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Main);
|
||||
32
scm-ui/src/createReduxStore.js
Normal file
32
scm-ui/src/createReduxStore.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
import thunk from "redux-thunk";
|
||||
import logger from "redux-logger";
|
||||
import { createStore, compose, applyMiddleware, combineReducers } from "redux";
|
||||
import { routerReducer, routerMiddleware } from "react-router-redux";
|
||||
|
||||
import users from "./users/modules/users";
|
||||
import auth from "./modules/auth";
|
||||
import pending from "./modules/pending";
|
||||
import failure from "./modules/failure";
|
||||
|
||||
import type { BrowserHistory } from "history/createBrowserHistory";
|
||||
|
||||
function createReduxStore(history: BrowserHistory) {
|
||||
const composeEnhancers =
|
||||
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const reducer = combineReducers({
|
||||
router: routerReducer,
|
||||
pending,
|
||||
failure,
|
||||
users,
|
||||
auth
|
||||
});
|
||||
|
||||
return createStore(
|
||||
reducer,
|
||||
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk, logger))
|
||||
);
|
||||
}
|
||||
|
||||
export default createReduxStore;
|
||||
37
scm-ui/src/i18n.js
Normal file
37
scm-ui/src/i18n.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from "i18next";
|
||||
import Backend from "i18next-fetch-backend";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { reactI18nextModule } from "react-i18next";
|
||||
|
||||
const loadPath = process.env.PUBLIC_URL + "/locales/{{lng}}/{{ns}}.json";
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(reactI18nextModule)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
// have a common namespace used around the full app
|
||||
ns: ["commons"],
|
||||
defaultNS: "commons",
|
||||
|
||||
debug: true,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // not needed for react!!
|
||||
},
|
||||
|
||||
react: {
|
||||
wait: true
|
||||
},
|
||||
|
||||
backend: {
|
||||
loadPath: loadPath,
|
||||
init: {
|
||||
credentials: "same-origin"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
BIN
scm-ui/src/images/blib.jpg
Normal file
BIN
scm-ui/src/images/blib.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
36
scm-ui/src/images/loading.svg
Normal file
36
scm-ui/src/images/loading.svg
Normal file
@@ -0,0 +1,36 @@
|
||||
<svg version="1.1" id="L7" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve">
|
||||
<path fill="#33B2E8" d="M31.6,3.5C5.9,13.6-6.6,42.7,3.5,68.4c10.1,25.7,39.2,38.3,64.9,28.1l-3.1-7.9c-21.3,8.4-45.4-2-53.8-23.3
|
||||
c-8.4-21.3,2-45.4,23.3-53.8L31.6,3.5z">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="2s"
|
||||
from="0 50 50"
|
||||
to="360 50 50"
|
||||
repeatCount="indefinite" />
|
||||
</path>
|
||||
<path fill="#33B2E8" d="M42.3,39.6c5.7-4.3,13.9-3.1,18.1,2.7c4.3,5.7,3.1,13.9-2.7,18.1l4.1,5.5c8.8-6.5,10.6-19,4.1-27.7
|
||||
c-6.5-8.8-19-10.6-27.7-4.1L42.3,39.6z">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="1s"
|
||||
from="0 50 50"
|
||||
to="-360 50 50"
|
||||
repeatCount="indefinite" />
|
||||
</path>
|
||||
<path fill="#33B2E8" d="M82,35.7C74.1,18,53.4,10.1,35.7,18S10.1,46.6,18,64.3l7.6-3.4c-6-13.5,0-29.3,13.5-35.3s29.3,0,35.3,13.5
|
||||
L82,35.7z">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="2s"
|
||||
from="0 50 50"
|
||||
to="360 50 50"
|
||||
repeatCount="indefinite" />
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
scm-ui/src/images/logo.png
Normal file
BIN
scm-ui/src/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
46
scm-ui/src/index.js
Normal file
46
scm-ui/src/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import App from "./containers/App";
|
||||
import registerServiceWorker from "./registerServiceWorker";
|
||||
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "./i18n";
|
||||
|
||||
import { Provider } from "react-redux";
|
||||
import createHistory from "history/createBrowserHistory";
|
||||
|
||||
import type { BrowserHistory } from "history/createBrowserHistory";
|
||||
|
||||
import createReduxStore from "./createReduxStore";
|
||||
import { ConnectedRouter } from "react-router-redux";
|
||||
|
||||
const publicUrl: string = process.env.PUBLIC_URL || "";
|
||||
|
||||
// Create a history of your choosing (we're using a browser history in this case)
|
||||
const history: BrowserHistory = createHistory({
|
||||
basename: publicUrl
|
||||
});
|
||||
|
||||
// Add the reducer to your store on the `router` key
|
||||
// Also apply our middleware for navigating
|
||||
const store = createReduxStore(history);
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error("could not find root element");
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{/* ConnectedRouter will use the store from Provider automatically */}
|
||||
<ConnectedRouter history={history}>
|
||||
<App />
|
||||
</ConnectedRouter>
|
||||
</I18nextProvider>
|
||||
</Provider>,
|
||||
root
|
||||
);
|
||||
|
||||
registerServiceWorker();
|
||||
235
scm-ui/src/modules/auth.js
Normal file
235
scm-ui/src/modules/auth.js
Normal file
@@ -0,0 +1,235 @@
|
||||
// @flow
|
||||
import type { Me } from "../types/Me";
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient, UNAUTHORIZED_ERROR } from "../apiclient";
|
||||
import { isPending } from "./pending";
|
||||
import { getFailure } from "./failure";
|
||||
|
||||
// Action
|
||||
|
||||
export const LOGIN = "scm/auth/LOGIN";
|
||||
export const LOGIN_PENDING = `${LOGIN}_${types.PENDING_SUFFIX}`;
|
||||
export const LOGIN_SUCCESS = `${LOGIN}_${types.SUCCESS_SUFFIX}`;
|
||||
export const LOGIN_FAILURE = `${LOGIN}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_ME = "scm/auth/FETCH_ME";
|
||||
export const FETCH_ME_PENDING = `${FETCH_ME}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_ME_SUCCESS = `${FETCH_ME}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_ME_FAILURE = `${FETCH_ME}_${types.FAILURE_SUFFIX}`;
|
||||
export const FETCH_ME_UNAUTHORIZED = `${FETCH_ME}_UNAUTHORIZED`;
|
||||
|
||||
export const LOGOUT = "scm/auth/LOGOUT";
|
||||
export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`;
|
||||
export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`;
|
||||
export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
// Reducer
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function reducer(
|
||||
state: Object = initialState,
|
||||
action: Object = { type: "UNKNOWN" }
|
||||
) {
|
||||
switch (action.type) {
|
||||
case LOGIN_SUCCESS:
|
||||
case FETCH_ME_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
me: action.payload,
|
||||
authenticated: true
|
||||
};
|
||||
case FETCH_ME_UNAUTHORIZED:
|
||||
return {
|
||||
me: {},
|
||||
authenticated: false
|
||||
};
|
||||
case LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const loginPending = () => {
|
||||
return {
|
||||
type: LOGIN_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const loginSuccess = (me: Me) => {
|
||||
return {
|
||||
type: LOGIN_SUCCESS,
|
||||
payload: me
|
||||
};
|
||||
};
|
||||
|
||||
export const loginFailure = (error: Error) => {
|
||||
return {
|
||||
type: LOGIN_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutPending = () => {
|
||||
return {
|
||||
type: LOGOUT_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutSuccess = () => {
|
||||
return {
|
||||
type: LOGOUT_SUCCESS
|
||||
};
|
||||
};
|
||||
|
||||
export const logoutFailure = (error: Error) => {
|
||||
return {
|
||||
type: LOGOUT_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMePending = () => {
|
||||
return {
|
||||
type: FETCH_ME_PENDING
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeSuccess = (me: Me) => {
|
||||
return {
|
||||
type: FETCH_ME_SUCCESS,
|
||||
payload: me
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeUnauthenticated = () => {
|
||||
return {
|
||||
type: FETCH_ME_UNAUTHORIZED,
|
||||
resetPending: true
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeFailure = (error: Error) => {
|
||||
return {
|
||||
type: FETCH_ME_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
};
|
||||
|
||||
// urls
|
||||
|
||||
const ME_URL = "/me";
|
||||
const LOGIN_URL = "/auth/access_token";
|
||||
|
||||
// side effects
|
||||
|
||||
const callFetchMe = (): Promise<Me> => {
|
||||
return apiClient
|
||||
.get(ME_URL)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
return { name: json.name, displayName: json.displayName };
|
||||
});
|
||||
};
|
||||
|
||||
export const login = (username: string, password: string) => {
|
||||
const login_data = {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
username,
|
||||
password
|
||||
};
|
||||
return function(dispatch: any) {
|
||||
dispatch(loginPending());
|
||||
return apiClient
|
||||
.post(LOGIN_URL, login_data)
|
||||
.then(response => {
|
||||
return callFetchMe();
|
||||
})
|
||||
.then(me => {
|
||||
dispatch(loginSuccess(me));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(loginFailure(err));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMe = () => {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchMePending());
|
||||
return callFetchMe()
|
||||
.then(me => {
|
||||
dispatch(fetchMeSuccess(me));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error === UNAUTHORIZED_ERROR) {
|
||||
dispatch(fetchMeUnauthenticated());
|
||||
} else {
|
||||
dispatch(fetchMeFailure(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = () => {
|
||||
return function(dispatch: any) {
|
||||
dispatch(logoutPending());
|
||||
return apiClient
|
||||
.delete(LOGIN_URL)
|
||||
.then(() => {
|
||||
dispatch(logoutSuccess());
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(logoutFailure(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// selectors
|
||||
|
||||
const stateAuth = (state: Object): Object => {
|
||||
return state.auth || {};
|
||||
};
|
||||
|
||||
export const isAuthenticated = (state: Object) => {
|
||||
if (stateAuth(state).authenticated) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getMe = (state: Object): Me => {
|
||||
return stateAuth(state).me;
|
||||
};
|
||||
|
||||
export const isFetchMePending = (state: Object) => {
|
||||
return isPending(state, FETCH_ME);
|
||||
};
|
||||
|
||||
export const getFetchMeFailure = (state: Object) => {
|
||||
return getFailure(state, FETCH_ME);
|
||||
};
|
||||
|
||||
export const isLoginPending = (state: Object) => {
|
||||
return isPending(state, LOGIN);
|
||||
};
|
||||
|
||||
export const getLoginFailure = (state: Object) => {
|
||||
return getFailure(state, LOGIN);
|
||||
};
|
||||
|
||||
export const isLogoutPending = (state: Object) => {
|
||||
return isPending(state, LOGOUT);
|
||||
};
|
||||
|
||||
export const getLogoutFailure = (state: Object) => {
|
||||
return getFailure(state, LOGOUT);
|
||||
};
|
||||
279
scm-ui/src/modules/auth.test.js
Normal file
279
scm-ui/src/modules/auth.test.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import reducer, {
|
||||
fetchMeSuccess,
|
||||
logout,
|
||||
logoutSuccess,
|
||||
loginSuccess,
|
||||
fetchMeUnauthenticated,
|
||||
LOGIN_SUCCESS,
|
||||
login,
|
||||
LOGIN_FAILURE,
|
||||
LOGOUT_FAILURE,
|
||||
LOGOUT_SUCCESS,
|
||||
FETCH_ME_SUCCESS,
|
||||
fetchMe,
|
||||
FETCH_ME_FAILURE,
|
||||
FETCH_ME_UNAUTHORIZED,
|
||||
isAuthenticated,
|
||||
LOGIN_PENDING,
|
||||
FETCH_ME_PENDING,
|
||||
LOGOUT_PENDING,
|
||||
getMe,
|
||||
isFetchMePending,
|
||||
isLoginPending,
|
||||
isLogoutPending,
|
||||
getFetchMeFailure,
|
||||
LOGIN,
|
||||
FETCH_ME,
|
||||
LOGOUT,
|
||||
getLoginFailure,
|
||||
getLogoutFailure
|
||||
} from "./auth";
|
||||
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
const me = { name: "tricia", displayName: "Tricia McMillian" };
|
||||
|
||||
describe("auth reducer", () => {
|
||||
it("should set me and login on successful fetch of me", () => {
|
||||
const state = reducer(undefined, fetchMeSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should set authenticated to false", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, fetchMeUnauthenticated());
|
||||
expect(state.me.name).toBeUndefined();
|
||||
expect(state.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the state after logout", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, logoutSuccess());
|
||||
expect(state.me).toBeUndefined();
|
||||
expect(state.authenticated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should set state authenticated and me after login", () => {
|
||||
const state = reducer(undefined, loginSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth actions", () => {
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should dispatch login success and dispatch fetch me", () => {
|
||||
fetchMock.postOnce("/scm/api/rest/v2/auth/access_token", {
|
||||
body: {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
username: "tricia",
|
||||
password: "secret123"
|
||||
},
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/scm/api/rest/v2/me", {
|
||||
body: me,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: LOGIN_PENDING },
|
||||
{ type: LOGIN_SUCCESS, payload: me }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(login("tricia", "secret123")).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch login failure", () => {
|
||||
fetchMock.postOnce("/scm/api/rest/v2/auth/access_token", {
|
||||
status: 400
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(login("tricia", "secret123")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(LOGIN_PENDING);
|
||||
expect(actions[1].type).toEqual(LOGIN_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me success", () => {
|
||||
fetchMock.getOnce("/scm/api/rest/v2/me", {
|
||||
body: me,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_ME_PENDING },
|
||||
{
|
||||
type: FETCH_ME_SUCCESS,
|
||||
payload: me
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchMe()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me failure", () => {
|
||||
fetchMock.getOnce("/scm/api/rest/v2/me", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchMe()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_ME_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_ME_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch fetch me unauthorized", () => {
|
||||
fetchMock.getOnce("/scm/api/rest/v2/me", {
|
||||
status: 401
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_ME_PENDING },
|
||||
{ type: FETCH_ME_UNAUTHORIZED, resetPending: true }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchMe()).then(() => {
|
||||
// return of async actions
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch logout success", () => {
|
||||
fetchMock.deleteOnce("/scm/api/rest/v2/auth/access_token", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
fetchMock.getOnce("/scm/api/rest/v2/me", {
|
||||
status: 401
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{ type: LOGOUT_PENDING },
|
||||
{ type: LOGOUT_SUCCESS }
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(logout()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch logout failure", () => {
|
||||
fetchMock.deleteOnce("/scm/api/rest/v2/auth/access_token", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(logout()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(LOGOUT_PENDING);
|
||||
expect(actions[1].type).toEqual(LOGOUT_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth selectors", () => {
|
||||
const error = new Error("yo it failed");
|
||||
|
||||
it("should be false, if authenticated is undefined or false", () => {
|
||||
expect(isAuthenticated({})).toBe(false);
|
||||
expect(isAuthenticated({ auth: {} })).toBe(false);
|
||||
expect(isAuthenticated({ auth: { authenticated: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true, if authenticated is true", () => {
|
||||
expect(isAuthenticated({ auth: { authenticated: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return me", () => {
|
||||
expect(getMe({ auth: { me } })).toBe(me);
|
||||
});
|
||||
|
||||
it("should return undefined, if me is not set", () => {
|
||||
expect(getMe({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return true, if FETCH_ME is pending", () => {
|
||||
expect(isFetchMePending({ pending: { [FETCH_ME]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if FETCH_ME is not in pending state", () => {
|
||||
expect(isFetchMePending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if LOGIN is pending", () => {
|
||||
expect(isLoginPending({ pending: { [LOGIN]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if LOGIN is not in pending state", () => {
|
||||
expect(isLoginPending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if LOGOUT is pending", () => {
|
||||
expect(isLogoutPending({ pending: { [LOGOUT]: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if LOGOUT is not in pending state", () => {
|
||||
expect(isLogoutPending({ pending: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for FETCH_ME", () => {
|
||||
expect(getFetchMeFailure({ failure: { [FETCH_ME]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for FETCH_ME", () => {
|
||||
expect(getFetchMeFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for LOGIN", () => {
|
||||
expect(getLoginFailure({ failure: { [LOGIN]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for LOGIN", () => {
|
||||
expect(getLoginFailure({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the error, if failure state is set for LOGOUT", () => {
|
||||
expect(getLogoutFailure({ failure: { [LOGOUT]: error } })).toBe(error);
|
||||
});
|
||||
|
||||
it("should return unknown, if failure state is not set for LOGOUT", () => {
|
||||
expect(getLogoutFailure({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
68
scm-ui/src/modules/failure.js
Normal file
68
scm-ui/src/modules/failure.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// @flow
|
||||
import type { Action } from "../types/Action";
|
||||
|
||||
const FAILURE_SUFFIX = "_FAILURE";
|
||||
const RESET_PATTERN = /^(.*)_(SUCCESS|RESET)$/;
|
||||
|
||||
function extractIdentifierFromFailure(action: Action) {
|
||||
const type = action.type;
|
||||
let identifier = type.substring(0, type.length - FAILURE_SUFFIX.length);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function removeFromState(state: Object, identifier: string) {
|
||||
const newState = {};
|
||||
for (let failureType in state) {
|
||||
if (failureType !== identifier) {
|
||||
newState[failureType] = state[failureType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
const type = action.type;
|
||||
if (type.endsWith(FAILURE_SUFFIX)) {
|
||||
const identifier = extractIdentifierFromFailure(action);
|
||||
let payload;
|
||||
if (action.payload instanceof Error) {
|
||||
payload = action.payload;
|
||||
} else if (action.payload) {
|
||||
payload = action.payload.error;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
[identifier]: payload
|
||||
};
|
||||
} else {
|
||||
const match = RESET_PATTERN.exec(type);
|
||||
if (match) {
|
||||
let identifier = match[1];
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return removeFromState(state, identifier);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getFailure(
|
||||
state: Object,
|
||||
actionType: string,
|
||||
itemId?: string | number
|
||||
) {
|
||||
if (state.failure) {
|
||||
let identifier = actionType;
|
||||
if (itemId) {
|
||||
identifier += "/" + itemId;
|
||||
}
|
||||
return state.failure[identifier];
|
||||
}
|
||||
}
|
||||
130
scm-ui/src/modules/failure.test.js
Normal file
130
scm-ui/src/modules/failure.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// @flow
|
||||
import reducer, { getFailure } from "./failure";
|
||||
|
||||
const err = new Error("something failed");
|
||||
const otherErr = new Error("something else failed");
|
||||
|
||||
describe("failure reducer", () => {
|
||||
it("should set the error for FETCH_ITEMS", () => {
|
||||
const newState = reducer({}, { type: "FETCH_ITEMS_FAILURE", payload: err });
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should do nothing for unknown action types", () => {
|
||||
const state = {};
|
||||
const newState = reducer(state, { type: "UNKNOWN" });
|
||||
expect(newState).toBe(state);
|
||||
});
|
||||
|
||||
it("should set the error for FETCH_ITEMS, if payload has multiple values", () => {
|
||||
const newState = reducer(
|
||||
{},
|
||||
{
|
||||
type: "FETCH_ITEMS_FAILURE",
|
||||
payload: { something: "something", error: err }
|
||||
}
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should set the error for FETCH_ITEMS, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: otherErr
|
||||
},
|
||||
{ type: "FETCH_ITEMS_FAILURE", payload: err }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(err);
|
||||
expect(newState["FETCH_USERS"]).toBe(otherErr);
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: err,
|
||||
FETCH_USERS: err
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
expect(newState["FETCH_USERS"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should set the error for a single item of FETCH_ITEM", () => {
|
||||
const newState = reducer(
|
||||
{},
|
||||
{ type: "FETCH_ITEM_FAILURE", payload: err, itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_ITEM/42"]).toBe(err);
|
||||
});
|
||||
|
||||
it("should reset error for a single item of FETCH_ITEM", () => {
|
||||
const newState = reducer(
|
||||
{ "FETCH_ITEM/42": err },
|
||||
{ type: "FETCH_ITEM_SUCCESS", payload: err, itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_ITEM/42"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure selector", () => {
|
||||
it("should return failure, if FETCH_ITEMS failure exists", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {
|
||||
FETCH_ITEMS: err
|
||||
}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(failure).toBe(err);
|
||||
});
|
||||
|
||||
it("should return undefined, if state has no failure", () => {
|
||||
const failure = getFailure({}, "FETCH_ITEMS");
|
||||
expect(failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined, if FETCH_ITEMS is not defined", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(failure).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return failure, if FETCH_ITEM 42 failure exists", () => {
|
||||
const failure = getFailure(
|
||||
{
|
||||
failure: {
|
||||
"FETCH_ITEM/42": err
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(failure).toBe(err);
|
||||
});
|
||||
});
|
||||
71
scm-ui/src/modules/pending.js
Normal file
71
scm-ui/src/modules/pending.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import type { Action } from "../types/Action";
|
||||
import * as types from "./types";
|
||||
|
||||
const PENDING_SUFFIX = "_" + types.PENDING_SUFFIX;
|
||||
const RESET_ACTIONTYPES = [
|
||||
types.SUCCESS_SUFFIX,
|
||||
types.FAILURE_SUFFIX,
|
||||
types.RESET_SUFFIX
|
||||
];
|
||||
|
||||
function removeFromState(state: Object, identifier: string) {
|
||||
let newState = {};
|
||||
for (let childType in state) {
|
||||
if (childType !== identifier) {
|
||||
newState[childType] = state[childType];
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
function extractIdentifierFromPending(action: Action) {
|
||||
const type = action.type;
|
||||
let identifier = type.substring(0, type.length - PENDING_SUFFIX.length);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
const type = action.type;
|
||||
if (type.endsWith(PENDING_SUFFIX)) {
|
||||
const identifier = extractIdentifierFromPending(action);
|
||||
return {
|
||||
...state,
|
||||
[identifier]: true
|
||||
};
|
||||
} else {
|
||||
const index = type.lastIndexOf("_");
|
||||
if (index > 0) {
|
||||
const actionType = type.substring(index + 1);
|
||||
if (RESET_ACTIONTYPES.indexOf(actionType) >= 0 || action.resetPending) {
|
||||
let identifier = type.substring(0, index);
|
||||
if (action.itemId) {
|
||||
identifier += "/" + action.itemId;
|
||||
}
|
||||
return removeFromState(state, identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function isPending(
|
||||
state: Object,
|
||||
actionType: string,
|
||||
itemId?: string | number
|
||||
) {
|
||||
let type = actionType;
|
||||
if (itemId) {
|
||||
type += "/" + itemId;
|
||||
}
|
||||
if (state.pending && state.pending[type]) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
143
scm-ui/src/modules/pending.test.js
Normal file
143
scm-ui/src/modules/pending.test.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import reducer, { isPending } from "./pending";
|
||||
|
||||
describe("pending reducer", () => {
|
||||
it("should set pending for FETCH_ITEMS to true", () => {
|
||||
const newState = reducer({}, { type: "FETCH_ITEMS_PENDING" });
|
||||
expect(newState["FETCH_ITEMS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should do nothing for unknown action types", () => {
|
||||
const state = {};
|
||||
const newState = reducer(state, { type: "UNKNOWN" });
|
||||
expect(newState).toBe(state);
|
||||
});
|
||||
|
||||
it("should set pending for FETCH_ITEMS to true, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_PENDING" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBe(true);
|
||||
expect(newState["FETCH_USERS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_FAILURE", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_FAILURE" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_RESET" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS, if resetPending prop is available", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SOMETHING", resetPending: true }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS, but should not affect others", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
FETCH_USERS: true,
|
||||
FETCH_ITEMS: true
|
||||
},
|
||||
{ type: "FETCH_ITEMS_SUCCESS" }
|
||||
);
|
||||
expect(newState["FETCH_ITEMS"]).toBeFalsy();
|
||||
expect(newState["FETCH_USERS"]).toBe(true);
|
||||
});
|
||||
|
||||
it("should set pending for a single item", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
"FETCH_USER/42": false
|
||||
},
|
||||
{ type: "FETCH_USER_PENDING", itemId: 21 }
|
||||
);
|
||||
expect(newState["FETCH_USER/21"]).toBe(true);
|
||||
expect(newState["FETCH_USER/42"]).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset pending for a single item", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
"FETCH_USER/42": true
|
||||
},
|
||||
{ type: "FETCH_USER_SUCCESS", itemId: 42 }
|
||||
);
|
||||
expect(newState["FETCH_USER/42"]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending selectors", () => {
|
||||
it("should return true, while FETCH_ITEMS is pending", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
FETCH_ITEMS: true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEMS"
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if pending is not defined", () => {
|
||||
const result = isPending({}, "FETCH_ITEMS");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, while FETCH_ITEM 42 is pending", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
"FETCH_ITEM/42": true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true, while FETCH_ITEM 42 is undefined", () => {
|
||||
const result = isPending(
|
||||
{
|
||||
pending: {
|
||||
"FETCH_ITEM/21": true
|
||||
}
|
||||
},
|
||||
"FETCH_ITEM",
|
||||
42
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
4
scm-ui/src/modules/types.js
Normal file
4
scm-ui/src/modules/types.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PENDING_SUFFIX = "PENDING";
|
||||
export const SUCCESS_SUFFIX = "SUCCESS";
|
||||
export const FAILURE_SUFFIX = "FAILURE";
|
||||
export const RESET_SUFFIX = "RESET";
|
||||
117
scm-ui/src/registerServiceWorker.js
Normal file
117
scm-ui/src/registerServiceWorker.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
console.log('New content is available; please refresh.');
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
||||
24
scm-ui/src/repositories/containers/Repositories.js
Normal file
24
scm-ui/src/repositories/containers/Repositories.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Page } from "../../components/layout";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Repositories extends React.Component<Props> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("repositories.title")}
|
||||
subtitle={t("repositories.subtitle")}
|
||||
>
|
||||
{t("repositories.body")}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repositories")(Repositories);
|
||||
12
scm-ui/src/tests/enzyme.js
Normal file
12
scm-ui/src/tests/enzyme.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import "raf/polyfill";
|
||||
import { configure } from "enzyme";
|
||||
import Adapter from "enzyme-adapter-react-16";
|
||||
|
||||
// Temporary hack to suppress error
|
||||
// https://github.com/facebook/create-react-app/issues/3199#issuecomment-345024029
|
||||
window.requestAnimationFrame = function(callback) {
|
||||
setTimeout(callback, 0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
configure({ adapter: new Adapter() });
|
||||
7
scm-ui/src/tests/i18n.js
Normal file
7
scm-ui/src/tests/i18n.js
Normal file
@@ -0,0 +1,7 @@
|
||||
jest.mock("react-i18next", () => ({
|
||||
// this mock makes sure any components using the translate HoC receive the t function as a prop
|
||||
translate: () => Component => {
|
||||
Component.defaultProps = { ...Component.defaultProps, t: key => key };
|
||||
return Component;
|
||||
}
|
||||
}));
|
||||
7
scm-ui/src/types/Action.js
Normal file
7
scm-ui/src/types/Action.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// @flow
|
||||
export type Action = {
|
||||
type: string,
|
||||
payload?: any,
|
||||
itemId?: string | number,
|
||||
resetPending?: boolean
|
||||
};
|
||||
12
scm-ui/src/types/Collection.js
Normal file
12
scm-ui/src/types/Collection.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import type { Links } from "./hal";
|
||||
|
||||
export type Collection = {
|
||||
_embedded: Object,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
export type PagedCollection = Collection & {
|
||||
page: number,
|
||||
pageTotal: number
|
||||
};
|
||||
6
scm-ui/src/types/Me.js
Normal file
6
scm-ui/src/types/Me.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// @flow
|
||||
|
||||
export type Me = {
|
||||
name: string,
|
||||
displayName: string
|
||||
};
|
||||
6
scm-ui/src/types/hal.js
Normal file
6
scm-ui/src/types/hal.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// @flow
|
||||
export type Link = {
|
||||
href: string
|
||||
};
|
||||
|
||||
export type Links = { [string]: Link };
|
||||
208
scm-ui/src/users/components/UserForm.js
Normal file
208
scm-ui/src/users/components/UserForm.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { User } from "../types/User";
|
||||
import { Checkbox, InputField } from "../../components/forms";
|
||||
import { SubmitButton } from "../../components/buttons";
|
||||
import * as validator from "./userValidation";
|
||||
|
||||
type Props = {
|
||||
submitForm: User => void,
|
||||
user?: User,
|
||||
loading?: boolean,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
user: User,
|
||||
mailValidationError: boolean,
|
||||
nameValidationError: boolean,
|
||||
displayNameValidationError: boolean,
|
||||
passwordValidationError: boolean,
|
||||
validatePasswordError: boolean,
|
||||
validatePassword: string
|
||||
};
|
||||
|
||||
class UserForm extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
user: {
|
||||
name: "",
|
||||
displayName: "",
|
||||
mail: "",
|
||||
password: "",
|
||||
admin: false,
|
||||
active: false,
|
||||
_links: {}
|
||||
},
|
||||
mailValidationError: false,
|
||||
displayNameValidationError: false,
|
||||
nameValidationError: false,
|
||||
passwordValidationError: false,
|
||||
validatePasswordError: false,
|
||||
validatePassword: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { user } = this.props;
|
||||
if (user) {
|
||||
this.setState({ user: { ...user } });
|
||||
}
|
||||
}
|
||||
|
||||
isFalsy(value) {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
const user = this.state.user;
|
||||
return !(
|
||||
this.state.validatePasswordError ||
|
||||
this.state.nameValidationError ||
|
||||
this.state.mailValidationError ||
|
||||
this.state.passwordValidationError ||
|
||||
this.state.displayNameValidationError ||
|
||||
this.isFalsy(user.name) ||
|
||||
this.isFalsy(user.displayName)
|
||||
);
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.submitForm(this.state.user);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, t } = this.props;
|
||||
const user = this.state.user;
|
||||
|
||||
let nameField = null;
|
||||
if (!this.props.user) {
|
||||
nameField = (
|
||||
<InputField
|
||||
label={t("user.name")}
|
||||
onChange={this.handleUsernameChange}
|
||||
value={user ? user.name : ""}
|
||||
validationError={this.state.nameValidationError}
|
||||
errorMessage={t("validation.name-invalid")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<form onSubmit={this.submit}>
|
||||
{nameField}
|
||||
<InputField
|
||||
label={t("user.displayName")}
|
||||
onChange={this.handleDisplayNameChange}
|
||||
value={user ? user.displayName : ""}
|
||||
validationError={this.state.displayNameValidationError}
|
||||
errorMessage={t("validation.displayname-invalid")}
|
||||
/>
|
||||
<InputField
|
||||
label={t("user.mail")}
|
||||
onChange={this.handleEmailChange}
|
||||
value={user ? user.mail : ""}
|
||||
validationError={this.state.mailValidationError}
|
||||
errorMessage={t("validation.mail-invalid")}
|
||||
/>
|
||||
<InputField
|
||||
label={t("user.password")}
|
||||
type="password"
|
||||
onChange={this.handlePasswordChange}
|
||||
value={user ? user.password : ""}
|
||||
validationError={this.state.validatePasswordError}
|
||||
errorMessage={t("validation.password-invalid")}
|
||||
/>
|
||||
<InputField
|
||||
label={t("validation.validatePassword")}
|
||||
type="password"
|
||||
onChange={this.handlePasswordValidationChange}
|
||||
value={this.state ? this.state.validatePassword : ""}
|
||||
validationError={this.state.passwordValidationError}
|
||||
errorMessage={t("validation.passwordValidation-invalid")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("user.admin")}
|
||||
onChange={this.handleAdminChange}
|
||||
checked={user ? user.admin : false}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("user.active")}
|
||||
onChange={this.handleActiveChange}
|
||||
checked={user ? user.active : false}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={!this.isValid()}
|
||||
loading={loading}
|
||||
label={t("user-form.submit")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
handleUsernameChange = (name: string) => {
|
||||
this.setState({
|
||||
nameValidationError: !validator.isNameValid(name),
|
||||
user: { ...this.state.user, name }
|
||||
});
|
||||
};
|
||||
|
||||
handleDisplayNameChange = (displayName: string) => {
|
||||
this.setState({
|
||||
displayNameValidationError: !validator.isDisplayNameValid(displayName),
|
||||
user: { ...this.state.user, displayName }
|
||||
});
|
||||
};
|
||||
|
||||
handleEmailChange = (mail: string) => {
|
||||
this.setState({
|
||||
mailValidationError: !validator.isMailValid(mail),
|
||||
user: { ...this.state.user, mail }
|
||||
});
|
||||
};
|
||||
|
||||
handlePasswordChange = (password: string) => {
|
||||
const validatePasswordError = !this.checkPasswords(
|
||||
password,
|
||||
this.state.validatePassword
|
||||
);
|
||||
this.setState({
|
||||
validatePasswordError: !validator.isPasswordValid(password),
|
||||
passwordValidationError: validatePasswordError,
|
||||
user: { ...this.state.user, password }
|
||||
});
|
||||
};
|
||||
|
||||
handlePasswordValidationChange = (validatePassword: string) => {
|
||||
const validatePasswordError = this.checkPasswords(
|
||||
this.state.user.password,
|
||||
validatePassword
|
||||
);
|
||||
this.setState({
|
||||
validatePassword,
|
||||
passwordValidationError: !validatePasswordError
|
||||
});
|
||||
};
|
||||
|
||||
checkPasswords = (password1: string, password2: string) => {
|
||||
return password1 === password2;
|
||||
};
|
||||
|
||||
handleAdminChange = (admin: boolean) => {
|
||||
this.setState({ user: { ...this.state.user, admin } });
|
||||
};
|
||||
|
||||
handleActiveChange = (active: boolean) => {
|
||||
this.setState({ user: { ...this.state.user, active } });
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("users")(UserForm);
|
||||
30
scm-ui/src/users/components/buttons/CreateUserButton.js
Normal file
30
scm-ui/src/users/components/buttons/CreateUserButton.js
Normal file
@@ -0,0 +1,30 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import { translate } from "react-i18next";
|
||||
import { AddButton } from "../../../components/buttons";
|
||||
import classNames from "classnames";
|
||||
|
||||
const styles = {
|
||||
spacing: {
|
||||
margin: "1em 0 0 1em"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
class CreateUserButton extends React.Component<Props> {
|
||||
render() {
|
||||
const { classes, t } = this.props;
|
||||
return (
|
||||
<div className={classNames("is-pulled-right", classes.spacing)}>
|
||||
<AddButton label={t("create-user-button.label")} link="/users/add" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("users")(injectSheet(styles)(CreateUserButton));
|
||||
57
scm-ui/src/users/components/navLinks/DeleteUserNavLink.js
Normal file
57
scm-ui/src/users/components/navLinks/DeleteUserNavLink.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { User } from "../../types/User";
|
||||
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
|
||||
import { NavAction } from "../../../components/navigation";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
confirmDialog?: boolean,
|
||||
t: string => string,
|
||||
deleteUser: (user: User) => void
|
||||
};
|
||||
|
||||
class DeleteUserNavLink extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
confirmDialog: true
|
||||
};
|
||||
|
||||
deleteUser = () => {
|
||||
this.props.deleteUser(this.props.user);
|
||||
};
|
||||
|
||||
confirmDelete = () => {
|
||||
const { t } = this.props;
|
||||
confirmAlert({
|
||||
title: t("delete-user-button.confirm-alert.title"),
|
||||
message: t("delete-user-button.confirm-alert.message"),
|
||||
buttons: [
|
||||
{
|
||||
label: t("delete-user-button.confirm-alert.submit"),
|
||||
onClick: () => this.deleteUser()
|
||||
},
|
||||
{
|
||||
label: t("delete-user-button.confirm-alert.cancel"),
|
||||
onClick: () => null
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
isDeletable = () => {
|
||||
return this.props.user._links.delete;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { confirmDialog, t } = this.props;
|
||||
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
|
||||
|
||||
if (!this.isDeletable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavAction label={t("delete-user-button.label")} action={action} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("users")(DeleteUserNavLink);
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "../../../tests/enzyme";
|
||||
import "../../../tests/i18n";
|
||||
import DeleteUserNavLink from "./DeleteUserNavLink";
|
||||
|
||||
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
|
||||
jest.mock("../../../components/modals/ConfirmAlert");
|
||||
|
||||
describe("DeleteUserNavLink", () => {
|
||||
it("should render nothing, if the delete link is missing", () => {
|
||||
const user = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(
|
||||
<DeleteUserNavLink user={user} deleteUser={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const user = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/users"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteUserNavLink user={user} deleteUser={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
|
||||
it("should open the confirm dialog on navLink click", () => {
|
||||
const user = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/users"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteUserNavLink user={user} deleteUser={() => {}} />
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(confirmAlert.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the delete user function with delete url", () => {
|
||||
const user = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/users"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let calledUrl = null;
|
||||
function capture(user) {
|
||||
calledUrl = user._links.delete.href;
|
||||
}
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteUserNavLink
|
||||
user={user}
|
||||
confirmDialog={false}
|
||||
deleteUser={capture}
|
||||
/>
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(calledUrl).toBe("/users");
|
||||
});
|
||||
});
|
||||
28
scm-ui/src/users/components/navLinks/EditUserNavLink.js
Normal file
28
scm-ui/src/users/components/navLinks/EditUserNavLink.js
Normal file
@@ -0,0 +1,28 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { User } from "../../types/User";
|
||||
import { NavLink } from "../../../components/navigation";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
user: User,
|
||||
editUrl: String
|
||||
};
|
||||
|
||||
class EditUserNavLink extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, editUrl } = this.props;
|
||||
|
||||
if (!this.isEditable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink label={t("edit-user-button.label")} to={editUrl} />;
|
||||
}
|
||||
|
||||
isEditable = () => {
|
||||
return this.props.user._links.update;
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("users")(EditUserNavLink);
|
||||
27
scm-ui/src/users/components/navLinks/EditUserNavLink.test.js
Normal file
27
scm-ui/src/users/components/navLinks/EditUserNavLink.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import "../../../tests/enzyme";
|
||||
import "../../../tests/i18n";
|
||||
import EditUserNavLink from "./EditUserNavLink";
|
||||
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const user = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const user = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/users"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
2
scm-ui/src/users/components/navLinks/index.js
Normal file
2
scm-ui/src/users/components/navLinks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as DeleteUserNavLink } from "./DeleteUserNavLink";
|
||||
export { default as EditUserNavLink } from "./EditUserNavLink";
|
||||
48
scm-ui/src/users/components/table/Details.js
Normal file
48
scm-ui/src/users/components/table/Details.js
Normal file
@@ -0,0 +1,48 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { User } from "../../types/User";
|
||||
import { translate } from "react-i18next";
|
||||
import { Checkbox } from "../../../components/forms";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Details extends React.Component<Props> {
|
||||
render() {
|
||||
const { user, t } = this.props;
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t("user.name")}</td>
|
||||
<td>{user.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.displayName")}</td>
|
||||
<td>{user.displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.mail")}</td>
|
||||
<td>{user.mail}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.admin")}</td>
|
||||
<td>
|
||||
<Checkbox checked={user.admin} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.active")}</td>
|
||||
<td>
|
||||
<Checkbox checked={user.active} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("users")(Details);
|
||||
31
scm-ui/src/users/components/table/UserRow.js
Normal file
31
scm-ui/src/users/components/table/UserRow.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { User } from "../../types/User";
|
||||
|
||||
type Props = {
|
||||
user: User
|
||||
};
|
||||
|
||||
export default class UserRow extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const to = `/user/${user.name}`;
|
||||
return (
|
||||
<tr>
|
||||
<td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td>
|
||||
<td>{this.renderLink(to, user.displayName)}</td>
|
||||
<td>
|
||||
<a href={`mailto: ${user.mail}`}>{user.mail}</a>
|
||||
</td>
|
||||
<td className="is-hidden-mobile">
|
||||
<input type="checkbox" id="admin" checked={user.admin} readOnly />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
scm-ui/src/users/components/table/UserTable.js
Normal file
35
scm-ui/src/users/components/table/UserTable.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import UserRow from "./UserRow";
|
||||
import type { User } from "../../types/User";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
users: User[]
|
||||
};
|
||||
|
||||
class UserTable extends React.Component<Props> {
|
||||
render() {
|
||||
const { users, t } = this.props;
|
||||
return (
|
||||
<table className="table is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-hidden-mobile">{t("user.name")}</th>
|
||||
<th>{t("user.displayName")}</th>
|
||||
<th>{t("user.mail")}</th>
|
||||
<th className="is-hidden-mobile">{t("user.admin")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, index) => {
|
||||
return <UserRow key={index} user={user} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("users")(UserTable);
|
||||
3
scm-ui/src/users/components/table/index.js
Normal file
3
scm-ui/src/users/components/table/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Details } from "./Details";
|
||||
export { default as UserRow } from "./UserRow";
|
||||
export { default as UserTable } from "./UserTable";
|
||||
24
scm-ui/src/users/components/userValidation.js
Normal file
24
scm-ui/src/users/components/userValidation.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
|
||||
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
|
||||
|
||||
export const isNameValid = (name: string) => {
|
||||
return nameRegex.test(name);
|
||||
};
|
||||
|
||||
export const isDisplayNameValid = (displayName: string) => {
|
||||
if (displayName) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
|
||||
|
||||
export const isMailValid = (mail: string) => {
|
||||
return mailRegex.test(mail);
|
||||
};
|
||||
|
||||
export const isPasswordValid = (password: string) => {
|
||||
return password.length > 6 && password.length < 32;
|
||||
};
|
||||
114
scm-ui/src/users/components/userValidation.test.js
Normal file
114
scm-ui/src/users/components/userValidation.test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// @flow
|
||||
import * as validator from "./userValidation";
|
||||
|
||||
describe("test name validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid names taken from ValidationUtilTest.java
|
||||
const invalidNames = [
|
||||
" test 123",
|
||||
" test 123 ",
|
||||
"test 123 ",
|
||||
"test/123",
|
||||
"test%123",
|
||||
"test:123",
|
||||
"t ",
|
||||
" t",
|
||||
" t ",
|
||||
""
|
||||
];
|
||||
for (let name of invalidNames) {
|
||||
expect(validator.isNameValid(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid names taken from ValidationUtilTest.java
|
||||
const validNames = [
|
||||
"test",
|
||||
"test.git",
|
||||
"Test123.git",
|
||||
"Test123-git",
|
||||
"Test_user-123.git",
|
||||
"test@scm-manager.de",
|
||||
"test 123",
|
||||
"tt",
|
||||
"t"
|
||||
];
|
||||
for (let name of validNames) {
|
||||
expect(validator.isNameValid(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test displayName validation", () => {
|
||||
it("should return false", () => {
|
||||
expect(validator.isDisplayNameValid("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid names taken from ValidationUtilTest.java
|
||||
const validNames = [
|
||||
"Arthur Dent",
|
||||
"Tricia.McMillan@hitchhiker.com",
|
||||
"Ford Prefect (ford.prefect@hitchhiker.com)",
|
||||
"Zaphod Beeblebrox <zaphod.beeblebrox@hitchhiker.com>",
|
||||
"Marvin, der depressive Roboter"
|
||||
];
|
||||
for (let name of validNames) {
|
||||
expect(validator.isDisplayNameValid(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test mail validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid taken from ValidationUtilTest.java
|
||||
const invalid = [
|
||||
"ostfalia.de",
|
||||
"@ostfalia.de",
|
||||
"s.sdorra@",
|
||||
"s.sdorra@ostfalia",
|
||||
"s.sdorra@@ostfalia.de",
|
||||
"s.sdorra@ ostfalia.de",
|
||||
"s.sdorra @ostfalia.de"
|
||||
];
|
||||
for (let mail of invalid) {
|
||||
expect(validator.isMailValid(mail)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid taken from ValidationUtilTest.java
|
||||
const valid = [
|
||||
"s.sdorra@ostfalia.de",
|
||||
"sdorra@ostfalia.de",
|
||||
"s.sdorra@hbk-bs.de",
|
||||
"s.sdorra@gmail.com",
|
||||
"s.sdorra@t.co",
|
||||
"s.sdorra@ucla.college",
|
||||
"s.sdorra@example.xn--p1ai",
|
||||
"s.sdorra@scm.solutions"
|
||||
];
|
||||
for (let mail of valid) {
|
||||
expect(validator.isMailValid(mail)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test password validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid taken from ValidationUtilTest.java
|
||||
const invalid = ["", "abc", "aaabbbcccdddeeefffggghhhiiijjjkkk"];
|
||||
for (let password of invalid) {
|
||||
expect(validator.isPasswordValid(password)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid taken from ValidationUtilTest.java
|
||||
const valid = ["secret123", "mySuperSecretPassword"];
|
||||
for (let password of valid) {
|
||||
expect(validator.isPasswordValid(password)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
84
scm-ui/src/users/containers/AddUser.js
Normal file
84
scm-ui/src/users/containers/AddUser.js
Normal file
@@ -0,0 +1,84 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import UserForm from "./../components/UserForm";
|
||||
import type { User } from "../types/User";
|
||||
import type { History } from "history";
|
||||
import {
|
||||
createUser,
|
||||
createUserReset,
|
||||
isCreateUserPending,
|
||||
getCreateUserFailure
|
||||
} from "../modules/users";
|
||||
import { Page } from "../../components/layout";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
|
||||
// dispatcher functions
|
||||
addUser: (user: User, callback?: () => void) => void,
|
||||
resetForm: () => void,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
history: History
|
||||
};
|
||||
|
||||
class AddUser extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.resetForm();
|
||||
}
|
||||
|
||||
userCreated = () => {
|
||||
const { history } = this.props;
|
||||
history.push("/users");
|
||||
};
|
||||
|
||||
createUser = (user: User) => {
|
||||
this.props.addUser(user, this.userCreated);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, loading, error } = this.props;
|
||||
|
||||
return (
|
||||
<Page
|
||||
title={t("add-user.title")}
|
||||
subtitle={t("add-user.subtitle")}
|
||||
error={error}
|
||||
>
|
||||
<UserForm
|
||||
submitForm={user => this.createUser(user)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
addUser: (user: User, callback?: () => void) => {
|
||||
dispatch(createUser(user, callback));
|
||||
},
|
||||
resetForm: () => {
|
||||
dispatch(createUserReset());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loading = isCreateUserPending(state);
|
||||
const error = getCreateUserFailure(state);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("users")(AddUser));
|
||||
71
scm-ui/src/users/containers/EditUser.js
Normal file
71
scm-ui/src/users/containers/EditUser.js
Normal file
@@ -0,0 +1,71 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import UserForm from "./../components/UserForm";
|
||||
import type { User } from "../types/User";
|
||||
import {
|
||||
modifyUser,
|
||||
isModifyUserPending,
|
||||
getModifyUserFailure
|
||||
} from "../modules/users";
|
||||
import type { History } from "history";
|
||||
import ErrorNotification from "../../components/ErrorNotification";
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatch functions
|
||||
modifyUser: (user: User, callback?: () => void) => void,
|
||||
|
||||
// context objects
|
||||
user: User,
|
||||
history: History
|
||||
};
|
||||
|
||||
class EditUser extends React.Component<Props> {
|
||||
userModified = (user: User) => () => {
|
||||
this.props.history.push(`/user/${user.name}`);
|
||||
};
|
||||
|
||||
modifyUser = (user: User) => {
|
||||
this.props.modifyUser(user, this.userModified(user));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user, loading, error } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ErrorNotification error={error} />
|
||||
<UserForm
|
||||
submitForm={user => this.modifyUser(user)}
|
||||
user={user}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
modifyUser: (user: User, callback?: () => void) => {
|
||||
dispatch(modifyUser(user, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loading = isModifyUserPending(state, ownProps.user.name);
|
||||
const error = getModifyUserFailure(state, ownProps.user.name);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withRouter(EditUser));
|
||||
146
scm-ui/src/users/containers/SingleUser.js
Normal file
146
scm-ui/src/users/containers/SingleUser.js
Normal file
@@ -0,0 +1,146 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Page } from "../../components/layout";
|
||||
import { Route } from "react-router";
|
||||
import { Details } from "./../components/table";
|
||||
import EditUser from "./EditUser";
|
||||
import type { User } from "../types/User";
|
||||
import type { History } from "history";
|
||||
import {
|
||||
fetchUser,
|
||||
deleteUser,
|
||||
getUserByName,
|
||||
isFetchUserPending,
|
||||
getFetchUserFailure,
|
||||
isDeleteUserPending,
|
||||
getDeleteUserFailure
|
||||
} from "../modules/users";
|
||||
import Loading from "../../components/Loading";
|
||||
|
||||
import { Navigation, Section, NavLink } from "../../components/navigation";
|
||||
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks";
|
||||
import ErrorPage from "../../components/ErrorPage";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
user: User,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatcher functions
|
||||
deleteUser: (user: User, callback?: () => void) => void,
|
||||
fetchUser: string => void,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
match: any,
|
||||
history: History
|
||||
};
|
||||
|
||||
class SingleUser extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchUser(this.props.name);
|
||||
}
|
||||
|
||||
userDeleted = () => {
|
||||
this.props.history.push("/users");
|
||||
};
|
||||
|
||||
deleteUser = (user: User) => {
|
||||
this.props.deleteUser(user, this.userDeleted);
|
||||
};
|
||||
|
||||
stripEndingSlash = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
return url.substring(0, url.length - 2);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
matchedUrl = () => {
|
||||
return this.stripEndingSlash(this.props.match.url);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, loading, error, user } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("single-user.error-title")}
|
||||
subtitle={t("single-user.error-subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const url = this.matchedUrl();
|
||||
|
||||
return (
|
||||
<Page title={user.displayName}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters">
|
||||
<Route path={url} exact component={() => <Details user={user} />} />
|
||||
<Route
|
||||
path={`${url}/edit`}
|
||||
component={() => <EditUser user={user} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("single-user.navigation-label")}>
|
||||
<NavLink
|
||||
to={`${url}`}
|
||||
label={t("single-user.information-label")}
|
||||
/>
|
||||
<EditUserNavLink user={user} editUrl={`${url}/edit`} />
|
||||
</Section>
|
||||
<Section label={t("single-user.actions-label")}>
|
||||
<DeleteUserNavLink user={user} deleteUser={this.deleteUser} />
|
||||
<NavLink to="/users" label={t("single-user.back-label")} />
|
||||
</Section>
|
||||
</Navigation>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const name = ownProps.match.params.name;
|
||||
const user = getUserByName(state, name);
|
||||
const loading =
|
||||
isFetchUserPending(state, name) || isDeleteUserPending(state, name);
|
||||
const error =
|
||||
getFetchUserFailure(state, name) || getDeleteUserFailure(state, name);
|
||||
|
||||
return {
|
||||
name,
|
||||
user,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchUser: (name: string) => {
|
||||
dispatch(fetchUser(name));
|
||||
},
|
||||
deleteUser: (user: User, callback?: () => void) => {
|
||||
dispatch(deleteUser(user, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("users")(SingleUser));
|
||||
140
scm-ui/src/users/containers/Users.js
Normal file
140
scm-ui/src/users/containers/Users.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import type { History } from "history";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
import {
|
||||
fetchUsersByPage,
|
||||
fetchUsersByLink,
|
||||
getUsersFromState,
|
||||
selectListAsCollection,
|
||||
isPermittedToCreateUsers,
|
||||
isFetchUsersPending,
|
||||
getFetchUsersFailure
|
||||
} from "../modules/users";
|
||||
|
||||
import { Page } from "../../components/layout";
|
||||
import { UserTable } from "./../components/table";
|
||||
import type { User } from "../types/User";
|
||||
import type { PagedCollection } from "../../types/Collection";
|
||||
import Paginator from "../../components/Paginator";
|
||||
import CreateUserButton from "../components/buttons/CreateUserButton";
|
||||
|
||||
type Props = {
|
||||
users: User[],
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
canAddUsers: boolean,
|
||||
list: PagedCollection,
|
||||
page: number,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
history: History,
|
||||
|
||||
// dispatch functions
|
||||
fetchUsersByPage: (page: number) => void,
|
||||
fetchUsersByLink: (link: string) => void
|
||||
};
|
||||
|
||||
class Users extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchUsersByPage(this.props.page);
|
||||
}
|
||||
|
||||
onPageChange = (link: string) => {
|
||||
this.props.fetchUsersByLink(link);
|
||||
};
|
||||
|
||||
/**
|
||||
* reflect page transitions in the uri
|
||||
*/
|
||||
componentDidUpdate = (prevProps: Props) => {
|
||||
const { page, list } = this.props;
|
||||
if (list.page) {
|
||||
// backend starts paging by 0
|
||||
const statePage: number = list.page + 1;
|
||||
if (page !== statePage) {
|
||||
this.props.history.push(`/users/${statePage}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { users, loading, error, t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("users.title")}
|
||||
subtitle={t("users.subtitle")}
|
||||
loading={loading || !users}
|
||||
error={error}
|
||||
>
|
||||
<UserTable users={users} />
|
||||
{this.renderPaginator()}
|
||||
{this.renderCreateButton()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
renderPaginator() {
|
||||
const { list } = this.props;
|
||||
if (list) {
|
||||
return <Paginator collection={list} onPageChange={this.onPageChange} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderCreateButton() {
|
||||
if (this.props.canAddUsers) {
|
||||
return <CreateUserButton />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getPageFromProps = props => {
|
||||
let page = props.match.params.page;
|
||||
if (page) {
|
||||
page = parseInt(page, 10);
|
||||
} else {
|
||||
page = 1;
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const users = getUsersFromState(state);
|
||||
const loading = isFetchUsersPending(state);
|
||||
const error = getFetchUsersFailure(state);
|
||||
|
||||
const page = getPageFromProps(ownProps);
|
||||
const canAddUsers = isPermittedToCreateUsers(state);
|
||||
const list = selectListAsCollection(state);
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
canAddUsers,
|
||||
list,
|
||||
page
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchUsersByPage: (page: number) => {
|
||||
dispatch(fetchUsersByPage(page));
|
||||
},
|
||||
fetchUsersByLink: (link: string) => {
|
||||
dispatch(fetchUsersByLink(link));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("users")(Users));
|
||||
479
scm-ui/src/users/modules/users.js
Normal file
479
scm-ui/src/users/modules/users.js
Normal file
@@ -0,0 +1,479 @@
|
||||
// @flow
|
||||
import { apiClient } from "../../apiclient";
|
||||
import { isPending } from "../../modules/pending";
|
||||
import { getFailure } from "../../modules/failure";
|
||||
import * as types from "../../modules/types";
|
||||
import type { User } from "../types/User";
|
||||
import { combineReducers, Dispatch } from "redux";
|
||||
import type { Action } from "../../types/Action";
|
||||
import type { PagedCollection } from "../../types/Collection";
|
||||
|
||||
export const FETCH_USERS = "scm/users/FETCH_USERS";
|
||||
export const FETCH_USERS_PENDING = `${FETCH_USERS}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_USERS_SUCCESS = `${FETCH_USERS}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_USERS_FAILURE = `${FETCH_USERS}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_USER = "scm/users/FETCH_USER";
|
||||
export const FETCH_USER_PENDING = `${FETCH_USER}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_USER_SUCCESS = `${FETCH_USER}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_USER_FAILURE = `${FETCH_USER}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const CREATE_USER = "scm/users/CREATE_USER";
|
||||
export const CREATE_USER_PENDING = `${CREATE_USER}_${types.PENDING_SUFFIX}`;
|
||||
export const CREATE_USER_SUCCESS = `${CREATE_USER}_${types.SUCCESS_SUFFIX}`;
|
||||
export const CREATE_USER_FAILURE = `${CREATE_USER}_${types.FAILURE_SUFFIX}`;
|
||||
export const CREATE_USER_RESET = `${CREATE_USER}_${types.RESET_SUFFIX}`;
|
||||
|
||||
export const MODIFY_USER = "scm/users/MODIFY_USER";
|
||||
export const MODIFY_USER_PENDING = `${MODIFY_USER}_${types.PENDING_SUFFIX}`;
|
||||
export const MODIFY_USER_SUCCESS = `${MODIFY_USER}_${types.SUCCESS_SUFFIX}`;
|
||||
export const MODIFY_USER_FAILURE = `${MODIFY_USER}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const DELETE_USER = "scm/users/DELETE";
|
||||
export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`;
|
||||
export const DELETE_USER_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`;
|
||||
export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
const USERS_URL = "users";
|
||||
|
||||
const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
|
||||
|
||||
// TODO i18n for error messages
|
||||
|
||||
// fetch users
|
||||
|
||||
export function fetchUsers() {
|
||||
return fetchUsersByLink(USERS_URL);
|
||||
}
|
||||
|
||||
export function fetchUsersByPage(page: number) {
|
||||
// backend start counting by 0
|
||||
return fetchUsersByLink(USERS_URL + "?page=" + (page - 1));
|
||||
}
|
||||
|
||||
export function fetchUsersByLink(link: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchUsersPending());
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
dispatch(fetchUsersSuccess(data));
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(`could not fetch users: ${cause.message}`);
|
||||
dispatch(fetchUsersFailure(USERS_URL, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUsersPending(): Action {
|
||||
return {
|
||||
type: FETCH_USERS_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUsersSuccess(users: any): Action {
|
||||
return {
|
||||
type: FETCH_USERS_SUCCESS,
|
||||
payload: users
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUsersFailure(url: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_USERS_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//fetch user
|
||||
export function fetchUser(name: string) {
|
||||
const userUrl = USERS_URL + "/" + name;
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchUserPending(name));
|
||||
return apiClient
|
||||
.get(userUrl)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
dispatch(fetchUserSuccess(data));
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(`could not fetch user: ${cause.message}`);
|
||||
dispatch(fetchUserFailure(name, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserPending(name: string): Action {
|
||||
return {
|
||||
type: FETCH_USER_PENDING,
|
||||
payload: name,
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserSuccess(user: any): Action {
|
||||
return {
|
||||
type: FETCH_USER_SUCCESS,
|
||||
payload: user,
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserFailure(name: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_USER_FAILURE,
|
||||
payload: {
|
||||
name,
|
||||
error
|
||||
},
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
//create user
|
||||
|
||||
export function createUser(user: User, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(createUserPending(user));
|
||||
return apiClient
|
||||
.postWithContentType(USERS_URL, user, CONTENT_TYPE_USER)
|
||||
.then(() => {
|
||||
dispatch(createUserSuccess());
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err =>
|
||||
dispatch(
|
||||
createUserFailure(
|
||||
new Error(`failed to add user ${user.name}: ${err.message}`)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUserPending(user: User): Action {
|
||||
return {
|
||||
type: CREATE_USER_PENDING,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
export function createUserSuccess(): Action {
|
||||
return {
|
||||
type: CREATE_USER_SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
export function createUserFailure(error: Error): Action {
|
||||
return {
|
||||
type: CREATE_USER_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
}
|
||||
|
||||
export function createUserReset() {
|
||||
return {
|
||||
type: CREATE_USER_RESET
|
||||
};
|
||||
}
|
||||
|
||||
//modify user
|
||||
|
||||
export function modifyUser(user: User, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(modifyUserPending(user));
|
||||
return apiClient
|
||||
.putWithContentType(user._links.update.href, user, CONTENT_TYPE_USER)
|
||||
.then(() => {
|
||||
dispatch(modifyUserSuccess(user));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(modifyUserFailure(user, err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyUserPending(user: User): Action {
|
||||
return {
|
||||
type: MODIFY_USER_PENDING,
|
||||
payload: user,
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyUserSuccess(user: User): Action {
|
||||
return {
|
||||
type: MODIFY_USER_SUCCESS,
|
||||
payload: user,
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyUserFailure(user: User, error: Error): Action {
|
||||
return {
|
||||
type: MODIFY_USER_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
user
|
||||
},
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
//delete user
|
||||
|
||||
export function deleteUser(user: User, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(deleteUserPending(user));
|
||||
return apiClient
|
||||
.delete(user._links.delete.href)
|
||||
.then(() => {
|
||||
dispatch(deleteUserSuccess(user));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(
|
||||
`could not delete user ${user.name}: ${cause.message}`
|
||||
);
|
||||
dispatch(deleteUserFailure(user, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUserPending(user: User): Action {
|
||||
return {
|
||||
type: DELETE_USER_PENDING,
|
||||
payload: user,
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUserSuccess(user: User): Action {
|
||||
return {
|
||||
type: DELETE_USER_SUCCESS,
|
||||
payload: user,
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUserFailure(user: User, error: Error): Action {
|
||||
return {
|
||||
type: DELETE_USER_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
user
|
||||
},
|
||||
itemId: user.name
|
||||
};
|
||||
}
|
||||
|
||||
function extractUsersByNames(
|
||||
users: User[],
|
||||
userNames: string[],
|
||||
oldUsersByNames: Object
|
||||
) {
|
||||
const usersByNames = {};
|
||||
|
||||
for (let user of users) {
|
||||
usersByNames[user.name] = user;
|
||||
}
|
||||
|
||||
for (let userName in oldUsersByNames) {
|
||||
usersByNames[userName] = oldUsersByNames[userName];
|
||||
}
|
||||
return usersByNames;
|
||||
}
|
||||
|
||||
function deleteUserInUsersByNames(users: {}, userName: string) {
|
||||
let newUsers = {};
|
||||
for (let username in users) {
|
||||
if (username !== userName) newUsers[username] = users[username];
|
||||
}
|
||||
return newUsers;
|
||||
}
|
||||
|
||||
function deleteUserInEntries(users: [], userName: string) {
|
||||
let newUsers = [];
|
||||
for (let user of users) {
|
||||
if (user !== userName) newUsers.push(user);
|
||||
}
|
||||
return newUsers;
|
||||
}
|
||||
|
||||
const reducerByName = (state: any, username: string, newUserState: any) => {
|
||||
const newUsersByNames = {
|
||||
...state,
|
||||
[username]: newUserState
|
||||
};
|
||||
|
||||
return newUsersByNames;
|
||||
};
|
||||
|
||||
function listReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
case FETCH_USERS_SUCCESS:
|
||||
const users = action.payload._embedded.users;
|
||||
const userNames = users.map(user => user.name);
|
||||
return {
|
||||
...state,
|
||||
entries: userNames,
|
||||
entry: {
|
||||
userCreatePermission: action.payload._links.create ? true : false,
|
||||
page: action.payload.page,
|
||||
pageTotal: action.payload.pageTotal,
|
||||
_links: action.payload._links
|
||||
}
|
||||
};
|
||||
|
||||
// Delete single user actions
|
||||
case DELETE_USER_SUCCESS:
|
||||
const newUserEntries = deleteUserInEntries(
|
||||
state.entries,
|
||||
action.payload.name
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
entries: newUserEntries
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function byNamesReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
// Fetch all users actions
|
||||
case FETCH_USERS_SUCCESS:
|
||||
const users = action.payload._embedded.users;
|
||||
const userNames = users.map(user => user.name);
|
||||
const byNames = extractUsersByNames(users, userNames, state.byNames);
|
||||
return {
|
||||
...byNames
|
||||
};
|
||||
|
||||
// Fetch single user actions
|
||||
case FETCH_USER_SUCCESS:
|
||||
return reducerByName(state, action.payload.name, action.payload);
|
||||
|
||||
case MODIFY_USER_SUCCESS:
|
||||
return reducerByName(state, action.payload.name, action.payload);
|
||||
|
||||
case DELETE_USER_SUCCESS:
|
||||
const newUserByNames = deleteUserInUsersByNames(
|
||||
state,
|
||||
action.payload.name
|
||||
);
|
||||
return newUserByNames;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
list: listReducer,
|
||||
byNames: byNamesReducer
|
||||
});
|
||||
|
||||
// selectors
|
||||
|
||||
const selectList = (state: Object) => {
|
||||
if (state.users && state.users.list) {
|
||||
return state.users.list;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const selectListEntry = (state: Object): Object => {
|
||||
const list = selectList(state);
|
||||
if (list.entry) {
|
||||
return list.entry;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const selectListAsCollection = (state: Object): PagedCollection => {
|
||||
return selectListEntry(state);
|
||||
};
|
||||
|
||||
export const isPermittedToCreateUsers = (state: Object): boolean => {
|
||||
const permission = selectListEntry(state).userCreatePermission;
|
||||
if (permission) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function getUsersFromState(state: Object) {
|
||||
const userNames = selectList(state).entries;
|
||||
if (!userNames) {
|
||||
return null;
|
||||
}
|
||||
const userEntries: User[] = [];
|
||||
|
||||
for (let userName of userNames) {
|
||||
userEntries.push(state.users.byNames[userName]);
|
||||
}
|
||||
|
||||
return userEntries;
|
||||
}
|
||||
|
||||
export function isFetchUsersPending(state: Object) {
|
||||
return isPending(state, FETCH_USERS);
|
||||
}
|
||||
|
||||
export function getFetchUsersFailure(state: Object) {
|
||||
return getFailure(state, FETCH_USERS);
|
||||
}
|
||||
|
||||
export function isCreateUserPending(state: Object) {
|
||||
return isPending(state, CREATE_USER);
|
||||
}
|
||||
|
||||
export function getCreateUserFailure(state: Object) {
|
||||
return getFailure(state, CREATE_USER);
|
||||
}
|
||||
|
||||
export function getUserByName(state: Object, name: string) {
|
||||
if (state.users && state.users.byNames) {
|
||||
return state.users.byNames[name];
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchUserPending(state: Object, name: string) {
|
||||
return isPending(state, FETCH_USER, name);
|
||||
}
|
||||
|
||||
export function getFetchUserFailure(state: Object, name: string) {
|
||||
return getFailure(state, FETCH_USER, name);
|
||||
}
|
||||
|
||||
export function isModifyUserPending(state: Object, name: string) {
|
||||
return isPending(state, MODIFY_USER, name);
|
||||
}
|
||||
|
||||
export function getModifyUserFailure(state: Object, name: string) {
|
||||
return getFailure(state, MODIFY_USER, name);
|
||||
}
|
||||
|
||||
export function isDeleteUserPending(state: Object, name: string) {
|
||||
return isPending(state, DELETE_USER, name);
|
||||
}
|
||||
|
||||
export function getDeleteUserFailure(state: Object, name: string) {
|
||||
return getFailure(state, DELETE_USER, name);
|
||||
}
|
||||
640
scm-ui/src/users/modules/users.test.js
Normal file
640
scm-ui/src/users/modules/users.test.js
Normal file
@@ -0,0 +1,640 @@
|
||||
//@flow
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import reducer, {
|
||||
CREATE_USER_FAILURE,
|
||||
CREATE_USER_PENDING,
|
||||
CREATE_USER_SUCCESS,
|
||||
createUser,
|
||||
DELETE_USER_FAILURE,
|
||||
DELETE_USER_PENDING,
|
||||
DELETE_USER_SUCCESS,
|
||||
deleteUser,
|
||||
deleteUserSuccess,
|
||||
FETCH_USER_FAILURE,
|
||||
FETCH_USER_PENDING,
|
||||
isFetchUserPending,
|
||||
FETCH_USER_SUCCESS,
|
||||
FETCH_USERS_FAILURE,
|
||||
FETCH_USERS_PENDING,
|
||||
FETCH_USERS_SUCCESS,
|
||||
fetchUser,
|
||||
fetchUserSuccess,
|
||||
getFetchUserFailure,
|
||||
fetchUsers,
|
||||
fetchUsersSuccess,
|
||||
isFetchUsersPending,
|
||||
selectListAsCollection,
|
||||
isPermittedToCreateUsers,
|
||||
MODIFY_USER,
|
||||
MODIFY_USER_FAILURE,
|
||||
MODIFY_USER_PENDING,
|
||||
MODIFY_USER_SUCCESS,
|
||||
modifyUser,
|
||||
modifyUserSuccess,
|
||||
getUsersFromState,
|
||||
FETCH_USERS,
|
||||
getFetchUsersFailure,
|
||||
FETCH_USER,
|
||||
CREATE_USER,
|
||||
isCreateUserPending,
|
||||
getCreateUserFailure,
|
||||
getUserByName,
|
||||
isModifyUserPending,
|
||||
getModifyUserFailure,
|
||||
DELETE_USER,
|
||||
isDeleteUserPending,
|
||||
getDeleteUserFailure
|
||||
} from "./users";
|
||||
|
||||
const userZaphod = {
|
||||
active: true,
|
||||
admin: true,
|
||||
creationDate: "2018-07-11T12:23:49.027Z",
|
||||
displayName: "Z. Beeblebrox",
|
||||
mail: "president@heartofgold.universe",
|
||||
name: "zaphod",
|
||||
password: "__dummypassword__",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
|
||||
},
|
||||
update: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const userFord = {
|
||||
active: true,
|
||||
admin: false,
|
||||
creationDate: "2018-07-06T13:21:18.459Z",
|
||||
displayName: "F. Prefect",
|
||||
mail: "ford@prefect.universe",
|
||||
name: "ford",
|
||||
password: "__dummypassword__",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
|
||||
},
|
||||
update: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const responseBody = {
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
|
||||
},
|
||||
first: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
|
||||
},
|
||||
last: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
|
||||
},
|
||||
create: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/users/"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
users: [userZaphod, userFord]
|
||||
}
|
||||
};
|
||||
|
||||
const response = {
|
||||
headers: { "content-type": "application/json" },
|
||||
responseBody
|
||||
};
|
||||
|
||||
const USERS_URL = "/scm/api/rest/v2/users";
|
||||
|
||||
const error = new Error("KAPUTT");
|
||||
|
||||
describe("users fetch()", () => {
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch users", () => {
|
||||
fetchMock.getOnce(USERS_URL, response);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_USERS_PENDING },
|
||||
{
|
||||
type: FETCH_USERS_SUCCESS,
|
||||
payload: response
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchUsers()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail getting users on HTTP 500", () => {
|
||||
fetchMock.getOnce(USERS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchUsers()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_USERS_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_USERS_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sucessfully fetch single user", () => {
|
||||
fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchUser("zaphod")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail fetching single user on HTTP 500", () => {
|
||||
fetchMock.getOnce(USERS_URL + "/zaphod", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchUser("zaphod")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should add a user successfully", () => {
|
||||
// unmatched
|
||||
fetchMock.postOnce(USERS_URL, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
// after create, the users are fetched again
|
||||
fetchMock.getOnce(USERS_URL, response);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_USER_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail adding a user on HTTP 500", () => {
|
||||
fetchMock.postOnce(USERS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_USER_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback after user successfully created", () => {
|
||||
// unmatched
|
||||
fetchMock.postOnce(USERS_URL, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let callMe = "not yet";
|
||||
|
||||
const callback = () => {
|
||||
callMe = "yeah";
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createUser(userZaphod, callback)).then(() => {
|
||||
expect(callMe).toBe("yeah");
|
||||
});
|
||||
});
|
||||
|
||||
it("successfully update user", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(modifyUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[0].type).toEqual(MODIFY_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_USER_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call callback, after successful modified user", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(modifyUser(userZaphod, callMe)).then(() => {
|
||||
expect(called).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail updating user on HTTP 500", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(modifyUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_USER_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_USER_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete successfully user zaphod", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
|
||||
expect(actions[0].payload).toBe(userZaphod);
|
||||
expect(actions[1].type).toEqual(DELETE_USER_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback, after successful delete", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteUser(userZaphod, callMe)).then(() => {
|
||||
expect(called).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to delete user zaphod", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteUser(userZaphod)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
|
||||
expect(actions[0].payload).toBe(userZaphod);
|
||||
expect(actions[1].type).toEqual(DELETE_USER_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("users reducer", () => {
|
||||
it("should update state correctly according to FETCH_USERS_SUCCESS action", () => {
|
||||
const newState = reducer({}, fetchUsersSuccess(responseBody));
|
||||
|
||||
expect(newState.list).toEqual({
|
||||
entries: ["zaphod", "ford"],
|
||||
entry: {
|
||||
userCreatePermission: true,
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: responseBody._links
|
||||
}
|
||||
});
|
||||
|
||||
expect(newState.byNames).toEqual({
|
||||
zaphod: userZaphod,
|
||||
ford: userFord
|
||||
});
|
||||
|
||||
expect(newState.list.entry.userCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set userCreatePermission to true if update link is present", () => {
|
||||
const newState = reducer({}, fetchUsersSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.userCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not replace whole byNames map when fetching users", () => {
|
||||
const oldState = {
|
||||
byNames: {
|
||||
ford: userFord
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(oldState, fetchUsersSuccess(responseBody));
|
||||
expect(newState.byNames["zaphod"]).toBeDefined();
|
||||
expect(newState.byNames["ford"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should remove user from state when delete succeeds", () => {
|
||||
const state = {
|
||||
list: {
|
||||
entries: ["ford", "zaphod"]
|
||||
},
|
||||
byNames: {
|
||||
zaphod: userZaphod,
|
||||
ford: userFord
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(state, deleteUserSuccess(userFord));
|
||||
expect(newState.byNames["zaphod"]).toBeDefined();
|
||||
expect(newState.byNames["ford"]).toBeFalsy();
|
||||
expect(newState.list.entries).toEqual(["zaphod"]);
|
||||
});
|
||||
|
||||
it("should set userCreatePermission to true if create link is present", () => {
|
||||
const newState = reducer({}, fetchUsersSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.userCreatePermission).toBeTruthy();
|
||||
expect(newState.list.entries).toEqual(["zaphod", "ford"]);
|
||||
expect(newState.byNames["ford"]).toBeTruthy();
|
||||
expect(newState.byNames["zaphod"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should update state according to FETCH_USER_SUCCESS action", () => {
|
||||
const newState = reducer({}, fetchUserSuccess(userFord));
|
||||
expect(newState.byNames["ford"]).toBe(userFord);
|
||||
});
|
||||
|
||||
it("should affect users state nor the state of other users", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
list: {
|
||||
entries: ["zaphod"]
|
||||
}
|
||||
},
|
||||
fetchUserSuccess(userFord)
|
||||
);
|
||||
expect(newState.byNames["ford"]).toBe(userFord);
|
||||
expect(newState.list.entries).toEqual(["zaphod"]);
|
||||
});
|
||||
|
||||
it("should update state according to MODIFY_USER_SUCCESS action", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
byNames: {
|
||||
ford: {
|
||||
name: "ford"
|
||||
}
|
||||
}
|
||||
},
|
||||
modifyUserSuccess(userFord)
|
||||
);
|
||||
expect(newState.byNames["ford"]).toBe(userFord);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selector tests", () => {
|
||||
it("should return an empty object", () => {
|
||||
expect(selectListAsCollection({})).toEqual({});
|
||||
expect(selectListAsCollection({ users: { a: "a" } })).toEqual({});
|
||||
});
|
||||
|
||||
it("should return a state slice collection", () => {
|
||||
const collection = {
|
||||
page: 3,
|
||||
totalPages: 42
|
||||
};
|
||||
|
||||
const state = {
|
||||
users: {
|
||||
list: {
|
||||
entry: collection
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(selectListAsCollection(state)).toBe(collection);
|
||||
});
|
||||
|
||||
it("should return false", () => {
|
||||
expect(isPermittedToCreateUsers({})).toBe(false);
|
||||
expect(isPermittedToCreateUsers({ users: { list: { entry: {} } } })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPermittedToCreateUsers({
|
||||
users: { list: { entry: { userCreatePermission: false } } }
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
const state = {
|
||||
users: {
|
||||
list: {
|
||||
entry: {
|
||||
userCreatePermission: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(isPermittedToCreateUsers(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should get users from state", () => {
|
||||
const state = {
|
||||
users: {
|
||||
list: {
|
||||
entries: ["a", "b"]
|
||||
},
|
||||
byNames: {
|
||||
a: { name: "a" },
|
||||
b: { name: "b" }
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(getUsersFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
|
||||
});
|
||||
|
||||
it("should return true, when fetch users is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_USERS]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchUsersPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch users is not pending", () => {
|
||||
expect(isFetchUsersPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch users did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_USERS]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchUsersFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch users did not fail", () => {
|
||||
expect(getFetchUsersFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true if create user is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[CREATE_USER]: true
|
||||
}
|
||||
};
|
||||
expect(isCreateUserPending(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if create user is not pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[CREATE_USER]: false
|
||||
}
|
||||
};
|
||||
expect(isCreateUserPending(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return error when create user did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[CREATE_USER]: error
|
||||
}
|
||||
};
|
||||
expect(getCreateUserFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when create user did not fail", () => {
|
||||
expect(getCreateUserFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return user ford", () => {
|
||||
const state = {
|
||||
users: {
|
||||
byNames: {
|
||||
ford: userFord
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(getUserByName(state, "ford")).toEqual(userFord);
|
||||
});
|
||||
|
||||
it("should return true, when fetch user zaphod is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_USER + "/zaphod"]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchUserPending(state, "zaphod")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch user zaphod is not pending", () => {
|
||||
expect(isFetchUserPending({}, "zaphod")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch user zaphod did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_USER + "/zaphod"]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchUserFailure(state, "zaphod")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch user zaphod did not fail", () => {
|
||||
expect(getFetchUserFailure({}, "zaphod")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true, when modify user ford is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[MODIFY_USER + "/ford"]: true
|
||||
}
|
||||
};
|
||||
expect(isModifyUserPending(state, "ford")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when modify user ford is not pending", () => {
|
||||
expect(isModifyUserPending({}, "ford")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when modify user ford did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[MODIFY_USER + "/ford"]: error
|
||||
}
|
||||
};
|
||||
expect(getModifyUserFailure(state, "ford")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when modify user ford did not fail", () => {
|
||||
expect(getModifyUserFailure({}, "ford")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true, when delete user zaphod is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[DELETE_USER + "/zaphod"]: true
|
||||
}
|
||||
};
|
||||
expect(isDeleteUserPending(state, "zaphod")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when delete user zaphod is not pending", () => {
|
||||
expect(isDeleteUserPending({}, "zaphod")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when delete user zaphod did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[DELETE_USER + "/zaphod"]: error
|
||||
}
|
||||
};
|
||||
expect(getDeleteUserFailure(state, "zaphod")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when delete user zaphod did not fail", () => {
|
||||
expect(getDeleteUserFailure({}, "zaphod")).toBe(undefined);
|
||||
});
|
||||
});
|
||||
12
scm-ui/src/users/types/User.js
Normal file
12
scm-ui/src/users/types/User.js
Normal file
@@ -0,0 +1,12 @@
|
||||
//@flow
|
||||
import type { Links } from "../../types/hal";
|
||||
|
||||
export type User = {
|
||||
displayName: string,
|
||||
name: string,
|
||||
mail: string,
|
||||
password: string,
|
||||
admin: boolean,
|
||||
active: boolean,
|
||||
_links: Links
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user